mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user