Files
hyperframes/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
T
James Russo 5bf61d6df0 feat(aws-lambda): support plan protocol v2 (#2789)
* feat(aws-lambda): support plan protocol v2

* fix(aws-lambda): align SAM v2 terminal errors
2026-07-25 23:42:51 -04:00

493 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `HyperframesRenderStack` — aws-cdk-lib L2 construct that emits the same
* topology as `examples/aws-lambda/template.yaml`.
*
* Adopters who embed HyperFrames inside their own CDK app can extend this
* construct or compose alongside it; the construct exposes its `.bucket`,
* `.renderFunction`, and `.stateMachine` properties so additional
* resources (alarms, dashboards, SNS topics) can be wired without
* re-deriving the ARNs from a stack export.
*
* `aws-cdk-lib` and `constructs` are **peerDependencies**. The package
* still type-checks (and the snapshot test still runs) because they're
* also `devDependencies`, but adopters who only consume the SDK side of
* `@hyperframes/aws-lambda` don't pull the CDK tree at runtime.
*
* Drift from the SAM template is guarded by the snapshot test
* (`HyperframesRenderStack.snapshot.test.ts`), which diffs the synthed
* CloudFormation against the SAM-rendered CloudFormation modulo
* normalisation.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Duration, RemovalPolicy, Size } from "aws-cdk-lib";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as logs from "aws-cdk-lib/aws-logs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as sfn from "aws-cdk-lib/aws-stepfunctions";
import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks";
import { Construct } from "constructs";
/** Construction-time props for {@link HyperframesRenderStack}. */
export interface HyperframesRenderStackProps {
/** Name prefix applied to function / state-machine / alarm names. Default `"hyperframes"`. */
projectName?: string;
/** Lambda memory in MB. Allowed: 2048..10240 in 1024 steps. Default 10240. */
lambdaMemoryMb?: 2048 | 3072 | 4096 | 5120 | 6144 | 7168 | 8192 | 9216 | 10240;
/** Per-invocation Lambda timeout. Default 900 (15 min, Lambda hard cap). */
lambdaTimeoutSec?: number;
/** Lambda reserved concurrency cap. `undefined` = unreserved (account default). */
reservedConcurrency?: number;
/** Which Chrome runtime was bundled into the handler ZIP. Default `"sparticuz"`. */
chromeSource?: "sparticuz" | "chrome-headless-shell";
/** Threshold for the runaway-invocations alarm. Default 1000 invocations/hour. */
chunkInvocationAlarmThreshold?: number;
/**
* Absolute path to the handler ZIP produced by
* `bun run --cwd packages/aws-lambda build:zip`. Defaults to the
* package-relative path the build script writes to. Adopters who
* deploy the published handler ZIP set this explicitly.
*/
handlerZipPath?: string;
/** S3 bucket retention policy on stack delete. Default RETAIN. */
bucketRemovalPolicy?: RemovalPolicy;
}
const DEFAULT_MEMORY_MB = 10240;
const DEFAULT_TIMEOUT_SEC = 900;
const DEFAULT_CHROME_SOURCE = "sparticuz";
const DEFAULT_ALARM_THRESHOLD = 1000;
export class HyperframesRenderStack extends Construct {
/** S3 bucket for plan tarballs, chunk outputs, and final renders. */
readonly bucket: s3.Bucket;
/** The single Lambda function dispatching plan / renderChunk / assemble. */
readonly renderFunction: lambda.Function;
/** The Step Functions state machine orchestrating the render. */
readonly stateMachine: sfn.StateMachine;
constructor(scope: Construct, id: string, props: HyperframesRenderStackProps = {}) {
super(scope, id);
const projectName = props.projectName ?? "hyperframes";
const memorySize = props.lambdaMemoryMb ?? DEFAULT_MEMORY_MB;
const timeoutSec = props.lambdaTimeoutSec ?? DEFAULT_TIMEOUT_SEC;
const chromeSource = props.chromeSource ?? DEFAULT_CHROME_SOURCE;
const alarmThreshold = props.chunkInvocationAlarmThreshold ?? DEFAULT_ALARM_THRESHOLD;
const handlerZipPath = props.handlerZipPath ?? defaultHandlerZipPath();
this.bucket = new s3.Bucket(this, "RenderBucket", {
removalPolicy: props.bucketRemovalPolicy ?? RemovalPolicy.RETAIN,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
// `Suspended` is the cheapest mode that still satisfies KMS / replication
// prerequisites callers can layer on later. Adopters who treat the final
// mp4 as user-keepable can switch to `Enabled`.
versioned: false,
lifecycleRules: [
{
id: "ExpireIntermediates",
enabled: true,
prefix: "renders/",
expiration: Duration.days(7),
},
],
});
this.renderFunction = new lambda.Function(this, "RenderFunction", {
functionName: `${projectName}-render`,
runtime: lambda.Runtime.NODEJS_22_X,
handler: "handler.handler",
code: lambda.Code.fromAsset(handlerZipPath),
memorySize,
timeout: Duration.seconds(timeoutSec),
ephemeralStorageSize: Size.gibibytes(10),
architecture: lambda.Architecture.X86_64,
reservedConcurrentExecutions: props.reservedConcurrency,
tracing: lambda.Tracing.ACTIVE,
environment: {
NODE_OPTIONS: "--enable-source-maps",
TMPDIR: "/tmp",
HYPERFRAMES_LAMBDA_CHROME_SOURCE: chromeSource,
HYPERFRAMES_RENDER_BUCKET: this.bucket.bucketName,
},
});
// Scoped S3 perms only — explicitly NOT `CloudWatchLogsFullAccess`,
// which would grant `logs:*` on `*` and overscope adopter accounts.
// SAM's AWSLambdaBasicExecutionRole equivalent is included by the
// default `new lambda.Function` execution role.
this.bucket.grantReadWrite(this.renderFunction);
const stateMachineLogGroup = new logs.LogGroup(this, "RenderStateMachineLogGroup", {
logGroupName: `/aws/states/${projectName}-render`,
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
});
const definition = this.buildStateMachineDefinition();
this.stateMachine = new sfn.StateMachine(this, "RenderStateMachine", {
stateMachineName: `${projectName}-render`,
stateMachineType: sfn.StateMachineType.STANDARD,
definitionBody: sfn.DefinitionBody.fromChainable(definition),
tracingEnabled: true,
timeout: Duration.hours(1),
logs: {
destination: stateMachineLogGroup,
level: sfn.LogLevel.ERROR,
includeExecutionData: false,
},
});
this.renderFunction.grantInvoke(this.stateMachine);
new cloudwatch.Alarm(this, "RenderChunkInvocationAlarm", {
alarmName: `${projectName}-runaway-chunk-invocations`,
alarmDescription:
"Fires if RenderChunk Lambda invocations exceed the configured threshold in a 1-hour window.",
metric: this.renderFunction.metricInvocations({
period: Duration.hours(1),
statistic: cloudwatch.Stats.SUM,
}),
threshold: alarmThreshold,
evaluationPeriods: 1,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
new cloudwatch.Alarm(this, "RenderFunctionErrorsAlarm", {
alarmName: `${projectName}-render-function-errors`,
alarmDescription: "Fires if the render Lambda reports any errors in a 5-minute window.",
metric: this.renderFunction.metricErrors({
period: Duration.minutes(5),
statistic: cloudwatch.Stats.SUM,
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
new cloudwatch.Alarm(this, "RenderStateMachineFailedAlarm", {
alarmName: `${projectName}-render-state-machine-failed`,
alarmDescription: "Fires when the render state machine reports a failed execution.",
metric: this.stateMachine.metricFailed({
period: Duration.minutes(5),
statistic: cloudwatch.Stats.SUM,
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
}
/**
* Build the state-machine chain. Kept in a single method so the SAM
* template and this construct can be diffed shape-for-shape during
* the snapshot test.
*/
private buildStateMachineDefinition(): sfn.IChainable {
// `ChromeBinaryUnavailableError` is non-retryable: a wedged warm
// instance keeps returning the same falsy executablePath until the
// env recycles, so retries just burn the 4× 15-min budget.
const NON_RETRYABLE_PLAN = [
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError",
];
const NON_RETRYABLE_CHUNK = [
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
const NON_RETRYABLE_ASSEMBLE = [
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
const plan = new tasks.LambdaInvoke(this, "Plan", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v1",
"PlanS3Uri.$": "$.Payload.PlanS3Uri",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
"Format.$": "$.Payload.Format",
"HasAudio.$": "$.Payload.HasAudio",
"AudioS3Uri.$": "$.Payload.AudioS3Uri",
},
resultPath: "$.Plan",
});
plan.addRetry({
errors: NON_RETRYABLE_PLAN,
maxAttempts: 0,
});
plan.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const planV2 = new tasks.LambdaInvoke(this, "PlanV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
PlanProtocol: "v2",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Payload.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Payload.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
"Format.$": "$.Payload.Format",
"HasAudio.$": "$.Payload.HasAudio",
},
resultPath: "$.Plan",
});
planV2.addRetry({ errors: NON_RETRYABLE_PLAN, maxAttempts: 0 });
planV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const buildChunkList = new sfn.Pass(this, "BuildChunkList", {
parameters: {
"ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)",
},
resultPath: "$.Iterator",
});
const planProducedZero = new sfn.Fail(this, "PlanProducedZeroChunks", {
error: "PLAN_TOO_LARGE",
cause: "Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.",
});
const renderChunkTask = new tasks.LambdaInvoke(this, "RenderChunk", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "renderChunk",
"ChunkIndex.$": "$.ChunkIndex",
"PlanS3Uri.$": "$.PlanS3Uri",
"PlanHash.$": "$.PlanHash",
"ChunkOutputS3Prefix.$": "$.ChunkOutputS3Prefix",
"Format.$": "$.Format",
}),
resultSelector: {
"ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
"ChunkIndex.$": "$.Payload.ChunkIndex",
"Sha256.$": "$.Payload.Sha256",
},
});
renderChunkTask.addRetry({
errors: NON_RETRYABLE_CHUNK,
maxAttempts: 0,
});
renderChunkTask.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const renderChunks = new sfn.Map(this, "RenderChunks", {
itemsPath: "$.Iterator.ChunkIndexes",
itemSelector: {
"ChunkIndex.$": "$$.Map.Item.Value",
"PlanS3Uri.$": "$.Plan.PlanS3Uri",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Format.$": "$.Plan.Format",
},
maxConcurrencyPath: "$.Plan.ChunkCount",
resultPath: "$.Chunks",
});
renderChunks.itemProcessor(renderChunkTask);
const assemble = new tasks.LambdaInvoke(this, "Assemble", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "assemble",
"PlanS3Uri.$": "$.Plan.PlanS3Uri",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
"AudioS3Uri.$": "$.Plan.AudioS3Uri",
"OutputS3Uri.$": "$.OutputS3Uri",
"Format.$": "$.Plan.Format",
}),
resultSelector: {
"OutputS3Uri.$": "$.Payload.OutputS3Uri",
"FramesEncoded.$": "$.Payload.FramesEncoded",
"FileSize.$": "$.Payload.FileSize",
},
resultPath: "$.Output",
});
assemble.addRetry({
errors: NON_RETRYABLE_ASSEMBLE,
maxAttempts: 0,
});
assemble.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const renderChunkV2Task = new tasks.LambdaInvoke(this, "RenderChunkV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "renderChunk",
PlanProtocol: "v2",
"ChunkIndex.$": "$.ChunkIndex",
"PlanV2ManifestS3Uri.$": "$.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.PlanHash",
"ChunkOutputS3Prefix.$": "$.ChunkOutputS3Prefix",
"Format.$": "$.Format",
}),
resultSelector: {
"ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
"ChunkIndex.$": "$.Payload.ChunkIndex",
"Sha256.$": "$.Payload.Sha256",
},
});
renderChunkV2Task.addRetry({ errors: NON_RETRYABLE_CHUNK, maxAttempts: 0 });
renderChunkV2Task.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const renderChunksV2 = new sfn.Map(this, "RenderChunksV2", {
itemsPath: "$.Iterator.ChunkIndexes",
itemSelector: {
"ChunkIndex.$": "$$.Map.Item.Value",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Format.$": "$.Plan.Format",
},
maxConcurrencyPath: "$.Plan.ChunkCount",
resultPath: "$.Chunks",
});
renderChunksV2.itemProcessor(renderChunkV2Task);
const assembleV2 = new tasks.LambdaInvoke(this, "AssembleV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "assemble",
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
AudioS3Uri: null,
"OutputS3Uri.$": "$.OutputS3Uri",
"Format.$": "$.Plan.Format",
}),
resultSelector: {
"OutputS3Uri.$": "$.Payload.OutputS3Uri",
"FramesEncoded.$": "$.Payload.FramesEncoded",
"FileSize.$": "$.Payload.FileSize",
},
resultPath: "$.Output",
});
assembleV2.addRetry({ errors: NON_RETRYABLE_ASSEMBLE, maxAttempts: 0 });
assembleV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol")
.when(
sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"),
renderChunksV2.next(assembleV2),
)
.otherwise(renderChunks.next(assemble));
const assertChunkCount = new sfn.Choice(this, "AssertChunkCount")
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), selectWorkerProtocol)
.otherwise(planProducedZero);
plan.next(buildChunkList);
planV2.next(buildChunkList);
buildChunkList.next(assertChunkCount);
const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", {
error: "PLAN_PROTOCOL_UNSUPPORTED",
cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v1).',
});
return new sfn.Choice(this, "SelectPlanProtocol")
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2)
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan)
.when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol)
.otherwise(plan);
}
}
/**
* Default location of the handler ZIP relative to this source file. Two
* parents up = `packages/aws-lambda/`; the build script writes the ZIP
* to `packages/aws-lambda/dist/handler.zip`. The package is published with
* `main: "./src/index.ts"`, so this path resolves correctly both in the
* source tree (during `bun test` / local CDK synth) and in a consumer's
* `node_modules/@hyperframes/aws-lambda/` install.
*/
function defaultHandlerZipPath(): string {
const here = dirname(fileURLToPath(import.meta.url));
return resolve(here, "..", "..", "dist", "handler.zip");
}