feat(aws-lambda): validate variables + 256 KiB Step Functions input cap (#976)

Add client-side validation for the new config.variables field
(introduced in PR 9.1) and a 256 KiB cap on the full Step Functions
Standard execution input. Both checks throw a typed InvalidConfigError
BEFORE the SDK calls StartExecution — catching the obvious mistakes
locally instead of as a States.DataLimitExceeded 50 ms into the
execution.

validateVariablesPayload walks the variables tree and rejects:
- functions, Symbols, BigInts, non-finite numbers
- undefined leaves (silently dropped by JSON.stringify — would
  surprise the caller when their value doesn't show up in the render)
- non-plain objects (Date, Map, class instances) — Date's toJSON does
  round-trip as a string, but the composition gets a string, not a
  Date, so explicit reject is clearer

validateStepFunctionsInputSize measures the actual UTF-8 byte length
of JSON.stringify(input) against the 256 KiB cap. We use Standard
workflows (per the plan §6.2 / §15.2) for execution-history
visibility, so the cap is 256 KiB (Express would be 32 KiB). The error
message names the actual byte count, the cap, and points at the
templates-on-lambda#working-with-large-variables section so users
know to URL-reference media assets instead of inlining them.

Both helpers are exported from @hyperframes/aws-lambda/sdk so adapters
that build custom Step Functions inputs (batch verbs, future Temporal
ports) can reuse the same gates.

Phase 9 PR 9.2 of the distributed rendering plan.
This commit is contained in:
James Russo
2026-05-19 19:53:31 -04:00
committed by GitHub
parent 0decb88946
commit 87fdd556c4
5 changed files with 451 additions and 3 deletions
@@ -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,
@@ -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/);
});
});