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
@@ -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> {