diff --git a/bun.lock b/bun.lock index f4d49111f..e39963586 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md index d32e5da20..492637d18 100644 --- a/packages/aws-lambda/README.md +++ b/packages/aws-lambda/README.md @@ -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. - -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. +AWS Lambda adapter for HyperFrames distributed rendering. Ships three +things together: + +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 + +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 -- `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. +- `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. diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index 3c6447fbb..66215fdf1 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -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" } diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts new file mode 100644 index 000000000..ef2e3f493 --- /dev/null +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts @@ -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); +}); diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts new file mode 100644 index 000000000..1baadb27d --- /dev/null +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts @@ -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; + +const EXPECTED_RESOURCE_COUNTS: Record = { + "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; 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; 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 : "<>")) + .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 = {}; + const allResources = template.toJSON().Resources as Record; + 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(); + // 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 }; + ItemProcessor?: { States?: Record }; + } + | 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): 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); + } + } +} diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts new file mode 100644 index 000000000..128c24e1c --- /dev/null +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts @@ -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"); +} diff --git a/packages/aws-lambda/src/cdk/index.ts b/packages/aws-lambda/src/cdk/index.ts new file mode 100644 index 000000000..bdc321398 --- /dev/null +++ b/packages/aws-lambda/src/cdk/index.ts @@ -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"; diff --git a/packages/aws-lambda/src/formatExtension.ts b/packages/aws-lambda/src/formatExtension.ts new file mode 100644 index 000000000..61368daa3 --- /dev/null +++ b/packages/aws-lambda/src/formatExtension.ts @@ -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}`); + } + } +} diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index 83da2d967..85e5ff16f 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -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 diff --git a/packages/aws-lambda/src/index.ts b/packages/aws-lambda/src/index.ts index 6cf54adaa..ef41f4d90 100644 --- a/packages/aws-lambda/src/index.ts +++ b/packages/aws-lambda/src/index.ts @@ -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. diff --git a/packages/aws-lambda/src/sdk/__fixtures__/fakeS3.ts b/packages/aws-lambda/src/sdk/__fixtures__/fakeS3.ts new file mode 100644 index 000000000..8221b9c79 --- /dev/null +++ b/packages/aws-lambda/src/sdk/__fixtures__/fakeS3.ts @@ -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(); + + async send(command: unknown): Promise { + 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 { + if (Buffer.isBuffer(body)) return; + await new Promise((resolve, reject) => { + body.on("data", () => {}); + body.on("end", () => resolve()); + body.on("close", () => resolve()); + body.on("error", reject); + }); +} diff --git a/packages/aws-lambda/src/sdk/costAccounting.test.ts b/packages/aws-lambda/src/sdk/costAccounting.test.ts new file mode 100644 index 000000000..be789daab --- /dev/null +++ b/packages/aws-lambda/src/sdk/costAccounting.test.ts @@ -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"); + }); +}); diff --git a/packages/aws-lambda/src/sdk/costAccounting.ts b/packages/aws-lambda/src/sdk/costAccounting.ts new file mode 100644 index 000000000..2679d48c0 --- /dev/null +++ b/packages/aws-lambda/src/sdk/costAccounting.ts @@ -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)}`; +} diff --git a/packages/aws-lambda/src/sdk/deploySite.test.ts b/packages/aws-lambda/src/sdk/deploySite.test.ts new file mode 100644 index 000000000..9863b0518 --- /dev/null +++ b/packages/aws-lambda/src/sdk/deploySite.test.ts @@ -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"), "hi"); + 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"), "changed"); + 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 { + 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); + }); +}); diff --git a/packages/aws-lambda/src/sdk/deploySite.ts b/packages/aws-lambda/src/sdk/deploySite.ts new file mode 100644 index 000000000..7cc5af09d --- /dev/null +++ b/packages/aws-lambda/src/sdk/deploySite.ts @@ -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//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//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 { + 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; + } +} diff --git a/packages/aws-lambda/src/sdk/getRenderProgress.test.ts b/packages/aws-lambda/src/sdk/getRenderProgress.test.ts new file mode 100644 index 000000000..b5ba925bd --- /dev/null +++ b/packages/aws-lambda/src/sdk/getRenderProgress.test.ts @@ -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 { + 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; diff --git a/packages/aws-lambda/src/sdk/getRenderProgress.ts b/packages/aws-lambda/src/sdk/getRenderProgress.ts new file mode 100644 index 000000000..093f62142 --- /dev/null +++ b/packages/aws-lambda/src/sdk/getRenderProgress.ts @@ -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 ``). */ + 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 { + 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 { + 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; + 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; + const out = obj.Output as Record | 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 ?? "", + error: ev.lambdaFunctionFailedEventDetails?.error ?? "UNKNOWN", + cause: ev.lambdaFunctionFailedEventDetails?.cause ?? "", + }); + break; + case "ExecutionFailed": + errors.push({ + state: "", + error: ev.executionFailedEventDetails?.error ?? "UNKNOWN", + cause: ev.executionFailedEventDetails?.cause ?? "", + }); + break; + case "ExecutionAborted": + errors.push({ + state: "", + error: ev.executionAbortedEventDetails?.error ?? "ABORTED", + cause: ev.executionAbortedEventDetails?.cause ?? "", + }); + break; + case "ExecutionTimedOut": + errors.push({ + state: "", + 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; + 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"; +} diff --git a/packages/aws-lambda/src/sdk/renderToLambda.test.ts b/packages/aws-lambda/src/sdk/renderToLambda.test.ts new file mode 100644 index 000000000..9dda1a8a2 --- /dev/null +++ b/packages/aws-lambda/src/sdk/renderToLambda.test.ts @@ -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 { + 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 }): 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"), ""); +}); + +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 { + 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/); + }); +}); diff --git a/packages/aws-lambda/src/sdk/renderToLambda.ts b/packages/aws-lambda/src/sdk/renderToLambda.ts new file mode 100644 index 000000000..94ee93101 --- /dev/null +++ b/packages/aws-lambda/src/sdk/renderToLambda.ts @@ -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//output.` + * where `` is derived from `config.format`. + */ + outputKey?: string; + /** + * Step Functions execution name. Defaults to `hf-render-`. + * 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 { + 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, + }; +} diff --git a/packages/aws-lambda/src/sdk/validateConfig.test.ts b/packages/aws-lambda/src/sdk/validateConfig.test.ts new file mode 100644 index 000000000..7967f6a81 --- /dev/null +++ b/packages/aws-lambda/src/sdk/validateConfig.test.ts @@ -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"); + } + }); +}); diff --git a/packages/aws-lambda/src/sdk/validateConfig.ts b/packages/aws-lambda/src/sdk/validateConfig.ts new file mode 100644 index 000000000..51e085293 --- /dev/null +++ b/packages/aws-lambda/src/sdk/validateConfig.ts @@ -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}`); + } +} diff --git a/packages/producer/src/distributed.ts b/packages/producer/src/distributed.ts index c63b3d533..0dbf43308 100644 --- a/packages/producer/src/distributed.ts +++ b/packages/producer/src/distributed.ts @@ -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, diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index a5f692555..7a2f6be9f 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -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 = new Set([ "node_modules", ".git", ".cache",