Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 68 additions & 7 deletions bun.lock

Large diffs are not rendered by default.

108 changes: 93 additions & 15 deletions packages/aws-lambda/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
20 changes: 18 additions & 2 deletions packages/aws-lambda/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"
}
Expand Down
137 changes: 137 additions & 0 deletions packages/aws-lambda/src/cdk/HyperframesRenderStack.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading