feat(lambda): add TypeScript SDK and CDK construct (#909)

* feat(lambda): add TypeScript SDK and CDK construct

Adds the client-side surface on top of the Phase 6a Lambda handler so
adopters can drive a deployed stack from Node without writing AWS-SDK
boilerplate:

- renderToLambda(opts) starts a Step Functions execution and returns a
  handle. Does NOT poll.
- getRenderProgress({ executionArn }) returns a snapshot of progress,
  frames rendered, cost (Lambda GB-seconds + SFN transitions), errors,
  and the final output object once Assemble completes.
- deploySite({ projectDir, bucketName }) content-addresses the project
  tree, tar.gzs it, and uploads to S3 with a HeadObject short-circuit so
  re-renders of the same tree skip the tar+PUT.
- validateDistributedRenderConfig throws a typed InvalidConfigError
  before StartExecution, so shape errors surface synchronously.
- computeRenderCost is exposed for callers who want to format cost out
  of band.

Also ships HyperframesRenderStack, an aws-cdk-lib L2 construct that
emits the same topology as examples/aws-lambda/template.yaml. Lives on
the ./cdk subpath export so SDK-only consumers don't pull aws-cdk-lib
into their runtime graph (declared as an optional peer dependency).

Tests: 24 new unit tests across the SDK plus 9 CDK synth / contract /
snapshot tests. All 83 tests in packages/aws-lambda/src pass.

* refactor(lambda): /simplify pass on the SDK + CDK PR

Pulls shared logic out so the SDK doesn't re-invent things the handler
and the producer already have:

- `formatExtension` extracted to packages/aws-lambda/src/formatExtension.ts.
  handler.ts and renderToLambda.ts both used identical 12-line copies of
  this switch.
- `PLAN_PROJECT_DIR_SKIP_SEGMENTS` is now exported from
  @hyperframes/producer/distributed. deploySite consumes it instead of
  its own duplicate SKIP_TOP_LEVEL set; the two lists were trivially
  identical and would have drifted silently.
- `FakeS3` + `drainBody` factored out of the two SDK test files into
  src/sdk/__fixtures__/fakeS3.ts. Drops ~110 lines of test-file
  duplication and gives future SDK tests a one-line FakeS3 import.
- S3 URI building in deploySite and renderToLambda routes through the
  existing `formatS3Uri` helper instead of inline `s3://...`
  concatenation; matches the convention already in handler.ts.

Net -133 lines across the touched files. All 83 aws-lambda tests still
pass; all 60 producer distributed tests still pass.

* fix(lambda): bump CDK test timeouts for CI cold-start synth

The bun:test default 5s timeout tripped the first CDK snapshot test
in CI when the cold-start `Template.fromStack(stack)` synth took ~5-8s
on the slowest GitHub Actions runner. Locally on a warm shell the
synth measures <1s, so the failure didn't reproduce until PR #909 hit
CI.

Two changes:

  - Both CDK test files cache one synth in `beforeAll(..., 30000)` and
    reuse the result across every test that uses the default props.
    Each individual test now runs in microseconds (pure assertions
    against the already-synthed template), so the 5s timeout no longer
    applies on the hot path.

  - The two contract tests that exercise non-default props
    (reservedConcurrency, projectName) still synth fresh per-test; they
    get a per-test `it(..., 30000)` timeout.

No behavior changes.

* fix(lambda): address PR review on SDK + CDK construct

Three correctness + ergonomics fixes raised in Vai's review:

  - getRenderProgress over-counted SFN transitions by 3-5×. Step
    Functions Standard Workflows bill per state-entry, not per
    history event. Each Task produces ~5-7 history events
    (Scheduled / Started / Succeeded / TaskStateExited / …);
    counting `events.length` reported the runaway. Switch to
    counting `*StateEntered` events explicitly.

  - assembleComplete + outputFile detection was coupled to the
    Lambda payload's `Action` field. Move both signals onto the
    enclosing state name (`StateExited.name === "Assemble"`), which
    is the state-machine identity rather than the Lambda event
    contract. framesRendered increment moves to the same boundary
    (RenderChunk state).

  - SiteHandle now carries `bucketName` directly so README + CLI
    callers don't have to re-parse `projectS3Uri.split("/")[2]`.

Test updates: getRenderProgress tests wrap renderChunk/assemble
events in matching StateEntered + StateExited pairs so the new
state-name-driven dispatch is exercised end-to-end. SiteHandle
fixture in renderToLambda.test.ts gets the new bucketName field.

All 83 aws-lambda tests still pass.
This commit is contained in:
James Russo
2026-05-17 03:03:51 -04:00
committed by GitHub
parent 0d726ae010
commit 34d1f0e1d0
23 changed files with 2662 additions and 47 deletions
+68 -7
View File
@@ -24,6 +24,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
"@hyperframes/producer": "workspace:^", "@hyperframes/producer": "workspace:^",
"@sparticuz/chromium": "148.0.0", "@sparticuz/chromium": "148.0.0",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
@@ -35,14 +36,24 @@
"@types/aws-lambda": "^8.10.146", "@types/aws-lambda": "^8.10.146",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/tar": "^6.1.13", "@types/tar": "^6.1.13",
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
}, },
"peerDependencies": {
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0",
},
"optionalPeers": [
"aws-cdk-lib",
"constructs",
],
}, },
"packages/cli": { "packages/cli": {
"name": "@hyperframes/cli", "name": "@hyperframes/cli",
"version": "0.6.7", "version": "0.6.14",
"bin": { "bin": {
"hyperframes": "./dist/cli.js", "hyperframes": "./dist/cli.js",
}, },
@@ -85,7 +96,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@hyperframes/core", "name": "@hyperframes/core",
"version": "0.6.7", "version": "0.6.14",
"dependencies": { "dependencies": {
"@chenglou/pretext": "^0.0.5", "@chenglou/pretext": "^0.0.5",
"postcss": "^8.5.8", "postcss": "^8.5.8",
@@ -112,7 +123,7 @@
}, },
"packages/engine": { "packages/engine": {
"name": "@hyperframes/engine", "name": "@hyperframes/engine",
"version": "0.6.7", "version": "0.6.14",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^", "@hyperframes/core": "workspace:^",
@@ -130,7 +141,7 @@
}, },
"packages/player": { "packages/player": {
"name": "@hyperframes/player", "name": "@hyperframes/player",
"version": "0.6.7", "version": "0.6.14",
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.0", "@types/bun": "^1.1.0",
"gsap": "^3.12.5", "gsap": "^3.12.5",
@@ -142,7 +153,7 @@
}, },
"packages/producer": { "packages/producer": {
"name": "@hyperframes/producer", "name": "@hyperframes/producer",
"version": "0.6.7", "version": "0.6.14",
"dependencies": { "dependencies": {
"@fontsource/archivo-black": "^5.2.8", "@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7", "@fontsource/eb-garamond": "^5.2.7",
@@ -181,7 +192,7 @@
}, },
"packages/shader-transitions": { "packages/shader-transitions": {
"name": "@hyperframes/shader-transitions", "name": "@hyperframes/shader-transitions",
"version": "0.6.7", "version": "0.6.14",
"dependencies": { "dependencies": {
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
}, },
@@ -193,7 +204,7 @@
}, },
"packages/studio": { "packages/studio": {
"name": "@hyperframes/studio", "name": "@hyperframes/studio",
"version": "0.6.7", "version": "0.6.14",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
@@ -251,6 +262,14 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@aws-cdk/asset-awscli-v1": ["@aws-cdk/asset-awscli-v1@2.2.273", "", {}, "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg=="],
"@aws-cdk/asset-node-proxy-agent-v6": ["@aws-cdk/asset-node-proxy-agent-v6@2.1.2", "", {}, "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q=="],
"@aws-cdk/cloud-assembly-api": ["@aws-cdk/cloud-assembly-api@2.2.3", "", { "dependencies": { "jsonschema": "~1.4.1", "semver": "^7.7.4" }, "peerDependencies": { "@aws-cdk/cloud-assembly-schema": ">=53.21.0" } }, "sha512-XxuKjZ8j1Kkqi3tJaevO9DMWWKfen9QBwTUlgdPKyT4XDDy07r9MnqgeUC1vcpskoc3eeXItZHgqdVuaddleRQ=="],
"@aws-cdk/cloud-assembly-schema": ["@aws-cdk/cloud-assembly-schema@53.24.0", "", { "dependencies": { "jsonschema": "~1.4.1", "semver": "^7.8.0" } }, "sha512-rCwsd0Y9z4CUmV5FhzjbO2MprXjKG0rxCACuLEY40lD+wInvgcHer0lSDo7Xq9Sxe/IoNe/Wxb2YP3GgxvT3GA=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
@@ -267,6 +286,8 @@
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1048.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/middleware-bucket-endpoint": "^3.972.13", "@aws-sdk/middleware-expect-continue": "^3.972.12", "@aws-sdk/middleware-flexible-checksums": "^3.974.19", "@aws-sdk/middleware-location-constraint": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.40", "@aws-sdk/middleware-ssec": "^3.972.10", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-SrJn5FteqqtcDBgQIvqLKk3Qn/2vSsi5XR03I53EDDR4CbCdLysVSNgUnjVncEECMua9Pz+nxO0/lEx3TP+6mA=="], "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1048.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/middleware-bucket-endpoint": "^3.972.13", "@aws-sdk/middleware-expect-continue": "^3.972.12", "@aws-sdk/middleware-flexible-checksums": "^3.974.19", "@aws-sdk/middleware-location-constraint": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.40", "@aws-sdk/middleware-ssec": "^3.972.10", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-SrJn5FteqqtcDBgQIvqLKk3Qn/2vSsi5XR03I53EDDR4CbCdLysVSNgUnjVncEECMua9Pz+nxO0/lEx3TP+6mA=="],
"@aws-sdk/client-sfn": ["@aws-sdk/client-sfn@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-f+F2WkFX9HOqd7Wq7phc45olVkd8jpTgbZmxPvj2ItixrgT/XiobZn6vLmUF+b/2f0wO074yFT3p2go8If7K9Q=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug=="], "@aws-sdk/core": ["@aws-sdk/core@3.974.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA=="], "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA=="],
@@ -353,6 +374,8 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
@@ -995,8 +1018,12 @@
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
"aws-cdk-lib": ["aws-cdk-lib@2.254.0", "", { "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.273", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", "@aws-cdk/cloud-assembly-api": "^2.2.3", "@aws-cdk/cloud-assembly-schema": "^53.21.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.3", "ignore": "^5.3.2", "jsonschema": "^1.5.0", "mime-types": "^2.1.35", "minimatch": "^10.2.3", "punycode": "^2.3.1", "semver": "^7.7.4", "table": "^6.9.0", "yaml": "1.10.3" }, "peerDependencies": { "constructs": "^10.5.0" } }, "sha512-O7fn6fu1FXb0BgoO/+WeiBXZ16H/q6z4fOndjdG62c9bPU7Z/UeW5gz7qBiwLm4ERvTObobLCqv7wjd8bp+v3A=="],
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], "b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
@@ -1057,6 +1084,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"case": ["case@1.6.3", "", {}, "sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ=="],
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -1095,6 +1124,8 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"constructs": ["constructs@10.6.0", "", {}, "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ=="],
"conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="], "conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="],
"conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw=="], "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw=="],
@@ -1261,6 +1292,8 @@
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
"fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -1299,6 +1332,8 @@
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gsap": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="], "gsap": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
"happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="],
@@ -1325,6 +1360,8 @@
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@@ -1393,6 +1430,10 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonschema": ["jsonschema@1.5.0", "", {}, "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
@@ -1431,6 +1472,8 @@
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
@@ -1647,6 +1690,8 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
@@ -1693,6 +1738,8 @@
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], "tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
@@ -1763,6 +1810,8 @@
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -1827,6 +1876,12 @@
"zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="],
"@aws-cdk/cloud-assembly-api/jsonschema": ["jsonschema@1.4.1", "", {}, "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ=="],
"@aws-cdk/cloud-assembly-schema/jsonschema": ["jsonschema@1.4.1", "", { "bundled": true }, "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ=="],
"@aws-cdk/cloud-assembly-schema/semver": ["semver@7.8.0", "", { "bundled": true, "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1847,6 +1902,10 @@
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"aws-cdk-lib/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"aws-cdk-lib/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -1929,6 +1988,8 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"aws-cdk-lib/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
+92 -14
View File
@@ -1,14 +1,20 @@
# @hyperframes/aws-lambda # @hyperframes/aws-lambda
AWS Lambda adapter for HyperFrames distributed rendering. Wraps the OSS AWS Lambda adapter for HyperFrames distributed rendering. Ships three
`plan` / `renderChunk` / `assemble` primitives into a single Lambda handler things together:
that Step Functions can dispatch on, plus a build pipeline that bundles
the handler + Chrome runtime + ffmpeg into a deployable ZIP.
The Lambda adapter ships in two parts: the foundation (this package + the 1. The **Lambda handler** that wraps the OSS `plan` / `renderChunk` /
SAM example) validates the architecture end-to-end on real AWS; the `assemble` primitives behind a single dispatch boundary Step Functions
user-facing surface (CLI, CDK construct, migration guide) lands in can drive (`src/handler.ts`).
follow-up PRs. 2. A **client-side SDK**`renderToLambda`, `getRenderProgress`,
`deploySite`, plus `validateDistributedRenderConfig` and
`computeRenderCost` (`src/sdk/`).
3. An **`aws-cdk-lib` L2 construct** (`HyperframesRenderStack`) that
provisions the same topology as `examples/aws-lambda/template.yaml`
inside an adopter's own CDK app (`src/cdk/`).
The handler ZIP and the SAM template still drive a maintainer-run real-AWS
smoke flow; the SDK + CDK are the supported public surface for adopters.
## Architecture ## Architecture
@@ -104,10 +110,82 @@ bun run --cwd packages/aws-lambda test # unit tests (no Chrome)
bun run --cwd packages/aws-lambda probe:beginframe # local probe (Linux only) bun run --cwd packages/aws-lambda probe:beginframe # local probe (Linux only)
``` ```
## What's NOT in this PR ## Using the SDK
- `examples/aws-lambda/template.yaml` (SAM template — separate PR). After deploying the stack (via the SAM template, CDK construct below, or
- Real-AWS deploy + smoke workflow (separate PR). your own CFN of choice), drive renders from Node:
- `npx hyperframes lambda deploy` CLI — follow-up.
- CDK construct (`HyperframesRenderStack`) — follow-up. ```ts
- Migration guide — follow-up. import { deploySite, getRenderProgress, renderToLambda } from "@hyperframes/aws-lambda";
// One-time upload per project version.
const site = await deploySite({
projectDir: "./my-composition",
bucketName: "hyperframes-render-bucket",
});
// Start a render. Returns immediately — does NOT poll.
const handle = await renderToLambda({
siteHandle: site,
bucketName: site.bucketName,
stateMachineArn: "arn:aws:states:us-east-1:123:stateMachine:hyperframes-render",
config: {
fps: 30,
width: 1920,
height: 1080,
format: "mp4",
chunkSize: 240,
maxParallelChunks: 16,
runtimeCap: "lambda",
},
});
// Poll progress + cost on your own cadence.
const progress = await getRenderProgress({ executionArn: handle.executionArn });
console.log(progress.overallProgress, progress.costs.displayCost);
if (progress.status === "SUCCEEDED" && progress.outputFile) {
console.log("Render landed at", progress.outputFile.s3Uri);
}
```
`renderToLambda` validates the config client-side via
`validateDistributedRenderConfig` and throws a typed `InvalidConfigError`
before the Step Functions execution starts, so shape errors surface
synchronously instead of as opaque `ExecutionFailed` results.
`getRenderProgress` reports an approximate per-render cost
(`accruedSoFarUsd` plus a formatted `displayCost`) derived from Lambda
billed-duration × memory × the us-east-1 on-demand rate plus the Step
Functions transition price. The math is documented in
`src/sdk/costAccounting.ts`; numbers are best-effort and exclude S3
transfer.
## Using the CDK construct
```ts
import { App, Stack } from "aws-cdk-lib";
import { HyperframesRenderStack } from "@hyperframes/aws-lambda/cdk";
const app = new App();
const stack = new Stack(app, "MyApp");
const render = new HyperframesRenderStack(stack, "Render", {
// optional: reservedConcurrency: 8,
// optional: lambdaMemoryMb: 10240,
// optional: chromeSource: "sparticuz",
});
// Re-export so an adopter app can wire dashboards / SNS topics.
new CfnOutput(stack, "RenderBucketName", { value: render.bucket.bucketName });
new CfnOutput(stack, "StateMachineArn", { value: render.stateMachine.stateMachineArn });
```
`aws-cdk-lib` and `constructs` are **optional peer dependencies**: SDK-only
consumers don't pull them at runtime. The construct itself imports from
`@hyperframes/aws-lambda/cdk`.
## What's still ahead
- `hyperframes lambda` CLI (deploy / sites create / render / progress / destroy) — PR 6.5.
- IAM bootstrap subcommand (`policies role | user | validate`) — PR 6.9.
- Lambda-local regression harness (`--mode=lambda-local`) — PR 6.6.
- Adopter-facing migration guide — PR 6.8.
+18 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@hyperframes/aws-lambda", "name": "@hyperframes/aws-lambda",
"version": "0.0.1", "version": "0.0.1",
"description": "AWS Lambda adapter for HyperFrames distributed rendering — Plan/RenderChunk/Assemble handler + ZIP bundling.", "description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/heygen-com/hyperframes", "url": "https://github.com/heygen-com/hyperframes",
@@ -17,7 +17,8 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./handler": "./src/handler.ts" "./handler": "./src/handler.ts",
"./cdk": "./src/cdk/index.ts"
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
@@ -34,6 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
"@hyperframes/producer": "workspace:^", "@hyperframes/producer": "workspace:^",
"@sparticuz/chromium": "148.0.0", "@sparticuz/chromium": "148.0.0",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
@@ -45,10 +47,24 @@
"@types/aws-lambda": "^8.10.146", "@types/aws-lambda": "^8.10.146",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/tar": "^6.1.13", "@types/tar": "^6.1.13",
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": {
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0"
},
"peerDependenciesMeta": {
"aws-cdk-lib": {
"optional": true
},
"constructs": {
"optional": true
}
},
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} }
@@ -0,0 +1,137 @@
/**
* Contract tests for {@link HyperframesRenderStack}.
*
* The snapshot test (in this directory's sibling `.snapshot.test.ts`)
* guards the full CloudFormation shape. The contract tests below pin
* the few properties whose drift would cause a real production
* regression — wrong Lambda runtime, lost reserved-concurrency knob,
* missing alarms — so we get a high-signal failure independent of
* the snapshot.
*/
import { beforeAll, describe, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { App, Stack } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
// CDK synth is slow on cold start (~5-8s on the slowest CI runner). The
// default bun:test 5s timeout trips the first `it()` that calls it. Cache
// the default-args synth in `beforeAll` so each test is pure assertions.
// Tests that need non-default props still synth on demand and bump their
// own per-test timeout.
let DEFAULT_TEMPLATE: Template;
function synthFixture(): Template {
const zipDir = mkdtempSync(join(tmpdir(), "hf-cdk-test-"));
const zipPath = join(zipDir, "handler.zip");
writeFileSync(zipPath, "fake zip bytes");
const app = new App();
const stack = new Stack(app, "TestStack");
new HyperframesRenderStack(stack, "Render", { handlerZipPath: zipPath });
return Template.fromStack(stack);
}
describe("HyperframesRenderStack — contract", () => {
beforeAll(() => {
DEFAULT_TEMPLATE = synthFixture();
}, 30000);
it("provisions exactly one Lambda function on the Node.js 22 runtime, x86_64, 10 GiB /tmp", () => {
const t = DEFAULT_TEMPLATE;
t.resourceCountIs("AWS::Lambda::Function", 1);
t.hasResourceProperties("AWS::Lambda::Function", {
Runtime: "nodejs22.x",
Architectures: ["x86_64"],
EphemeralStorage: { Size: 10240 },
MemorySize: 10240,
Handler: "handler.handler",
});
});
it("provisions exactly one Step Functions state machine of type STANDARD with tracing on", () => {
const t = DEFAULT_TEMPLATE;
t.resourceCountIs("AWS::StepFunctions::StateMachine", 1);
t.hasResourceProperties("AWS::StepFunctions::StateMachine", {
StateMachineType: "STANDARD",
TracingConfiguration: { Enabled: true },
});
});
it("provisions exactly one S3 bucket with PublicAccessBlockConfiguration and a 7-day intermediates lifecycle", () => {
const t = DEFAULT_TEMPLATE;
t.resourceCountIs("AWS::S3::Bucket", 1);
t.hasResourceProperties("AWS::S3::Bucket", {
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
BlockPublicPolicy: true,
IgnorePublicAcls: true,
RestrictPublicBuckets: true,
},
LifecycleConfiguration: {
Rules: [
{
Id: "ExpireIntermediates",
Status: "Enabled",
Prefix: "renders/",
ExpirationInDays: 7,
},
],
},
});
});
it("provisions the three CloudWatch alarms (runaway invocations, Lambda Errors, SFN ExecutionsFailed)", () => {
const t = DEFAULT_TEMPLATE;
t.resourceCountIs("AWS::CloudWatch::Alarm", 3);
t.hasResourceProperties("AWS::CloudWatch::Alarm", {
MetricName: "Invocations",
Period: 3600,
Threshold: 1000,
});
t.hasResourceProperties("AWS::CloudWatch::Alarm", {
MetricName: "Errors",
Threshold: 1,
});
t.hasResourceProperties("AWS::CloudWatch::Alarm", {
MetricName: "ExecutionsFailed",
Threshold: 1,
});
});
// These two synth fresh stacks (non-default props), so they pay the
// synth cost individually. Bump per-test timeout so a slow CI runner
// doesn't trip the default 5s.
it("honours reservedConcurrency when supplied", () => {
const zipDir = mkdtempSync(join(tmpdir(), "hf-cdk-test-"));
writeFileSync(join(zipDir, "handler.zip"), "fake");
const app = new App();
const stack = new Stack(app, "TestStack");
new HyperframesRenderStack(stack, "Render", {
handlerZipPath: join(zipDir, "handler.zip"),
reservedConcurrency: 4,
});
const t = Template.fromStack(stack);
t.hasResourceProperties("AWS::Lambda::Function", {
ReservedConcurrentExecutions: 4,
});
}, 30000);
it("uses the projectName prefix on function + state-machine names", () => {
const zipDir = mkdtempSync(join(tmpdir(), "hf-cdk-test-"));
writeFileSync(join(zipDir, "handler.zip"), "fake");
const app = new App();
const stack = new Stack(app, "TestStack");
new HyperframesRenderStack(stack, "Render", {
handlerZipPath: join(zipDir, "handler.zip"),
projectName: "demo",
});
const t = Template.fromStack(stack);
t.hasResourceProperties("AWS::Lambda::Function", { FunctionName: "demo-render" });
t.hasResourceProperties("AWS::StepFunctions::StateMachine", {
StateMachineName: "demo-render",
});
}, 30000);
});
@@ -0,0 +1,170 @@
/**
* Structural snapshot of {@link HyperframesRenderStack}.
*
* `toMatchSnapshot` is intentionally avoided here: bun's snapshot format
* is brittle against the CloudFormation tokens CDK emits (random suffixes
* on log group + role logical ids, asset hashes that change with the
* handler ZIP). Instead we freeze:
*
* - The count of each AWS::* resource type the synthed stack contains
* (catches accidental new resources, deletions, type swaps).
* - A frozen list of Step Functions state names in the parsed
* `DefinitionString`, in declaration order (catches state-machine
* topology drift).
* - The full set of state-machine retry/catch error names (catches
* accidental loss of typed non-retryable failure handling).
*
* Any intentional change to those properties should update this file in
* the same commit — a reviewer reading the diff knows exactly what shifted
* in the topology.
*/
import { beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { App, Stack } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
// CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on
// the first call). The default bun:test 5s timeout trips it on the
// first `it()` that calls `synth()`. Run synth once in `beforeAll`
// and reuse the result — each test is a few µs of pure assertions
// against the already-synthed template.
let SYNTHED: ReturnType<typeof doSynth>;
const EXPECTED_RESOURCE_COUNTS: Record<string, number> = {
"AWS::Lambda::Function": 1,
"AWS::S3::Bucket": 1,
"AWS::StepFunctions::StateMachine": 1,
"AWS::CloudWatch::Alarm": 3,
"AWS::Logs::LogGroup": 1,
// CDK emits IAM roles for both the function and the state machine, plus
// a managed policy for the bucket grant.
"AWS::IAM::Role": 2,
"AWS::IAM::Policy": 2,
};
// Top-level state names emitted by ASL. The Map state's inner
// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
// not at this level — we cover it separately in the contract test.
const EXPECTED_STATE_NAMES = [
"Plan",
"BuildChunkList",
"AssertChunkCount",
"RenderChunks",
"Assemble",
"PlanProducedZeroChunks",
];
const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
]);
function doSynth(): {
template: Template;
definition: { States: Record<string, unknown>; StartAt: string };
} {
const zipDir = mkdtempSync(join(tmpdir(), "hf-cdk-snap-"));
writeFileSync(join(zipDir, "handler.zip"), "fake zip bytes");
const app = new App();
const stack = new Stack(app, "TestStack");
new HyperframesRenderStack(stack, "Render", { handlerZipPath: join(zipDir, "handler.zip") });
const template = Template.fromStack(stack);
const stateMachine = Object.values(
template.findResources("AWS::StepFunctions::StateMachine"),
)[0] as {
Properties: { DefinitionString: unknown };
};
const def = stateMachine.Properties.DefinitionString;
// CDK emits a `Fn::Join` over interpolated ARN tokens; reduce it to
// a definition string we can JSON.parse for inspection.
let parsed: { States: Record<string, unknown>; StartAt: string };
if (typeof def === "string") {
parsed = JSON.parse(def);
} else if (def && typeof def === "object" && "Fn::Join" in def) {
const join = (def as { "Fn::Join": [string, unknown[]] })["Fn::Join"];
const concatenated = join[1]
.map((seg) => (typeof seg === "string" ? seg : "<<TOKEN>>"))
.join("");
parsed = JSON.parse(concatenated);
} else {
throw new Error(`Unexpected DefinitionString shape: ${JSON.stringify(def).slice(0, 200)}`);
}
return { template, definition: parsed };
}
describe("HyperframesRenderStack — snapshot", () => {
// 30s is plenty: cold synth on the slowest CI runner has measured ~8s.
beforeAll(() => {
SYNTHED = doSynth();
}, 30000);
it("emits the expected set of AWS resource types in the expected counts", () => {
const { template } = SYNTHED;
const actual: Record<string, number> = {};
const allResources = template.toJSON().Resources as Record<string, { Type: string }>;
for (const res of Object.values(allResources)) {
actual[res.Type] = (actual[res.Type] ?? 0) + 1;
}
// Only assert on the types we explicitly track so the assertion
// failure highlights the drift, not the surrounding noise.
for (const [type, expected] of Object.entries(EXPECTED_RESOURCE_COUNTS)) {
expect({ type, count: actual[type] ?? 0 }).toEqual({ type, count: expected });
}
// And catch unexpected new resource types up front.
const unexpected = Object.keys(actual).filter(
(type) => EXPECTED_RESOURCE_COUNTS[type] === undefined,
);
expect(unexpected).toEqual([]);
});
it("declares the state machine with the expected state names", () => {
const { definition } = SYNTHED;
expect(definition.StartAt).toBe("Plan");
const actualStates = Object.keys(definition.States);
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
});
it("preserves every typed non-retryable error name across the three Lambda tasks", () => {
const { definition } = SYNTHED;
const collected = new Set<string>();
// Plan + Assemble are top-level states; RenderChunk is nested inside
// the Map's Iterator definition.
const topLevelStates = ["Plan", "Assemble"] as const;
for (const stateName of topLevelStates) {
collectNonRetryableErrors(definition.States[stateName], collected);
}
const renderChunks = definition.States.RenderChunks as
| {
Iterator?: { States?: Record<string, unknown> };
ItemProcessor?: { States?: Record<string, unknown> };
}
| undefined;
const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStates.RenderChunk, collected);
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
expect({ error: expected, present: collected.has(expected) }).toEqual({
error: expected,
present: true,
});
}
});
});
function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
const retries =
(state as { Retry?: { ErrorEquals: string[]; MaxAttempts?: number }[] })?.Retry ?? [];
for (const retry of retries) {
if (retry.MaxAttempts === 0) {
for (const err of retry.ErrorEquals) out.add(err);
}
}
}
@@ -0,0 +1,343 @@
/**
* `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,
},
});
// 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 {
const NON_RETRYABLE_PLAN = [
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
];
const NON_RETRYABLE_CHUNK = [
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"BROWSER_GPU_NOT_SOFTWARE",
];
const NON_RETRYABLE_ASSEMBLE = [
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
];
const plan = new tasks.LambdaInvoke(this, "Plan", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
}),
resultSelector: {
"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 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 assertChunkCount = new sfn.Choice(this, "AssertChunkCount")
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), renderChunks.next(assemble))
.otherwise(planProducedZero);
return plan.next(buildChunkList).next(assertChunkCount);
}
}
/**
* 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");
}
+13
View File
@@ -0,0 +1,13 @@
/**
* CDK subpath export — `@hyperframes/aws-lambda/cdk`.
*
* Pulled into its own subpath so SDK-only consumers don't import
* `aws-cdk-lib`. The construct itself depends on `aws-cdk-lib` and
* `constructs` as peer dependencies; adopters using CDK already have
* both installed.
*/
export {
HyperframesRenderStack,
type HyperframesRenderStackProps,
} from "./HyperframesRenderStack.js";
@@ -0,0 +1,24 @@
/**
* Map a distributed `format` to the file extension the assembled output
* should carry on disk + in S3. Shared by `src/handler.ts` (chunk +
* assemble output paths) and `src/sdk/renderToLambda.ts` (final
* output key construction) so the two sides agree on what an mp4
* looks like vs a png-sequence.
*/
export type DistributedFormat = "mp4" | "mov" | "png-sequence";
export function formatExtension(format: DistributedFormat): string {
switch (format) {
case "mp4":
return ".mp4";
case "mov":
return ".mov";
case "png-sequence":
return "";
default: {
const _exhaustive: never = format;
throw new Error(`[formatExtension] unsupported format: ${_exhaustive as string}`);
}
}
}
+1 -16
View File
@@ -25,6 +25,7 @@ import {
renderChunk, renderChunk,
} from "@hyperframes/producer/distributed"; } from "@hyperframes/producer/distributed";
import { resolveChromeExecutablePath } from "./chromium.js"; import { resolveChromeExecutablePath } from "./chromium.js";
import { formatExtension } from "./formatExtension.js";
import type { import type {
AssembleEvent, AssembleEvent,
AssembleLambdaResult, AssembleLambdaResult,
@@ -472,22 +473,6 @@ function trimTrailingSlash(prefix: string): string {
return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
} }
function formatExtension(format: "mp4" | "mov" | "png-sequence"): string {
switch (format) {
case "mp4":
return ".mp4";
case "mov":
return ".mov";
case "png-sequence":
return "";
default: {
// Compile-time exhaustiveness — a future Format member trips here.
const _exhaustive: never = format;
throw new Error(`[handler] unsupported format: ${_exhaustive as string}`);
}
}
}
function cleanupDir(dir: string): void { function cleanupDir(dir: string): void {
try { try {
// Lambda warm starts can reuse `/tmp` across invocations; clean up // Lambda warm starts can reuse `/tmp` across invocations; clean up
+40 -7
View File
@@ -2,14 +2,24 @@
* `@hyperframes/aws-lambda` — Lambda adapter for the HyperFrames * `@hyperframes/aws-lambda` — Lambda adapter for the HyperFrames
* distributed render pipeline. * distributed render pipeline.
* *
* The package exports the Lambda handler entry point plus the event / * Two surfaces, one package:
* result types Step Functions consumers and CDK constructs need to
* type-check their state machine definitions.
* *
* The handler is bundled with `scripts/build-zip.ts` into `dist/handler.zip` * - **Server-side handler.** `handler`, the Step Functions event/result
* — that artifact is what `examples/aws-lambda/template.yaml` and any * types, Chrome resolution, and S3 transport. These power the Lambda
* future CDK construct point at via `CodeUri`. The package is NOT a * function bundled into `dist/handler.zip`.
* dependency of `@hyperframes/producer`; consumers install it separately. * - **Client-side SDK.** `renderToLambda`, `getRenderProgress`,
* `deploySite`, `validateDistributedRenderConfig`, and `computeRenderCost`.
* Adopters call these from their Node process (CI scripts, CLIs, CDK
* pipelines) to drive a deployed stack without writing AWS-SDK
* boilerplate.
*
* The CDK L2 construct lives at the `./cdk` subpath export so SDK-only
* consumers don't pull `aws-cdk-lib` into their runtime graph:
*
* import { HyperframesRenderStack } from "@hyperframes/aws-lambda/cdk";
*
* The package is NOT a dependency of `@hyperframes/producer`; consumers
* install it separately.
*/ */
export { handler, type HandlerDeps, unwrapEvent } from "./handler.js"; export { handler, type HandlerDeps, unwrapEvent } from "./handler.js";
@@ -43,3 +53,26 @@ export {
untarDirectory, untarDirectory,
uploadFileToS3, uploadFileToS3,
} from "./s3Transport.js"; } from "./s3Transport.js";
// ── Client-side SDK ─────────────────────────────────────────────────────────
export { deploySite, type DeploySiteOptions, type SiteHandle } from "./sdk/deploySite.js";
export {
renderToLambda,
type RenderHandle,
type RenderToLambdaOptions,
} from "./sdk/renderToLambda.js";
export {
getRenderProgress,
type GetRenderProgressOptions,
type RenderError,
type RenderProgress,
type RenderStatus,
} from "./sdk/getRenderProgress.js";
export {
type BilledLambdaInvocation,
computeRenderCost,
type RenderCost,
} from "./sdk/costAccounting.js";
export { InvalidConfigError, validateDistributedRenderConfig } from "./sdk/validateConfig.js";
// The CDK construct is exported separately via the `./cdk` subpath so
// SDK-only consumers don't pay the `aws-cdk-lib` import cost.
@@ -0,0 +1,69 @@
/**
* Shared `FakeS3` for the SDK unit tests.
*
* Multiple SDK tests need to assert what `deploySite` / `renderToLambda`
* sent to S3. Each fake here records `HeadObjectCommand` + `PutObjectCommand`
* activity and drains the body stream so the lazy `createReadStream`
* inside `uploadFileToS3` doesn't try to open the workdir tarball after
* `deploySite`'s `finally` block has rmSync'd it.
*/
import type { S3Client } from "@aws-sdk/client-s3";
export interface FakeS3Op {
kind: "head" | "put";
bucket: string;
key: string;
}
export class FakeS3 {
ops: FakeS3Op[] = [];
existing = new Set<string>();
async send(command: unknown): Promise<unknown> {
const cmdName = (command as { constructor: { name: string } }).constructor.name;
const input = (command as { input: { Bucket: string; Key: string } }).input;
if (cmdName === "HeadObjectCommand") {
this.ops.push({ kind: "head", bucket: input.Bucket, key: input.Key });
if (this.existing.has(`${input.Bucket}/${input.Key}`)) {
return { ContentLength: 1, LastModified: new Date("2026-05-16T00:00:00Z") };
}
const err = new Error("Not Found") as Error & {
$metadata: { httpStatusCode: number };
name: string;
};
err.name = "NotFound";
err.$metadata = { httpStatusCode: 404 };
throw err;
}
if (cmdName === "PutObjectCommand") {
this.ops.push({ kind: "put", bucket: input.Bucket, key: input.Key });
this.existing.add(`${input.Bucket}/${input.Key}`);
await drainBody((command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body);
return {};
}
throw new Error(`FakeS3: unexpected command ${cmdName}`);
}
}
/** Convenience cast so `deploySite({ s3: makeFakeS3() })` reads cleanly. */
export function asS3Client(fake: FakeS3): S3Client {
return fake as unknown as S3Client;
}
/**
* Consume the body stream to completion. Without this, the lazy
* `createReadStream` opened inside `uploadFileToS3` would attempt to
* read the workdir tarball after deploySite's `finally` block removed
* the workdir — surfacing as an "Unhandled error between tests"
* teardown noise that bun's runner attributes to the next test.
*/
async function drainBody(body: NodeJS.ReadableStream | Buffer): Promise<void> {
if (Buffer.isBuffer(body)) return;
await new Promise<void>((resolve, reject) => {
body.on("data", () => {});
body.on("end", () => resolve());
body.on("close", () => resolve());
body.on("error", reject);
});
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "bun:test";
import { type BilledLambdaInvocation, computeRenderCost } from "./costAccounting.js";
describe("computeRenderCost", () => {
it("returns zero when nothing is billed", () => {
const result = computeRenderCost([], 0);
expect(result.accruedSoFarUsd).toBe(0);
expect(result.displayCost).toBe("$0.0000");
expect(result.breakdown.estimated).toBe(false);
});
it("computes Lambda GB-seconds × USD per GB-s", () => {
// 10 GiB × 6 s = 60 GB-s × $0.0000166667 = $0.001.
const invs: BilledLambdaInvocation[] = [
{ billedDurationMs: 6_000, memorySizeMb: 10_240, estimated: false },
];
const result = computeRenderCost(invs, 0);
expect(result.breakdown.lambdaUsd).toBeCloseTo(0.001, 4);
expect(result.breakdown.stepFunctionsUsd).toBe(0);
});
it("sums multiple Lambda invocations", () => {
const invs: BilledLambdaInvocation[] = [
{ billedDurationMs: 1_000, memorySizeMb: 1_024, estimated: false }, // 1 GB-s
{ billedDurationMs: 2_000, memorySizeMb: 2_048, estimated: false }, // 4 GB-s
];
const result = computeRenderCost(invs, 0);
// (1 + 4) × $0.0000166667 ≈ $0.0000833335 → rounds to $0.0001 at 4 dp.
expect(result.breakdown.lambdaUsd).toBe(0.0001);
});
it("adds Step Functions transition costs", () => {
const result = computeRenderCost([], 200);
expect(result.breakdown.stepFunctionsUsd).toBeCloseTo(200 * 0.000025, 6);
expect(result.breakdown.stepFunctionsUsd).toBeCloseTo(0.005, 4);
});
it("flags estimated=true when any invocation was estimated", () => {
const result = computeRenderCost(
[
{ billedDurationMs: 1_000, memorySizeMb: 1_024, estimated: true },
{ billedDurationMs: 1_000, memorySizeMb: 1_024, estimated: false },
],
10,
);
expect(result.breakdown.estimated).toBe(true);
});
it("formats USD to four decimal places", () => {
const result = computeRenderCost([], 1);
expect(result.displayCost).toBe("$0.0000");
const result2 = computeRenderCost([], 4_000);
expect(result2.displayCost).toBe("$0.1000");
});
it("does not include S3 in the breakdown", () => {
const result = computeRenderCost([], 0);
expect(result.breakdown.s3Estimate).toBe("not-included");
});
});
@@ -0,0 +1,91 @@
/**
* Per-render cost accounting for {@link getRenderProgress}.
*
* AWS bills Lambda by **GB-seconds** (billed-duration × memory-in-GiB)
* and Step Functions standard workflows by **state transitions**. Both
* inputs are recoverable from the SFN execution history without an
* extra CloudWatch query — the history events carry
* `billedDurationInMillis` and `memorySizeInMB` on each Lambda
* invocation, and the transition count is simply `history.length`
* filtered to transition-worthy events.
*
* The math is documented inline so the constants stay close to the
* pricing source they came from. Cost is **best-effort**: AWS pricing
* varies by region + commitment plan; we use on-demand `us-east-1`
* rates as of 2026-05 and label the result `displayCost` so callers
* see the dollar value but downstream automation can also read the
* raw number.
*/
/** On-demand Lambda price, us-east-1, x86_64, on-demand: USD per GB-second. */
const LAMBDA_USD_PER_GB_SECOND = 0.0000166667;
/** Step Functions Standard Workflows, us-east-1: USD per state transition. */
const SFN_USD_PER_TRANSITION = 0.000025;
/** Raw history event subset the cost calc cares about. Caller filters from `getExecutionHistory`. */
export interface BilledLambdaInvocation {
/** Millis of Lambda billed duration. Carried on `TaskSucceeded`/`TaskFailed` events. */
billedDurationMs: number;
/** Memory size in MB the function was configured with at invocation time. */
memorySizeMb: number;
/** `true` if the event payload did NOT carry a billed duration and we fell back to `Duration` or a constant. */
estimated: boolean;
}
/** Result of {@link computeRenderCost}. */
export interface RenderCost {
/** USD accrued to date. */
accruedSoFarUsd: number;
/** Human-readable USD string, e.g. `"$0.0214"`. */
displayCost: string;
breakdown: {
lambdaUsd: number;
stepFunctionsUsd: number;
/** S3 transfer + storage cost varies by tier; we don't try to compute it here. */
s3Estimate: "not-included";
/** `true` if any Lambda invocation fell back to estimated billing. */
estimated: boolean;
};
}
/**
* Sum Lambda GB-seconds + SFN transitions into an aggregate USD figure.
*
* `stateTransitions` is the count of billable state-machine transitions
* — every successful state entry transitions once for standard
* workflows. Express workflows price differently and are out of scope.
*/
export function computeRenderCost(
lambdaInvocations: BilledLambdaInvocation[],
stateTransitions: number,
): RenderCost {
let lambdaUsd = 0;
let anyEstimated = false;
for (const inv of lambdaInvocations) {
const gbSeconds = (inv.memorySizeMb / 1024) * (inv.billedDurationMs / 1000);
lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND;
if (inv.estimated) anyEstimated = true;
}
const stepFunctionsUsd = stateTransitions * SFN_USD_PER_TRANSITION;
const accruedSoFarUsd = roundUsd(lambdaUsd + stepFunctionsUsd);
return {
accruedSoFarUsd,
displayCost: formatUsd(accruedSoFarUsd),
breakdown: {
lambdaUsd: roundUsd(lambdaUsd),
stepFunctionsUsd: roundUsd(stepFunctionsUsd),
s3Estimate: "not-included",
estimated: anyEstimated,
},
};
}
function roundUsd(usd: number): number {
// Four decimal places — enough resolution for per-chunk granularity on
// a 10 GB Lambda. Anything finer is noise vs AWS' own rounding.
return Math.round(usd * 10_000) / 10_000;
}
function formatUsd(usd: number): string {
return `$${usd.toFixed(4)}`;
}
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { S3Client } from "@aws-sdk/client-s3";
import { asS3Client, FakeS3 } from "./__fixtures__/fakeS3.js";
import { deploySite } from "./deploySite.js";
let projectDir: string;
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "hf-deploy-site-test-"));
mkdirSync(join(projectDir, "assets"));
writeFileSync(join(projectDir, "index.html"), "<html><body>hi</body></html>");
writeFileSync(join(projectDir, "assets", "style.css"), "body { color: red; }");
});
afterEach(() => {
rmSync(projectDir, { recursive: true, force: true });
});
describe("deploySite", () => {
it("uploads the tarball when no matching object exists", async () => {
const s3 = new FakeS3();
const result = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3),
});
expect(result.uploaded).toBe(true);
expect(result.siteId).toMatch(/^[0-9a-f]{16}$/);
expect(result.bucketName).toBe("test-bucket");
expect(result.projectS3Uri).toBe(`s3://test-bucket/sites/${result.siteId}/project.tar.gz`);
expect(result.bytes).toBeGreaterThan(0);
expect(s3.ops).toEqual([
{ kind: "head", bucket: "test-bucket", key: `sites/${result.siteId}/project.tar.gz` },
{ kind: "put", bucket: "test-bucket", key: `sites/${result.siteId}/project.tar.gz` },
]);
});
it("yields a stable siteId across re-runs of the same tree", async () => {
const s3a = new FakeS3();
const a = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3a),
});
const s3b = new FakeS3();
const b = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3b),
});
expect(a.siteId).toBe(b.siteId);
});
it("changes siteId when a file's content changes", async () => {
const s3 = new FakeS3();
const before = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3),
});
writeFileSync(join(projectDir, "index.html"), "<html><body>changed</body></html>");
const s3b = new FakeS3();
const after = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3b),
});
expect(after.siteId).not.toBe(before.siteId);
});
it("short-circuits on HEAD 200 (skips PUT)", async () => {
const s3 = new FakeS3();
const first = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3),
});
const second = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3),
});
expect(second.uploaded).toBe(false);
expect(second.siteId).toBe(first.siteId);
// Only one PUT total, plus two HEADs.
expect(s3.ops.filter((op) => op.kind === "put")).toHaveLength(1);
expect(s3.ops.filter((op) => op.kind === "head")).toHaveLength(2);
});
it("honours a caller-supplied siteId", async () => {
const s3 = new FakeS3();
const result = await deploySite({
projectDir,
bucketName: "test-bucket",
siteId: "release-v1.2.3",
s3: asS3Client(s3),
});
expect(result.siteId).toBe("release-v1.2.3");
expect(result.projectS3Uri).toBe("s3://test-bucket/sites/release-v1.2.3/project.tar.gz");
});
it("propagates non-404 S3 errors", async () => {
const errS3 = {
async send(_cmd: unknown): Promise<unknown> {
const err = new Error("Access Denied") as Error & {
$metadata: { httpStatusCode: number };
};
err.$metadata = { httpStatusCode: 403 };
throw err;
},
};
await expect(
deploySite({
projectDir,
bucketName: "test-bucket",
s3: errS3 as unknown as S3Client,
}),
).rejects.toThrow(/Access Denied/);
});
it("ignores SKIP_TOP_LEVEL dirs when hashing", async () => {
const s3 = new FakeS3();
const before = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3),
});
mkdirSync(join(projectDir, "node_modules"));
writeFileSync(join(projectDir, "node_modules", "junk.bin"), "x".repeat(100));
const s3b = new FakeS3();
const after = await deploySite({
projectDir,
bucketName: "test-bucket",
s3: asS3Client(s3b),
});
// node_modules contents shouldn't move the hash.
expect(after.siteId).toBe(before.siteId);
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* `deploySite` — upload a project directory to S3 once per content hash
* and return a reusable handle.
*
* `renderToLambda` calls this implicitly when no `siteHandle` is passed,
* but exposing it as a standalone verb lets adopters bundle a project
* ahead of time and reuse the handle across many renders without
* re-tarring the project tree on every call.
*
* The handle is **content-addressed**: `siteId` is derived from a SHA-256
* over the project files. Two `deploySite` calls on an unchanged tree
* produce the same `siteId` and `HeadObject`-short-circuit the upload.
*/
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { HeadObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { PLAN_PROJECT_DIR_SKIP_SEGMENTS } from "@hyperframes/producer/distributed";
import { formatS3Uri, tarDirectory, uploadFileToS3 } from "../s3Transport.js";
/** Options for {@link deploySite}. */
export interface DeploySiteOptions {
/** Local project directory containing `index.html` (and any composition assets). */
projectDir: string;
/** S3 bucket the SAM stack / CDK construct provisioned. */
bucketName: string;
/** AWS region for the S3 client. Defaults to the SDK's default chain (env / config / IMDS). */
region?: string;
/**
* Override the content-addressed site id. Useful when the caller has a
* stable external identifier they want to use (e.g. a git SHA); if
* unset, the hash of the project tree picks it.
*/
siteId?: string;
/** Injection seam for tests. Production callers leave unset. */
s3?: S3Client;
}
/** Stable handle returned by {@link deploySite}. Pass back to {@link renderToLambda}. */
export interface SiteHandle {
/** Content-addressed (or caller-supplied) identifier; stable across re-uploads of the same tree. */
siteId: string;
/** Bucket the site landed in. Surfaced separately so callers don't have to re-parse `projectS3Uri`. */
bucketName: string;
/** Full `s3://bucket/sites/<siteId>/project.tar.gz` URI; pass through to `renderToLambda`. */
projectS3Uri: string;
/** Tarball size in bytes; useful for "did we actually skip the upload?" assertions. */
bytes: number;
/** ISO timestamp of the most recent upload OR the existing object the short-circuit found. */
uploadedAt: string;
/** `false` if the object already existed and we skipped the PUT. */
uploaded: boolean;
}
/**
* Upload `projectDir` to `s3://bucketName/sites/<siteId>/project.tar.gz`.
*
* Short-circuits when an object with the same key already exists in the
* bucket — `siteId` derives from the project's content hash, so the same
* bytes produce the same key, and re-uploading would be redundant.
*/
export async function deploySite(opts: DeploySiteOptions): Promise<SiteHandle> {
if (!statSync(opts.projectDir).isDirectory()) {
throw new Error(`[deploySite] projectDir is not a directory: ${opts.projectDir}`);
}
const siteId = opts.siteId ?? hashProjectDir(opts.projectDir);
const key = `sites/${siteId}/project.tar.gz`;
const projectS3Uri = formatS3Uri({ bucket: opts.bucketName, key });
const s3 = opts.s3 ?? new S3Client({ region: opts.region });
// HeadObject short-circuit. Adopters re-rendering the same project on
// a tight inner loop (CI smoke, demo flows) save the tar+gzip+PUT pass
// on every iteration.
const existing = await headObject(s3, opts.bucketName, key);
if (existing) {
return {
siteId,
bucketName: opts.bucketName,
projectS3Uri,
bytes: existing.bytes,
uploadedAt: existing.lastModified,
uploaded: false,
};
}
const workdir = mkdtempSync(join(tmpdir(), "hf-deploy-site-"));
try {
const tarball = join(workdir, "project.tar.gz");
await tarDirectory(opts.projectDir, tarball);
// Note: tarDirectory packs *everything* under `cwd`. We don't need to
// re-implement the skip list inside the tar pack because the
// producer's plan stage applies the same skip during its copy; the
// archive is slightly bigger than the planDir's compiled/ subtree
// but the cost is bounded by the project's user-authored content.
const size = statSync(tarball).size;
await uploadFileToS3(s3, tarball, projectS3Uri, "application/gzip");
return {
siteId,
bucketName: opts.bucketName,
projectS3Uri,
bytes: size,
uploadedAt: new Date().toISOString(),
uploaded: true,
};
} finally {
rmSync(workdir, { recursive: true, force: true });
}
}
/**
* SHA-256 over every regular file under `projectDir` (sorted by relative
* path) → 16-character hex prefix. The prefix is the `siteId`.
*
* The hash includes the relative path plus every byte of each file, so a
* same-bytes rename still yields a fresh id. We trim to 16 chars because
* the full 64 isn't useful in an S3 key for legibility.
*
* Reads are synchronous: project trees are typically tens of MB at most
* (HTML/CSS/JS plus a few composition assets), so the simpler shape wins
* over a streaming pipeline.
*/
function hashProjectDir(projectDir: string): string {
const hash = createHash("sha256");
const files: string[] = [];
function walk(dir: string, isRoot: boolean): void {
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
)) {
if (isRoot && PLAN_PROJECT_DIR_SKIP_SEGMENTS.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, false);
else if (entry.isFile()) files.push(full);
}
}
walk(projectDir, true);
for (const file of files) {
const rel = relative(projectDir, file).replaceAll("\\", "/");
hash.update(rel);
hash.update("\0");
hash.update(readFileSync(file));
}
return hash.digest("hex").slice(0, 16);
}
async function headObject(
s3: S3Client,
bucket: string,
key: string,
): Promise<{ bytes: number; lastModified: string } | null> {
try {
const res = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return {
bytes: typeof res.ContentLength === "number" ? res.ContentLength : 0,
lastModified:
res.LastModified instanceof Date
? res.LastModified.toISOString()
: new Date().toISOString(),
};
} catch (err) {
// The SDK throws different error shapes for 404 vs 403 vs network;
// a 404 means "needs upload" and is the most common case. Anything
// else propagates so callers see auth / network failures.
const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
if (status === 404) return null;
const name = (err as { name?: string }).name;
if (name === "NotFound" || name === "NoSuchKey") return null;
throw err;
}
}
@@ -0,0 +1,228 @@
import { describe, expect, it } from "bun:test";
import {
DescribeExecutionCommand,
GetExecutionHistoryCommand,
type HistoryEvent,
type SFNClient,
} from "@aws-sdk/client-sfn";
import { getRenderProgress } from "./getRenderProgress.js";
interface DescribeShape {
status?: string;
startDate?: Date;
stopDate?: Date;
}
class FakeSFN {
describe: DescribeShape = {};
// Pages of history events; FakeSFN walks them in order, paginating with `nextToken`.
historyPages: HistoryEvent[][] = [];
async send(command: unknown): Promise<unknown> {
const cmdName = (command as { constructor: { name: string } }).constructor.name;
if (cmdName === "DescribeExecutionCommand") {
return {
status: this.describe.status ?? "RUNNING",
startDate: this.describe.startDate ?? new Date("2026-05-16T00:00:00Z"),
stopDate: this.describe.stopDate,
};
}
if (cmdName === "GetExecutionHistoryCommand") {
const input = (command as { input: { nextToken?: string } }).input;
const idx = input.nextToken ? Number.parseInt(input.nextToken, 10) : 0;
const page = this.historyPages[idx] ?? [];
const nextToken = idx + 1 < this.historyPages.length ? String(idx + 1) : undefined;
return { events: page, nextToken };
}
throw new Error(`FakeSFN: unexpected command ${cmdName}`);
}
}
function lambdaSucceeded(payload: unknown): HistoryEvent {
return {
type: "LambdaFunctionSucceeded",
id: 1,
timestamp: new Date(),
lambdaFunctionSucceededEventDetails: { output: JSON.stringify(payload) },
} as HistoryEvent;
}
function stateEntered(name: string): HistoryEvent {
return {
type: "TaskStateEntered",
id: 1,
timestamp: new Date(),
stateEnteredEventDetails: { name },
} as HistoryEvent;
}
function stateExited(name: string, output?: unknown): HistoryEvent {
return {
type: "TaskStateExited",
id: 1,
timestamp: new Date(),
stateExitedEventDetails: {
name,
output: output === undefined ? undefined : JSON.stringify(output),
},
} as HistoryEvent;
}
describe("getRenderProgress", () => {
it("reports 0 progress before Plan completes", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[]];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.status).toBe("RUNNING");
expect(progress.overallProgress).toBe(0);
expect(progress.totalFrames).toBeNull();
expect(progress.framesRendered).toBe(0);
});
it("reports 0.1 once Plan completes (totalFrames known, no chunks done)", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
lambdaSucceeded({
Action: "plan",
TotalFrames: 240,
DurationMs: 1_000,
}),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.totalFrames).toBe(240);
expect(progress.overallProgress).toBeCloseTo(0.1, 6);
expect(progress.framesRendered).toBe(0);
});
it("advances chunk progress proportionally", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("Plan"),
lambdaSucceeded({ Action: "plan", TotalFrames: 100, DurationMs: 1_000 }),
stateEntered("RenderChunk"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 50, DurationMs: 2_000 }),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
// 0.1 + 0.8 × 0.5 = 0.5
expect(progress.overallProgress).toBeCloseTo(0.5, 6);
expect(progress.framesRendered).toBe(50);
});
it("does not double-count Assemble's FramesEncoded toward framesRendered", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("Plan"),
lambdaSucceeded({ Action: "plan", TotalFrames: 100, DurationMs: 1_000 }),
stateEntered("RenderChunk"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 100, DurationMs: 2_000 }),
stateEntered("Assemble"),
lambdaSucceeded({
Action: "assemble",
FramesEncoded: 100,
FileSize: 9_000_000,
OutputS3Uri: "s3://b/k.mp4",
DurationMs: 1_500,
}),
stateExited("Assemble", {
Output: { OutputS3Uri: "s3://b/k.mp4", FileSize: 9_000_000, FramesEncoded: 100 },
}),
],
];
sfn.describe.status = "SUCCEEDED";
sfn.describe.stopDate = new Date("2026-05-16T00:05:00Z");
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.framesRendered).toBe(100);
expect(progress.overallProgress).toBe(1);
expect(progress.outputFile).toEqual({ s3Uri: "s3://b/k.mp4", bytes: 9_000_000 });
expect(progress.endedAt).not.toBeNull();
});
it("computes cost from observed billed duration", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]];
const progress = await getRenderProgress({
executionArn: "arn",
defaultMemorySizeMb: 10_240,
sfn: sfn as unknown as SFNClient,
});
// 1 transition event + 1 invocation × 60 GB-s × $0.0000166667 ≈ $0.001
expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.001, 4);
});
it("captures Lambda failures with the enclosing state name", async () => {
const sfn = new FakeSFN();
const failed: HistoryEvent = {
type: "LambdaFunctionFailed",
id: 2,
timestamp: new Date(),
lambdaFunctionFailedEventDetails: {
error: "PLAN_HASH_MISMATCH",
cause: "bad plan",
},
} as HistoryEvent;
sfn.historyPages = [[stateEntered("RenderChunk"), failed]];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.errors).toEqual([
{ state: "RenderChunk", error: "PLAN_HASH_MISMATCH", cause: "bad plan" },
]);
});
it("marks fatalErrorEncountered when execution ends FAILED", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[]];
sfn.describe.status = "FAILED";
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.fatalErrorEncountered).toBe(true);
});
it("paginates the history", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("Plan"),
lambdaSucceeded({ Action: "plan", TotalFrames: 4, DurationMs: 1_000 }),
],
[
stateEntered("RenderChunk"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 4, DurationMs: 2_000 }),
],
];
sfn.describe.status = "SUCCEEDED";
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.framesRendered).toBe(4);
expect(progress.totalFrames).toBe(4);
expect(progress.overallProgress).toBe(1);
});
it("requires executionArn", async () => {
await expect(getRenderProgress({ executionArn: "" })).rejects.toThrow(/executionArn/);
});
});
void DescribeExecutionCommand;
void GetExecutionHistoryCommand;
@@ -0,0 +1,339 @@
/**
* `getRenderProgress` — read-only progress + cost snapshot for a single
* render started by {@link renderToLambda}.
*
* Pulls one `DescribeExecution` + one `GetExecutionHistory` per call. The
* history is paginated server-side; the helper loops until exhausted so a
* 1,000-event Step Functions execution still produces a single
* `RenderProgress` snapshot.
*
* Progress math:
* - 0 before Plan completes (no frame count is known yet)
* - 0.1 once Plan completes (we know `totalFrames`)
* - 0.1 + 0.8 × framesEncoded / totalFrames during chunk render
* - 1.0 after Assemble completes
*
* Frame counts come from the parsed Lambda result payloads on each
* `TaskSucceeded` event — Plan reports `TotalFrames`, RenderChunk reports
* `FramesEncoded`. The shape mirrors what the handler produces in
* `events.ts`, so the parser doesn't need to know anything beyond
* "JSON.parse this string and grab two fields."
*/
import {
DescribeExecutionCommand,
GetExecutionHistoryCommand,
type HistoryEvent,
SFNClient,
} from "@aws-sdk/client-sfn";
import {
type BilledLambdaInvocation,
computeRenderCost,
type RenderCost,
} from "./costAccounting.js";
/** Options for {@link getRenderProgress}. */
export interface GetRenderProgressOptions {
/** Execution ARN from a {@link renderToLambda} call. */
executionArn: string;
/**
* Default memory size in MB to assume for Lambda invocations when the
* history event payload doesn't carry it explicitly. Matches the
* `LambdaMemoryMb` parameter the stack was deployed with.
*/
defaultMemorySizeMb?: number;
region?: string;
/** Test injection seam. */
sfn?: SFNClient;
}
/** Render-status discriminant; mirrors Step Functions execution states. */
export type RenderStatus =
| "RUNNING"
| "SUCCEEDED"
| "FAILED"
| "TIMED_OUT"
| "ABORTED"
| "PENDING_REDRIVE";
export interface RenderError {
/** State name where the failure surfaced (`Plan`, `RenderChunk`, `Assemble`, or `<unknown>`). */
state: string;
/** Error class / type as Step Functions reports it. */
error: string;
/** Cause string Step Functions surfaces (often a stringified JSON payload from the handler). */
cause: string;
}
/** Snapshot of a single render's progress + cost + errors at one point in time. */
export interface RenderProgress {
status: RenderStatus;
/** `[0, 1]`; see module doc for the math. */
overallProgress: number;
framesRendered: number;
/** `null` until Plan completes. */
totalFrames: number | null;
/** Count of `LambdaFunctionScheduled` events seen in the history so far. */
lambdasInvoked: number;
costs: RenderCost;
/** Final output object if Assemble succeeded; `null` otherwise. */
outputFile: { s3Uri: string; bytes: number | null } | null;
errors: RenderError[];
/** `true` once the execution has terminated in a non-`SUCCEEDED` state. */
fatalErrorEncountered: boolean;
startedAt: string;
endedAt: string | null;
}
const DEFAULT_MEMORY_MB = 10240;
/** Pull a current progress snapshot for one render. */
export async function getRenderProgress(opts: GetRenderProgressOptions): Promise<RenderProgress> {
if (!opts.executionArn) {
throw new Error("[getRenderProgress] executionArn is required");
}
const sfn = opts.sfn ?? new SFNClient({ region: opts.region });
const memoryMb = opts.defaultMemorySizeMb ?? DEFAULT_MEMORY_MB;
const describe = await sfn.send(
new DescribeExecutionCommand({ executionArn: opts.executionArn }),
);
const status = (describe.status ?? "RUNNING") as RenderStatus;
const startedAt = describe.startDate?.toISOString() ?? new Date(0).toISOString();
const endedAt = describe.stopDate?.toISOString() ?? null;
const history = await loadFullHistory(sfn, opts.executionArn);
const summary = summarizeHistory(history, memoryMb);
const costs = computeRenderCost(summary.lambdaInvocations, summary.stateTransitions);
const overallProgress = computeOverallProgress({
status,
totalFrames: summary.totalFrames,
framesRendered: summary.framesRendered,
assembleComplete: summary.assembleComplete,
});
return {
status,
overallProgress,
framesRendered: summary.framesRendered,
totalFrames: summary.totalFrames,
lambdasInvoked: summary.lambdasInvoked,
costs,
outputFile: summary.outputFile,
errors: summary.errors,
fatalErrorEncountered: isTerminalFailure(status),
startedAt,
endedAt,
};
}
async function loadFullHistory(sfn: SFNClient, executionArn: string): Promise<HistoryEvent[]> {
const events: HistoryEvent[] = [];
let nextToken: string | undefined;
for (let page = 0; page < 50; page++) {
const res = await sfn.send(
new GetExecutionHistoryCommand({
executionArn,
maxResults: 1000,
nextToken,
reverseOrder: false,
}),
);
if (res.events) events.push(...res.events);
nextToken = res.nextToken;
if (!nextToken) break;
}
return events;
}
interface HistorySummary {
lambdaInvocations: BilledLambdaInvocation[];
stateTransitions: number;
framesRendered: number;
totalFrames: number | null;
lambdasInvoked: number;
assembleComplete: boolean;
outputFile: { s3Uri: string; bytes: number | null } | null;
errors: RenderError[];
}
/**
* One pass over the history events that pulls every number {@link getRenderProgress}
* needs. State transitions = the count of events that advance the state
* machine (entering/exiting states + map iteration completions). Lambda
* invocations = `LambdaFunctionScheduled` count. Frame totals come from
* the success-payload of each Lambda invocation.
*/
function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySummary {
let framesRendered = 0;
let totalFrames: number | null = null;
let lambdasInvoked = 0;
let assembleComplete = false;
let outputFile: HistorySummary["outputFile"] = null;
let stateTransitions = 0;
const errors: RenderError[] = [];
const lambdaInvocations: BilledLambdaInvocation[] = [];
// Track the state name we most recently entered, so we can:
// - attach the enclosing state to LambdaFunctionFailed errors, and
// - identify when the Assemble state finished (StateExited.Assemble)
// without relying on the inner Lambda payload's `Action` field.
let currentLambdaState: string | null = null;
for (const ev of events) {
switch (ev.type) {
case "TaskStateEntered":
case "MapStateEntered":
case "PassStateEntered":
case "ChoiceStateEntered":
case "SucceedStateEntered":
case "FailStateEntered":
case "WaitStateEntered":
case "ParallelStateEntered":
// Step Functions Standard Workflows bill per *state entry*, not per
// history event. Lambda invocations produce ~5-7 history events
// each (Scheduled / Started / Succeeded / TaskStateExited / …);
// counting every event as a transition over-reports cost by 3-5×.
stateTransitions++;
currentLambdaState = ev.stateEnteredEventDetails?.name ?? currentLambdaState;
break;
case "LambdaFunctionScheduled":
lambdasInvoked++;
break;
case "LambdaFunctionSucceeded": {
const payload = parseJson(ev.lambdaFunctionSucceededEventDetails?.output);
const billedDurationMs = inferBilledMs(payload);
lambdaInvocations.push({
billedDurationMs,
memorySizeMb: memoryMb,
estimated: billedDurationMs === 0,
});
if (payload && typeof payload === "object") {
const obj = payload as Record<string, unknown>;
if (typeof obj.TotalFrames === "number") totalFrames = obj.TotalFrames;
if (typeof obj.FramesEncoded === "number") {
// Plan and Assemble also return FramesEncoded; count framesRendered
// only inside the RenderChunk state so we don't double-count
// it on the Assemble pass. Keyed off the enclosing state name
// (set by the matching StateEntered) rather than the payload's
// `Action` field — `Action` is part of the Lambda event
// contract and not load-bearing for state-machine identity.
if (currentLambdaState === "RenderChunk") {
framesRendered += obj.FramesEncoded;
}
}
}
break;
}
case "TaskStateExited":
case "MapStateExited":
// Mark the assemble step complete on its state-exit, independent
// of the inner Lambda payload shape. The Assemble state's
// ResultSelector pulls FileSize + OutputS3Uri from the Lambda
// result, so we re-extract them here from the state exit's
// own output rather than relying on the Lambda payload.
if (ev.stateExitedEventDetails?.name === "Assemble") {
assembleComplete = true;
const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
if (exitPayload && typeof exitPayload === "object") {
const obj = exitPayload as Record<string, unknown>;
const out = obj.Output as Record<string, unknown> | undefined;
const outputS3Uri = typeof out?.OutputS3Uri === "string" ? out.OutputS3Uri : null;
const bytes = typeof out?.FileSize === "number" ? out.FileSize : null;
outputFile = outputS3Uri ? { s3Uri: outputS3Uri, bytes } : outputFile;
}
}
break;
case "LambdaFunctionFailed":
errors.push({
state: currentLambdaState ?? "<unknown>",
error: ev.lambdaFunctionFailedEventDetails?.error ?? "UNKNOWN",
cause: ev.lambdaFunctionFailedEventDetails?.cause ?? "",
});
break;
case "ExecutionFailed":
errors.push({
state: "<execution>",
error: ev.executionFailedEventDetails?.error ?? "UNKNOWN",
cause: ev.executionFailedEventDetails?.cause ?? "",
});
break;
case "ExecutionAborted":
errors.push({
state: "<execution>",
error: ev.executionAbortedEventDetails?.error ?? "ABORTED",
cause: ev.executionAbortedEventDetails?.cause ?? "",
});
break;
case "ExecutionTimedOut":
errors.push({
state: "<execution>",
error: "TIMEOUT",
cause: ev.executionTimedOutEventDetails?.cause ?? "",
});
break;
default:
break;
}
}
return {
lambdaInvocations,
stateTransitions,
framesRendered,
totalFrames,
lambdasInvoked,
assembleComplete,
outputFile,
errors,
};
}
function parseJson(s: string | undefined): unknown {
if (!s) return null;
try {
return JSON.parse(s);
} catch {
return null;
}
}
/**
* Lambda success payloads from our handler include `DurationMs` — the
* wall-clock the handler observed. We use it as a best-effort proxy
* for `BilledDuration` when SFN doesn't expose the latter directly
* on `LambdaFunctionSucceeded` (the dedicated `BilledDuration` field
* is in CloudWatch Metrics, not the SFN history payload).
*/
function inferBilledMs(payload: unknown): number {
if (!payload || typeof payload !== "object") return 0;
const obj = payload as Record<string, unknown>;
if (typeof obj.DurationMs === "number") return obj.DurationMs;
return 0;
}
interface ComputeProgressArgs {
status: RenderStatus;
totalFrames: number | null;
framesRendered: number;
assembleComplete: boolean;
}
function computeOverallProgress({
status,
totalFrames,
framesRendered,
assembleComplete,
}: ComputeProgressArgs): number {
if (status === "SUCCEEDED") return 1;
if (assembleComplete) return 1;
if (totalFrames === null) return 0;
// 10 % Plan + 80 % chunk render + 10 % Assemble.
const chunkProgress = Math.min(1, framesRendered / totalFrames);
return 0.1 + 0.8 * chunkProgress;
}
function isTerminalFailure(status: RenderStatus): boolean {
return status === "FAILED" || status === "TIMED_OUT" || status === "ABORTED";
}
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SFNClient } from "@aws-sdk/client-sfn";
import type { SerializableDistributedRenderConfig } from "../events.js";
import { asS3Client, FakeS3 } from "./__fixtures__/fakeS3.js";
import type { SiteHandle } from "./deploySite.js";
import { renderToLambda } from "./renderToLambda.js";
import { InvalidConfigError } from "./validateConfig.js";
interface CapturedStart {
stateMachineArn: string;
name: string;
input: unknown;
}
class FakeSFN {
starts: CapturedStart[] = [];
async send(command: unknown): Promise<unknown> {
const cmdName = (command as { constructor: { name: string } }).constructor.name;
if (cmdName === "StartExecutionCommand") {
const input = (command as { input: { stateMachineArn: string; name: string; input: string } })
.input;
this.starts.push({
stateMachineArn: input.stateMachineArn,
name: input.name,
input: JSON.parse(input.input),
});
return {
executionArn: `arn:aws:states:us-east-1:1234:execution:hf:${input.name}`,
startDate: new Date(),
};
}
throw new Error(`FakeSFN: unexpected command ${cmdName}`);
}
}
function asSFNClient(fake: { send(command: unknown): Promise<unknown> }): SFNClient {
return fake as unknown as SFNClient;
}
const baseConfig: SerializableDistributedRenderConfig = {
fps: 30,
width: 1280,
height: 720,
format: "mp4",
};
let projectDir: string;
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "hf-render-test-"));
writeFileSync(join(projectDir, "index.html"), "<html></html>");
});
afterEach(() => {
rmSync(projectDir, { recursive: true, force: true });
});
describe("renderToLambda", () => {
it("returns a handle and starts a state-machine execution with the right input", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
const handle = await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
executionName: "smoke-1",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(handle.renderId).toBe("smoke-1");
expect(handle.executionArn).toContain("smoke-1");
expect(handle.bucketName).toBe("test-bucket");
expect(handle.outputS3Uri).toBe("s3://test-bucket/renders/smoke-1/output.mp4");
expect(handle.projectS3Uri).toMatch(
/^s3:\/\/test-bucket\/sites\/[0-9a-f]{16}\/project\.tar\.gz$/,
);
expect(sfn.starts).toHaveLength(1);
const start = sfn.starts[0]!;
expect(start.name).toBe("smoke-1");
expect(start.stateMachineArn).toBe("arn:aws:states:us-east-1:1234:stateMachine:hf");
expect(start.input).toEqual({
ProjectS3Uri: handle.projectS3Uri,
PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/",
OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4",
Config: baseConfig,
});
});
it("derives the file extension from config.format", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
const handle = await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: { ...baseConfig, format: "mov" },
executionName: "smoke-mov",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(handle.outputS3Uri).toBe("s3://test-bucket/renders/smoke-mov/output.mov");
});
it("reuses a supplied siteHandle (no deploy)", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
const prebuilt: SiteHandle = {
siteId: "prebaked",
bucketName: "test-bucket",
projectS3Uri: "s3://test-bucket/sites/prebaked/project.tar.gz",
bytes: 4096,
uploadedAt: "2026-05-16T00:00:00Z",
uploaded: true,
};
const handle = await renderToLambda({
siteHandle: prebuilt,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
executionName: "smoke-reuse",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(handle.projectS3Uri).toBe(prebuilt.projectS3Uri);
// No HEAD/PUT means s3.existing stayed empty.
expect(s3.existing.size).toBe(0);
});
it("rejects invalid configs synchronously before any AWS call", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
try {
await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: { ...baseConfig, fps: 25 as 24 | 30 | 60 },
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
}
expect(sfn.starts).toHaveLength(0);
});
it("requires either siteHandle or projectDir", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
await expect(
renderToLambda({
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
}),
).rejects.toThrow(/either siteHandle or projectDir/);
});
it("auto-generates an executionName when omitted", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
const handle = await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(handle.renderId).toMatch(/^hf-render-[0-9a-f-]{36}$/);
});
it("propagates a missing executionArn as an error", async () => {
const sfn = {
async send(_cmd: unknown): Promise<unknown> {
return { executionArn: undefined };
},
};
const s3 = new FakeS3();
await expect(
renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: baseConfig,
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
}),
).rejects.toThrow(/no executionArn/);
});
});
@@ -0,0 +1,134 @@
/**
* `renderToLambda` — start a distributed render against an already-deployed
* SAM/CDK stack and return a handle the caller can poll with
* {@link getRenderProgress}.
*
* The function does *not* wait for the render to finish. Step Functions
* standard workflows can run for hours; blocking the caller's process on
* the SFN execution is the wrong default. The returned `RenderHandle`
* carries everything the progress / cost / download paths need.
*
* Wire order:
* 1. Validate config (typed throw before any AWS call).
* 2. `deploySite` if no `siteHandle` was provided.
* 3. `StartExecution` against the state machine with the same input
* shape `examples/aws-lambda/scripts/smoke.sh` builds.
* 4. Return handle. The S3 `outputKey` is deterministic from the
* execution name so the caller can predict the final object URL.
*/
import { randomUUID } from "node:crypto";
import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
import type { S3Client } from "@aws-sdk/client-s3";
import type { SerializableDistributedRenderConfig } from "../events.js";
import { formatExtension } from "../formatExtension.js";
import { formatS3Uri } from "../s3Transport.js";
import { deploySite, type SiteHandle } from "./deploySite.js";
import { validateDistributedRenderConfig } from "./validateConfig.js";
/** Options for {@link renderToLambda}. */
export interface RenderToLambdaOptions {
/** Local project directory. Required when `siteHandle` is not supplied. */
projectDir?: string;
/** Re-use an existing `deploySite` upload (skips tar+S3 PUT). */
siteHandle?: SiteHandle;
/** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */
config: SerializableDistributedRenderConfig;
/** S3 bucket from the SAM stack output (`RenderBucketName`). */
bucketName: string;
/** State machine ARN from the SAM stack output (`RenderStateMachineArn`). */
stateMachineArn: string;
/** AWS region; defaults to the SDK default chain. */
region?: string;
/**
* Final output S3 key. Defaults to `renders/<executionName>/output.<ext>`
* where `<ext>` is derived from `config.format`.
*/
outputKey?: string;
/**
* Step Functions execution name. Defaults to `hf-render-<uuid>`.
* Used as `renderId` everywhere downstream (history queries, cost
* accounting, predictable S3 key prefix).
*/
executionName?: string;
/** Test injection seam — production callers leave unset. */
sfn?: SFNClient;
/** Test injection seam — propagated to `deploySite` when applicable. */
s3?: S3Client;
}
/** Stable identifier + every URL/ARN the caller needs to follow the render. */
export interface RenderHandle {
/** Same as the Step Functions execution name. */
renderId: string;
/** Full execution ARN; pass to {@link getRenderProgress}. */
executionArn: string;
bucketName: string;
stateMachineArn: string;
outputS3Uri: string;
projectS3Uri: string;
startedAt: string;
}
export async function renderToLambda(opts: RenderToLambdaOptions): Promise<RenderHandle> {
validateDistributedRenderConfig(opts.config);
if (!opts.bucketName) {
throw new Error("[renderToLambda] bucketName is required");
}
if (!opts.stateMachineArn) {
throw new Error("[renderToLambda] stateMachineArn is required");
}
if (!opts.siteHandle && !opts.projectDir) {
throw new Error("[renderToLambda] either siteHandle or projectDir must be supplied");
}
const executionName = opts.executionName ?? `hf-render-${randomUUID()}`;
const ext = formatExtension(opts.config.format);
const outputKey = opts.outputKey ?? `renders/${executionName}/output${ext}`;
const planOutputS3Prefix = formatS3Uri({
bucket: opts.bucketName,
key: `renders/${executionName}/`,
});
const outputS3Uri = formatS3Uri({ bucket: opts.bucketName, key: outputKey });
const site =
opts.siteHandle ??
(await deploySite({
projectDir: opts.projectDir as string,
bucketName: opts.bucketName,
region: opts.region,
s3: opts.s3,
}));
const input = {
ProjectS3Uri: site.projectS3Uri,
PlanOutputS3Prefix: planOutputS3Prefix,
OutputS3Uri: outputS3Uri,
Config: opts.config,
};
const sfn = opts.sfn ?? new SFNClient({ region: opts.region });
const startedAt = new Date().toISOString();
const response = await sfn.send(
new StartExecutionCommand({
stateMachineArn: opts.stateMachineArn,
name: executionName,
input: JSON.stringify(input),
}),
);
if (!response.executionArn) {
throw new Error("[renderToLambda] StartExecution returned no executionArn");
}
return {
renderId: executionName,
executionArn: response.executionArn,
bucketName: opts.bucketName,
stateMachineArn: opts.stateMachineArn,
outputS3Uri,
projectS3Uri: site.projectS3Uri,
startedAt,
};
}
@@ -0,0 +1,130 @@
import { describe, expect, it } from "bun:test";
import type { SerializableDistributedRenderConfig } from "../events.js";
import { InvalidConfigError, validateDistributedRenderConfig } from "./validateConfig.js";
const VALID: SerializableDistributedRenderConfig = {
fps: 30,
width: 1920,
height: 1080,
format: "mp4",
};
describe("validateDistributedRenderConfig", () => {
it("returns the same reference on the happy path", () => {
expect(validateDistributedRenderConfig(VALID)).toBe(VALID);
});
it("accepts optional fields when valid", () => {
const cfg: SerializableDistributedRenderConfig = {
...VALID,
codec: "h265",
quality: "high",
crf: 18,
chunkSize: 240,
maxParallelChunks: 16,
runtimeCap: "lambda",
hdrMode: "force-sdr",
};
expect(validateDistributedRenderConfig(cfg)).toBe(cfg);
});
it.each([
["null config", null as unknown as SerializableDistributedRenderConfig, "config"],
[
"wrong fps",
{ ...VALID, fps: 25 as 24 | 30 | 60 } satisfies SerializableDistributedRenderConfig,
"config.fps",
],
[
"non-integer width",
{ ...VALID, width: 1280.5 } satisfies SerializableDistributedRenderConfig,
"config.width",
],
[
"odd width (yuv420p parity)",
{ ...VALID, width: 1281 } satisfies SerializableDistributedRenderConfig,
"config.width",
],
[
"out-of-range height",
{ ...VALID, height: 8000 } satisfies SerializableDistributedRenderConfig,
"config.height",
],
[
"unsupported format",
{
...VALID,
format: "webm",
} as unknown as SerializableDistributedRenderConfig,
"config.format",
],
[
"codec with non-mp4 format",
{ ...VALID, format: "mov", codec: "h264" } satisfies SerializableDistributedRenderConfig,
"config.codec",
],
[
"unknown codec",
{
...VALID,
codec: "av1",
} as unknown as SerializableDistributedRenderConfig,
"config.codec",
],
[
"crf + bitrate together",
{ ...VALID, crf: 18, bitrate: "10M" } satisfies SerializableDistributedRenderConfig,
"config.crf",
],
[
"crf out of range",
{ ...VALID, crf: 60 } satisfies SerializableDistributedRenderConfig,
"config.crf",
],
[
"malformed bitrate",
{ ...VALID, bitrate: "fast" } satisfies SerializableDistributedRenderConfig,
"config.bitrate",
],
[
"non-positive chunkSize",
{ ...VALID, chunkSize: 0 } satisfies SerializableDistributedRenderConfig,
"config.chunkSize",
],
[
"chunkSize over Lambda ceiling",
{ ...VALID, chunkSize: 9999 } satisfies SerializableDistributedRenderConfig,
"config.chunkSize",
],
[
"maxParallelChunks 0",
{ ...VALID, maxParallelChunks: 0 } satisfies SerializableDistributedRenderConfig,
"config.maxParallelChunks",
],
[
"unknown runtimeCap",
{
...VALID,
runtimeCap: "azure",
} as unknown as SerializableDistributedRenderConfig,
"config.runtimeCap",
],
[
"force-hdr rejected",
{
...VALID,
hdrMode: "force-hdr",
} as unknown as SerializableDistributedRenderConfig,
"config.hdrMode",
],
])("rejects %s with field=%s", (_label, input, expectedField) => {
try {
validateDistributedRenderConfig(input);
throw new Error("expected validateDistributedRenderConfig to throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe(expectedField);
expect((err as InvalidConfigError).name).toBe("InvalidConfigError");
}
});
});
@@ -0,0 +1,183 @@
/**
* Client-side validation of `SerializableDistributedRenderConfig` so the
* SDK fails on shape errors with a typed `InvalidConfigError` *before* a
* Step Functions execution starts.
*
* The producer's `plan` stage validates the same fields server-side, but a
* caller staring at "ExecutionFailed: BROWSER_GPU_NOT_SOFTWARE" five
* minutes after StartExecution has to dig through Step Functions history
* to learn that the renderToLambda call passed an unsupported format.
* Catching the obvious mistakes locally turns that wait into a synchronous
* throw.
*
* The check is deliberately narrow — it covers the *shape* errors any
* caller could have surfaced with `tsc` if they passed a literal, plus
* the documented `webm`/`force-hdr` rejections from §5.3 of the
* distributed-rendering plan. Anything deeper (font availability, plan
* size cap, GPU mode at runtime) needs the actual planner.
*/
import type { SerializableDistributedRenderConfig } from "../events.js";
/** Thrown for any client-side `SerializableDistributedRenderConfig` violation. */
export class InvalidConfigError extends Error {
override readonly name = "InvalidConfigError";
/** Dotted JSON-pointer-ish path to the offending field, e.g. `config.fps`. */
readonly field: string;
constructor(field: string, message: string) {
super(`[validateConfig] ${field}: ${message}`);
this.field = field;
}
}
const ALLOWED_FPS = [24, 30, 60] as const;
const ALLOWED_FORMATS = ["mp4", "mov", "png-sequence"] as const;
const ALLOWED_CODECS = ["h264", "h265"] as const;
const ALLOWED_QUALITIES = ["draft", "standard", "high"] as const;
const ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"] as const;
const ALLOWED_HDR_MODES = ["auto", "force-sdr"] as const;
const MAX_DIMENSION = 7680;
const MIN_DIMENSION = 16;
const MAX_CHUNK_SIZE = 3600;
const MAX_PARALLEL_CHUNKS_CEILING = 256;
/**
* Throw an `InvalidConfigError` if `config` is not a valid
* `SerializableDistributedRenderConfig`. Returns the same reference on
* success so the call site reads:
*
* const validated = validateDistributedRenderConfig(input);
*/
export function validateDistributedRenderConfig(
config: SerializableDistributedRenderConfig,
): SerializableDistributedRenderConfig {
if (config === null || typeof config !== "object") {
throw new InvalidConfigError("config", "must be an object");
}
if (!ALLOWED_FPS.includes(config.fps as 24 | 30 | 60)) {
throw new InvalidConfigError(
"config.fps",
`must be one of ${ALLOWED_FPS.join(", ")}; got ${String(config.fps)}`,
);
}
validateIntDimension("config.width", config.width);
validateIntDimension("config.height", config.height);
if (!ALLOWED_FORMATS.includes(config.format)) {
throw new InvalidConfigError(
"config.format",
`must be one of ${ALLOWED_FORMATS.join(", ")}; got ${String(config.format)}`,
);
}
if (config.codec !== undefined) {
if (config.format !== "mp4") {
throw new InvalidConfigError(
"config.codec",
`is only valid with format="mp4"; got format=${String(config.format)}`,
);
}
if (!ALLOWED_CODECS.includes(config.codec)) {
throw new InvalidConfigError(
"config.codec",
`must be one of ${ALLOWED_CODECS.join(", ")}; got ${String(config.codec)}`,
);
}
}
if (config.quality !== undefined && !ALLOWED_QUALITIES.includes(config.quality)) {
throw new InvalidConfigError(
"config.quality",
`must be one of ${ALLOWED_QUALITIES.join(", ")}; got ${String(config.quality)}`,
);
}
if (config.crf !== undefined && config.bitrate !== undefined) {
throw new InvalidConfigError("config.crf", "is mutually exclusive with config.bitrate");
}
if (
config.crf !== undefined &&
(!Number.isInteger(config.crf) || config.crf < 0 || config.crf > 51)
) {
throw new InvalidConfigError("config.crf", `must be an integer in [0, 51]; got ${config.crf}`);
}
if (config.bitrate !== undefined && !/^\d+(\.\d+)?[kKmM]?$/.test(config.bitrate)) {
throw new InvalidConfigError(
"config.bitrate",
`must look like "10M" or "5000k"; got ${JSON.stringify(config.bitrate)}`,
);
}
if (config.chunkSize !== undefined) {
if (!Number.isInteger(config.chunkSize) || config.chunkSize < 1) {
throw new InvalidConfigError(
"config.chunkSize",
`must be a positive integer; got ${config.chunkSize}`,
);
}
if (config.chunkSize > MAX_CHUNK_SIZE) {
throw new InvalidConfigError(
"config.chunkSize",
// Lambda 15-min cap leaves no useful headroom past ~3600 frames
// at 4 fps capture-equivalent throughput; rejecting up front
// avoids a 14-minute Plan-state retry storm.
`must be ≤ ${MAX_CHUNK_SIZE} (Lambda 15-min cap); got ${config.chunkSize}`,
);
}
}
if (config.maxParallelChunks !== undefined) {
if (!Number.isInteger(config.maxParallelChunks) || config.maxParallelChunks < 1) {
throw new InvalidConfigError(
"config.maxParallelChunks",
`must be a positive integer; got ${config.maxParallelChunks}`,
);
}
if (config.maxParallelChunks > MAX_PARALLEL_CHUNKS_CEILING) {
throw new InvalidConfigError(
"config.maxParallelChunks",
`must be ≤ ${MAX_PARALLEL_CHUNKS_CEILING}; got ${config.maxParallelChunks}`,
);
}
}
if (config.runtimeCap !== undefined && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) {
throw new InvalidConfigError(
"config.runtimeCap",
`must be one of ${ALLOWED_RUNTIME_CAPS.join(", ")}; got ${String(config.runtimeCap)}`,
);
}
if (config.hdrMode !== undefined && !ALLOWED_HDR_MODES.includes(config.hdrMode)) {
// `force-hdr` is rejected here on top of the producer's plan-stage
// rejection — it makes the typical typo (`"force-hdr"` from a copy-
// paste of in-process config) surface synchronously instead of as a
// typed Step Functions failure two minutes in.
throw new InvalidConfigError(
"config.hdrMode",
`distributed mode supports only ${ALLOWED_HDR_MODES.join(", ")}; got ${String(config.hdrMode)}`,
);
}
return config;
}
function validateIntDimension(field: string, value: unknown): void {
if (typeof value !== "number" || !Number.isInteger(value)) {
throw new InvalidConfigError(field, `must be an integer; got ${String(value)}`);
}
if (value < MIN_DIMENSION || value > MAX_DIMENSION) {
throw new InvalidConfigError(
field,
`must be in [${MIN_DIMENSION}, ${MAX_DIMENSION}]; got ${value}`,
);
}
if (value % 2 !== 0) {
// libx264 / libx265 yuv420p require even dimensions; rejecting now
// beats a Plan-stage ffmpeg crash on dimension parity.
throw new InvalidConfigError(field, `must be even (yuv420p constraint); got ${value}`);
}
}
+1
View File
@@ -45,6 +45,7 @@ export {
DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_SIZE,
DEFAULT_MAX_PARALLEL_CHUNKS, DEFAULT_MAX_PARALLEL_CHUNKS,
PLAN_DIR_SIZE_LIMIT_BYTES, PLAN_DIR_SIZE_LIMIT_BYTES,
PLAN_PROJECT_DIR_SKIP_SEGMENTS,
// Error codes + classes // Error codes + classes
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED, FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
FormatNotSupportedInDistributedError, FormatNotSupportedInDistributedError,
@@ -166,7 +166,7 @@ export interface PlanResult {
* whose absolute path happens to contain one of these names (e.g. * whose absolute path happens to contain one of these names (e.g.
* `~/work/output/comp/`) doesn't false-positive-skip the entire copy. * `~/work/output/comp/`) doesn't false-positive-skip the entire copy.
*/ */
const PLAN_PROJECT_DIR_SKIP_SEGMENTS = new Set([ export const PLAN_PROJECT_DIR_SKIP_SEGMENTS: ReadonlySet<string> = new Set([
"node_modules", "node_modules",
".git", ".git",
".cache", ".cache",