mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-06 17:46:03 +00:00
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:
@@ -24,6 +24,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
"@hyperframes/producer": "workspace:^",
|
||||
"@sparticuz/chromium": "148.0.0",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
@@ -35,14 +36,24 @@
|
||||
"@types/aws-lambda": "^8.10.146",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/tar": "^6.1.13",
|
||||
"aws-cdk-lib": "^2.130.0",
|
||||
"constructs": "^10.3.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"aws-cdk-lib": "^2.130.0",
|
||||
"constructs": "^10.3.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"aws-cdk-lib",
|
||||
"constructs",
|
||||
],
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.14",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -85,7 +96,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.14",
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"postcss": "^8.5.8",
|
||||
@@ -112,7 +123,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.14",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -130,7 +141,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.14",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.0",
|
||||
"gsap": "^3.12.5",
|
||||
@@ -142,7 +153,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.14",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -181,7 +192,7 @@
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.14",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -193,7 +204,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.14",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -251,6 +262,14 @@
|
||||
|
||||
"@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/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-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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"case": ["case@1.6.3", "", {}, "sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1095,6 +1124,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
@@ -1299,6 +1332,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
@@ -1647,6 +1690,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
# @hyperframes/aws-lambda
|
||||
|
||||
AWS Lambda adapter for HyperFrames distributed rendering. Wraps the OSS
|
||||
`plan` / `renderChunk` / `assemble` primitives into a single Lambda handler
|
||||
that Step Functions can dispatch on, plus a build pipeline that bundles
|
||||
the handler + Chrome runtime + ffmpeg into a deployable ZIP.
|
||||
AWS Lambda adapter for HyperFrames distributed rendering. Ships three
|
||||
things together:
|
||||
|
||||
The Lambda adapter ships in two parts: the foundation (this package + the
|
||||
SAM example) validates the architecture end-to-end on real AWS; the
|
||||
user-facing surface (CLI, CDK construct, migration guide) lands in
|
||||
follow-up PRs.
|
||||
1. The **Lambda handler** that wraps the OSS `plan` / `renderChunk` /
|
||||
`assemble` primitives behind a single dispatch boundary Step Functions
|
||||
can drive (`src/handler.ts`).
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
## What's NOT in this PR
|
||||
## Using the SDK
|
||||
|
||||
- `examples/aws-lambda/template.yaml` (SAM template — separate PR).
|
||||
- Real-AWS deploy + smoke workflow (separate PR).
|
||||
- `npx hyperframes lambda deploy` CLI — follow-up.
|
||||
- CDK construct (`HyperframesRenderStack`) — follow-up.
|
||||
- Migration guide — follow-up.
|
||||
After deploying the stack (via the SAM template, CDK construct below, or
|
||||
your own CFN of choice), drive renders from Node:
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"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": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
@@ -17,7 +17,8 @@
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./handler": "./src/handler.ts"
|
||||
"./handler": "./src/handler.ts",
|
||||
"./cdk": "./src/cdk/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -34,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
"@hyperframes/producer": "workspace:^",
|
||||
"@sparticuz/chromium": "148.0.0",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
@@ -45,10 +47,24 @@
|
||||
"@types/aws-lambda": "^8.10.146",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/tar": "^6.1.13",
|
||||
"aws-cdk-lib": "^2.130.0",
|
||||
"constructs": "^10.3.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"tsx": "^4.21.0",
|
||||
"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": {
|
||||
"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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
renderChunk,
|
||||
} from "@hyperframes/producer/distributed";
|
||||
import { resolveChromeExecutablePath } from "./chromium.js";
|
||||
import { formatExtension } from "./formatExtension.js";
|
||||
import type {
|
||||
AssembleEvent,
|
||||
AssembleLambdaResult,
|
||||
@@ -472,22 +473,6 @@ function trimTrailingSlash(prefix: string): string {
|
||||
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 {
|
||||
try {
|
||||
// Lambda warm starts can reuse `/tmp` across invocations; clean up
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
* `@hyperframes/aws-lambda` — Lambda adapter for the HyperFrames
|
||||
* distributed render pipeline.
|
||||
*
|
||||
* The package exports the Lambda handler entry point plus the event /
|
||||
* result types Step Functions consumers and CDK constructs need to
|
||||
* type-check their state machine definitions.
|
||||
* Two surfaces, one package:
|
||||
*
|
||||
* The handler is bundled with `scripts/build-zip.ts` into `dist/handler.zip`
|
||||
* — that artifact is what `examples/aws-lambda/template.yaml` and any
|
||||
* future CDK construct point at via `CodeUri`. The package is NOT a
|
||||
* dependency of `@hyperframes/producer`; consumers install it separately.
|
||||
* - **Server-side handler.** `handler`, the Step Functions event/result
|
||||
* types, Chrome resolution, and S3 transport. These power the Lambda
|
||||
* function bundled into `dist/handler.zip`.
|
||||
* - **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";
|
||||
@@ -43,3 +53,26 @@ export {
|
||||
untarDirectory,
|
||||
uploadFileToS3,
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_MAX_PARALLEL_CHUNKS,
|
||||
PLAN_DIR_SIZE_LIMIT_BYTES,
|
||||
PLAN_PROJECT_DIR_SKIP_SEGMENTS,
|
||||
// Error codes + classes
|
||||
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
|
||||
FormatNotSupportedInDistributedError,
|
||||
|
||||
@@ -166,7 +166,7 @@ export interface PlanResult {
|
||||
* whose absolute path happens to contain one of these names (e.g.
|
||||
* `~/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",
|
||||
".git",
|
||||
".cache",
|
||||
|
||||
Reference in New Issue
Block a user