mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
* feat(aws-lambda): support plan protocol v2 * fix(aws-lambda): align SAM v2 terminal errors
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
/**
|
||
* `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");
|
||
}
|