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

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

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

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

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

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

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

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

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

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

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

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

Two changes:

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

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

No behavior changes.

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

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

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

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

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

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

All 83 aws-lambda tests still pass.
This commit is contained in:
James Russo
2026-05-17 03:03:51 -04:00
committed by GitHub
parent 0d726ae010
commit 34d1f0e1d0
23 changed files with 2662 additions and 47 deletions
@@ -0,0 +1,228 @@
import { describe, expect, it } from "bun:test";
import {
DescribeExecutionCommand,
GetExecutionHistoryCommand,
type HistoryEvent,
type SFNClient,
} from "@aws-sdk/client-sfn";
import { getRenderProgress } from "./getRenderProgress.js";
interface DescribeShape {
status?: string;
startDate?: Date;
stopDate?: Date;
}
class FakeSFN {
describe: DescribeShape = {};
// Pages of history events; FakeSFN walks them in order, paginating with `nextToken`.
historyPages: HistoryEvent[][] = [];
async send(command: unknown): Promise<unknown> {
const cmdName = (command as { constructor: { name: string } }).constructor.name;
if (cmdName === "DescribeExecutionCommand") {
return {
status: this.describe.status ?? "RUNNING",
startDate: this.describe.startDate ?? new Date("2026-05-16T00:00:00Z"),
stopDate: this.describe.stopDate,
};
}
if (cmdName === "GetExecutionHistoryCommand") {
const input = (command as { input: { nextToken?: string } }).input;
const idx = input.nextToken ? Number.parseInt(input.nextToken, 10) : 0;
const page = this.historyPages[idx] ?? [];
const nextToken = idx + 1 < this.historyPages.length ? String(idx + 1) : undefined;
return { events: page, nextToken };
}
throw new Error(`FakeSFN: unexpected command ${cmdName}`);
}
}
function lambdaSucceeded(payload: unknown): HistoryEvent {
return {
type: "LambdaFunctionSucceeded",
id: 1,
timestamp: new Date(),
lambdaFunctionSucceededEventDetails: { output: JSON.stringify(payload) },
} as HistoryEvent;
}
function stateEntered(name: string): HistoryEvent {
return {
type: "TaskStateEntered",
id: 1,
timestamp: new Date(),
stateEnteredEventDetails: { name },
} as HistoryEvent;
}
function stateExited(name: string, output?: unknown): HistoryEvent {
return {
type: "TaskStateExited",
id: 1,
timestamp: new Date(),
stateExitedEventDetails: {
name,
output: output === undefined ? undefined : JSON.stringify(output),
},
} as HistoryEvent;
}
describe("getRenderProgress", () => {
it("reports 0 progress before Plan completes", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[]];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.status).toBe("RUNNING");
expect(progress.overallProgress).toBe(0);
expect(progress.totalFrames).toBeNull();
expect(progress.framesRendered).toBe(0);
});
it("reports 0.1 once Plan completes (totalFrames known, no chunks done)", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
lambdaSucceeded({
Action: "plan",
TotalFrames: 240,
DurationMs: 1_000,
}),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.totalFrames).toBe(240);
expect(progress.overallProgress).toBeCloseTo(0.1, 6);
expect(progress.framesRendered).toBe(0);
});
it("advances chunk progress proportionally", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("Plan"),
lambdaSucceeded({ Action: "plan", TotalFrames: 100, DurationMs: 1_000 }),
stateEntered("RenderChunk"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 50, DurationMs: 2_000 }),
],
];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
// 0.1 + 0.8 × 0.5 = 0.5
expect(progress.overallProgress).toBeCloseTo(0.5, 6);
expect(progress.framesRendered).toBe(50);
});
it("does not double-count Assemble's FramesEncoded toward framesRendered", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("Plan"),
lambdaSucceeded({ Action: "plan", TotalFrames: 100, DurationMs: 1_000 }),
stateEntered("RenderChunk"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 100, DurationMs: 2_000 }),
stateEntered("Assemble"),
lambdaSucceeded({
Action: "assemble",
FramesEncoded: 100,
FileSize: 9_000_000,
OutputS3Uri: "s3://b/k.mp4",
DurationMs: 1_500,
}),
stateExited("Assemble", {
Output: { OutputS3Uri: "s3://b/k.mp4", FileSize: 9_000_000, FramesEncoded: 100 },
}),
],
];
sfn.describe.status = "SUCCEEDED";
sfn.describe.stopDate = new Date("2026-05-16T00:05:00Z");
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.framesRendered).toBe(100);
expect(progress.overallProgress).toBe(1);
expect(progress.outputFile).toEqual({ s3Uri: "s3://b/k.mp4", bytes: 9_000_000 });
expect(progress.endedAt).not.toBeNull();
});
it("computes cost from observed billed duration", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[lambdaSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 6_000 })]];
const progress = await getRenderProgress({
executionArn: "arn",
defaultMemorySizeMb: 10_240,
sfn: sfn as unknown as SFNClient,
});
// 1 transition event + 1 invocation × 60 GB-s × $0.0000166667 ≈ $0.001
expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.001, 4);
});
it("captures Lambda failures with the enclosing state name", async () => {
const sfn = new FakeSFN();
const failed: HistoryEvent = {
type: "LambdaFunctionFailed",
id: 2,
timestamp: new Date(),
lambdaFunctionFailedEventDetails: {
error: "PLAN_HASH_MISMATCH",
cause: "bad plan",
},
} as HistoryEvent;
sfn.historyPages = [[stateEntered("RenderChunk"), failed]];
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.errors).toEqual([
{ state: "RenderChunk", error: "PLAN_HASH_MISMATCH", cause: "bad plan" },
]);
});
it("marks fatalErrorEncountered when execution ends FAILED", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [[]];
sfn.describe.status = "FAILED";
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.fatalErrorEncountered).toBe(true);
});
it("paginates the history", async () => {
const sfn = new FakeSFN();
sfn.historyPages = [
[
stateEntered("Plan"),
lambdaSucceeded({ Action: "plan", TotalFrames: 4, DurationMs: 1_000 }),
],
[
stateEntered("RenderChunk"),
lambdaSucceeded({ Action: "renderChunk", FramesEncoded: 4, DurationMs: 2_000 }),
],
];
sfn.describe.status = "SUCCEEDED";
const progress = await getRenderProgress({
executionArn: "arn",
sfn: sfn as unknown as SFNClient,
});
expect(progress.framesRendered).toBe(4);
expect(progress.totalFrames).toBe(4);
expect(progress.overallProgress).toBe(1);
});
it("requires executionArn", async () => {
await expect(getRenderProgress({ executionArn: "" })).rejects.toThrow(/executionArn/);
});
});
void DescribeExecutionCommand;
void GetExecutionHistoryCommand;