mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe
Phase 6 of the distributed rendering plan: AWS Lambda turnkey adoption
(see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 6 + §15).
This PR adds the new packages/aws-lambda/ workspace package that wraps
the OSS plan/renderChunk/assemble primitives in an AWS Lambda handler,
plus a build pipeline that bundles the handler + Chromium runtime +
ffmpeg into a deployable ZIP.
Architecture: ZIP deploy (not Docker image), Chrome via @sparticuz/chromium
with chrome-headless-shell fallback, dispatch on event.Action ∈ {plan,
renderChunk, assemble}.
The load-bearing concern — does @sparticuz/chromium's chrome-headless-shell
build honour CDP HeadlessExperimental.beginFrame? — is pinned by the new
scripts/probe-beginframe.ts regression guard. Probe boots the runtime
inside public.ecr.aws/lambda/nodejs:22, navigates to a static page, and
asserts beginFrame returns a PNG buffer. Verified locally + inside the
Docker container; both pass with hasDamage=true.
Sizes (sparticuz source): unzipped 157 MiB, zipped 99 MiB. Well under
the 240 MiB / 150 MiB in-house gates and the Lambda 250 MiB hard ceiling.
This is part of a stack of 8 PRs (3 in Phase 6a, 5 in Phase 6b); this is
PR 6.1.
* fix(lambda): address PR 878 review feedback
- Verify event.PlanHash against the untarred plan.json at the handler
boundary before invoking the producer primitive. Throws typed
PLAN_HASH_MISMATCH on divergence so Step Functions routes it as
non-retryable; previously the field was schema bloat the handler
ignored, leaving enforcement entirely inside the producer.
- Standardize on MiB throughout build-zip.ts, verify-zip-size.ts, and
the README. Lambda's hard ceiling is 250 MiB (AWS docs label "250 MB"
but use binary mebibytes); previously mixed units made the 248 MiB
budget look like a ~5 MB margin instead of the 2 MiB it actually is.
- stageChromeHeadlessShell now picks Chrome versions via numeric semver
comparison instead of lexicographic sort+reverse — the latter would
silently pick "99.x" over "131.x" once Chrome cached three-digit
majors that aren't width-aligned.
- Drop _setSparticuzChromiumForTests from the public index barrel.
Test-only DI seam imported directly from ./chromium.js in tests.
- Replace require("node:fs") inside walkSize() with the top-level fs
imports — file is ESM and the same module is already imported.
* docs(lambda): drop internal plan-doc refs from package README
* ci(windows): fix bun filter UNION bug excluding producer from Windows tests
`bun run --filter "!a" --filter "!b" test` composes as a UNION (any
package matching either negation runs), not an intersection. Effect:
@hyperframes/producer was still being tested on Windows even though
it's explicitly excluded — its regression harness (Docker + LFS golden
mp4 baselines) is Linux-only and was driving the 32min timeout.
Enumerate the packages we DO want to test instead.
100 lines
3.8 KiB
TypeScript
100 lines
3.8 KiB
TypeScript
/**
|
|
* Unit tests for the Chrome runtime resolver.
|
|
*
|
|
* The actual @sparticuz/chromium probe lives in
|
|
* `scripts/probe-beginframe.ts` (run in a Lambda-like Docker container).
|
|
* These tests pin the env-var → source-selection logic so a misconfigured
|
|
* deploy fails loudly rather than silently picking the wrong binary.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
_setSparticuzChromiumForTests,
|
|
resolveChromeArgs,
|
|
resolveChromeExecutablePath,
|
|
resolveChromeSource,
|
|
} from "./chromium.js";
|
|
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
|
|
beforeEach(() => {
|
|
savedEnv.HYPERFRAMES_LAMBDA_CHROME_SOURCE = process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE;
|
|
savedEnv.HYPERFRAMES_LAMBDA_CHROME_PATH = process.env.HYPERFRAMES_LAMBDA_CHROME_PATH;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = savedEnv.HYPERFRAMES_LAMBDA_CHROME_SOURCE;
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_PATH = savedEnv.HYPERFRAMES_LAMBDA_CHROME_PATH;
|
|
_setSparticuzChromiumForTests(null);
|
|
});
|
|
|
|
describe("resolveChromeSource", () => {
|
|
it("defaults to sparticuz when no env var is set", () => {
|
|
delete process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE;
|
|
expect(resolveChromeSource()).toBe("sparticuz");
|
|
});
|
|
|
|
it("returns chrome-headless-shell when env var requests it", () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
|
|
expect(resolveChromeSource()).toBe("chrome-headless-shell");
|
|
});
|
|
|
|
it("accepts the short alias 'shell'", () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "shell";
|
|
expect(resolveChromeSource()).toBe("chrome-headless-shell");
|
|
});
|
|
|
|
it("is case insensitive", () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "Chrome-Headless-Shell";
|
|
expect(resolveChromeSource()).toBe("chrome-headless-shell");
|
|
});
|
|
|
|
it("falls back to sparticuz on an unknown value", () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "wat";
|
|
expect(resolveChromeSource()).toBe("sparticuz");
|
|
});
|
|
});
|
|
|
|
describe("resolveChromeExecutablePath", () => {
|
|
it("returns the path from a stubbed sparticuz module", async () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "sparticuz";
|
|
_setSparticuzChromiumForTests({
|
|
args: ["--fake-arg"],
|
|
executablePath: async () => "/tmp/sparticuz-chromium",
|
|
});
|
|
expect(await resolveChromeExecutablePath()).toBe("/tmp/sparticuz-chromium");
|
|
expect(await resolveChromeArgs()).toEqual(["--fake-arg"]);
|
|
});
|
|
|
|
it("reads chrome-headless-shell path from HYPERFRAMES_LAMBDA_CHROME_PATH", async () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
|
|
const dir = mkdtempSync(join(tmpdir(), "hf-chrome-test-"));
|
|
const binPath = join(dir, "chrome-headless-shell");
|
|
writeFileSync(binPath, "fake binary contents");
|
|
try {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_PATH = binPath;
|
|
expect(await resolveChromeExecutablePath()).toBe(binPath);
|
|
expect(await resolveChromeArgs()).toEqual([]);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("throws if chrome-headless-shell path is missing", async () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
|
|
delete process.env.HYPERFRAMES_LAMBDA_CHROME_PATH;
|
|
await expect(resolveChromeExecutablePath()).rejects.toThrow(
|
|
/HYPERFRAMES_LAMBDA_CHROME_PATH to be set/,
|
|
);
|
|
});
|
|
|
|
it("throws if chrome-headless-shell path doesn't exist on disk", async () => {
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
|
|
process.env.HYPERFRAMES_LAMBDA_CHROME_PATH = "/nonexistent/path/chrome-headless-shell";
|
|
await expect(resolveChromeExecutablePath()).rejects.toThrow(/does not exist/);
|
|
});
|
|
});
|