mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
* 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.
229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
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;
|