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
8 changes: 7 additions & 1 deletion packages/aws-lambda/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export {
computeRenderCost,
type RenderCost,
} from "./costAccounting.js";
export { InvalidConfigError, validateDistributedRenderConfig } from "./validateConfig.js";
export {
InvalidConfigError,
MAX_STEP_FUNCTIONS_INPUT_BYTES,
validateDistributedRenderConfig,
validateStepFunctionsInputSize,
validateVariablesPayload,
} from "./validateConfig.js";
export type { SerializableDistributedRenderConfig } from "../events.js";
export type { DistributedFormat } from "../formatExtension.js";
65 changes: 65 additions & 0 deletions packages/aws-lambda/src/sdk/renderToLambda.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,71 @@ describe("renderToLambda", () => {
expect(handle.renderId).toMatch(/^hf-render-[0-9a-f-]{36}$/);
});

it("threads variables through the Step Functions execution input", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
const variables = { title: "Hello Alice", accent: "#ff0000" };
await renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: { ...baseConfig, variables },
executionName: "smoke-variables",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
});
expect(sfn.starts).toHaveLength(1);
const start = sfn.starts[0]!;
// The execution input carries the variables under Config.variables —
// the Step Functions state machine forwards `Config` verbatim into the
// PlanEvent's `Config` field, where the handler spreads it into the
// producer's DistributedRenderConfig.
const input = start.input as { Config: { variables?: Record<string, unknown> } };
expect(input.Config.variables).toEqual(variables);
});

it("rejects a config whose variables blob would push the execution input over 256 KiB", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
const huge = "x".repeat(260 * 1024);
await expect(
renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: { ...baseConfig, variables: { blob: huge } },
executionName: "smoke-too-big",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
}),
).rejects.toThrow(/256.*KiB|templates-on-lambda/);
// The reject must happen BEFORE StartExecution — uncaught oversize input
// surfaces as States.DataLimitExceeded 50ms in, far from this call site.
expect(sfn.starts).toHaveLength(0);
});

it("rejects a config whose variables contain non-JSON-safe values", async () => {
const sfn = new FakeSFN();
const s3 = new FakeS3();
await expect(
renderToLambda({
projectDir,
bucketName: "test-bucket",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
config: {
...baseConfig,
// BigInt would throw at JSON.stringify time; catch it at the validator
// boundary with a typed error instead.
variables: { count: 9_007_199_254_740_993n } as unknown as Record<string, unknown>,
},
executionName: "smoke-bigint",
sfn: asSFNClient(sfn),
s3: asS3Client(s3),
}),
).rejects.toThrow(InvalidConfigError);
expect(sfn.starts).toHaveLength(0);
});

it("propagates a missing executionArn as an error", async () => {
const sfn = {
async send(_cmd: unknown): Promise<unknown> {
Expand Down
15 changes: 14 additions & 1 deletion packages/aws-lambda/src/sdk/renderToLambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ 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";
import {
validateDistributedRenderConfig,
validateStepFunctionsInputSize,
} from "./validateConfig.js";

/** Options for {@link renderToLambda}. */
export interface RenderToLambdaOptions {
Expand Down Expand Up @@ -70,6 +73,7 @@ export interface RenderHandle {
startedAt: string;
}

// fallow-ignore-next-line complexity
export async function renderToLambda(opts: RenderToLambdaOptions): Promise<RenderHandle> {
validateDistributedRenderConfig(opts.config);

Expand Down Expand Up @@ -108,6 +112,15 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise<Rende
Config: opts.config,
};

// Reject oversize input client-side. Step Functions Standard caps the
// execution input at 256 KiB; without this check, the input bloat
// (typically from `config.variables` containing inlined media) surfaces
// as `States.DataLimitExceeded` 50 ms into the execution, far from the
// caller's stack frame. Measured AFTER `deploySite` so the synthesised
// `ProjectS3Uri` is counted (a few hundred bytes either way, but the
// check should be against the actual wire payload).
validateStepFunctionsInputSize(input);

const sfn = opts.sfn ?? new SFNClient({ region: opts.region });
const startedAt = new Date().toISOString();
const response = await sfn.send(
Expand Down
201 changes: 200 additions & 1 deletion packages/aws-lambda/src/sdk/validateConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from "bun:test";
import type { SerializableDistributedRenderConfig } from "../events.js";
import { InvalidConfigError, validateDistributedRenderConfig } from "./validateConfig.js";
import {
InvalidConfigError,
MAX_STEP_FUNCTIONS_INPUT_BYTES,
validateDistributedRenderConfig,
validateStepFunctionsInputSize,
validateVariablesPayload,
} from "./validateConfig.js";

const VALID: SerializableDistributedRenderConfig = {
fps: 30,
Expand Down Expand Up @@ -127,4 +133,197 @@ describe("validateDistributedRenderConfig", () => {
expect((err as InvalidConfigError).name).toBe("InvalidConfigError");
}
});

describe("variables", () => {
it("accepts a plain JSON object", () => {
const cfg: SerializableDistributedRenderConfig = {
...VALID,
variables: {
title: "Hello",
accent: "#ff0000",
nested: { items: [1, 2, 3], visible: true, note: null },
},
};
expect(validateDistributedRenderConfig(cfg)).toBe(cfg);
});

it("rejects variables that's an array, not a plain object", () => {
try {
validateDistributedRenderConfig({
...VALID,
variables: [1, 2, 3] as unknown as Record<string, unknown>,
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables");
}
});

it("rejects functions inside variables", () => {
try {
validateVariablesPayload({ greet: () => "hi" });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.greet");
expect((err as Error).message).toMatch(/function/i);
}
});

it("rejects undefined leaves (silently dropped by JSON.stringify)", () => {
try {
validateVariablesPayload({ title: "x", maybe: undefined });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.maybe");
}
});

it("rejects BigInt values", () => {
try {
validateVariablesPayload({ count: 9_007_199_254_740_993n });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.count");
}
});

it("rejects NaN / Infinity numbers", () => {
try {
validateVariablesPayload({ ratio: Number.NaN });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.ratio");
}
try {
validateVariablesPayload({ ratio: Number.POSITIVE_INFINITY });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.ratio");
}
});

it("rejects Symbols", () => {
try {
validateVariablesPayload({ id: Symbol("hi") });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.id");
}
});

it("rejects Date instances (non-plain objects)", () => {
try {
validateVariablesPayload({ when: new Date("2026-01-01") });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.when");
expect((err as Error).message).toMatch(/Date|non-plain/);
}
});

it("walks into arrays and reports nested paths", () => {
try {
validateVariablesPayload({ items: ["a", { broken: () => 1 }] });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as InvalidConfigError).field).toBe("config.variables.items[1].broken");
}
});

it("rejects circular references with a typed error instead of stack-overflowing", () => {
const cyclic: Record<string, unknown> = { title: "x" };
cyclic.self = cyclic;
try {
validateVariablesPayload(cyclic);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as Error).message).toMatch(/circular/i);
}
});

it("rejects cycles via arrays too", () => {
const arr: unknown[] = ["a"];
arr.push(arr);
try {
validateVariablesPayload({ items: arr });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
expect((err as Error).message).toMatch(/circular/i);
}
});

it("round-trips through JSON for the validated set", () => {
const variables = {
title: "Personalised render",
scene: { intro: { lines: ["one", "two"], delay: 0.5 } },
tags: ["alpha", "beta"],
active: true,
nothing: null,
};
validateVariablesPayload(variables);
const round = JSON.parse(JSON.stringify(variables));
expect(round).toEqual(variables);
});
});
});

describe("validateStepFunctionsInputSize", () => {
it("accepts inputs under the 256 KiB cap", () => {
const input = {
ProjectS3Uri: "s3://bucket/sites/abc/project.tar.gz",
Config: { fps: 30, width: 1280, height: 720, format: "mp4" },
};
expect(() => validateStepFunctionsInputSize(input)).not.toThrow();
});

it("rejects inputs over the 256 KiB cap with a message that names the byte count", () => {
// Build a variables blob that pushes the serialised input over the cap.
// 256 KiB ÷ 2 bytes per char × 1 char per byte for ASCII; pad to 260 KiB
// worth of payload so the serialiser overhead is dwarfed.
const huge = "x".repeat(260 * 1024);
const input = {
ProjectS3Uri: "s3://bucket/sites/abc/project.tar.gz",
Config: {
fps: 30,
width: 1280,
height: 720,
format: "mp4",
variables: { blob: huge },
},
};
try {
validateStepFunctionsInputSize(input);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(InvalidConfigError);
const msg = (err as Error).message;
expect(msg).toMatch(/256/);
// Names the actual byte count so users see how far over the cap they are.
const serialized = JSON.stringify(input);
const expectedBytes = Buffer.byteLength(serialized, "utf8");
expect(msg).toContain(String(expectedBytes));
// Pointer to the docs section on URL'ing assets.
expect(msg).toMatch(/templates-on-lambda/);
}
});

it("MAX_STEP_FUNCTIONS_INPUT_BYTES is 256 KiB", () => {
expect(MAX_STEP_FUNCTIONS_INPUT_BYTES).toBe(256 * 1024);
});

it("rejects non-JSON-serializable roots with a clear error", () => {
// A top-level function reference makes JSON.stringify return undefined.
expect(() => validateStepFunctionsInputSize(() => "boom")).toThrow(/not JSON-serializable/);
});
});
Loading
Loading