Files
hyperframes/packages/aws-lambda/scripts/probe-beginframe.ts
T
James Russo c50f59a53b feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe (#878)
* 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.
2026-05-16 18:08:47 -04:00

158 lines
5.8 KiB
TypeScript

#!/usr/bin/env tsx
/**
* BeginFrame regression guard for `@sparticuz/chromium`.
*
* The load-bearing assumption of `@hyperframes/aws-lambda` is that the
* Chromium build shipped by `@sparticuz/chromium` honours CDP
* `HeadlessExperimental.beginFrame` with `screenshot: true`. This script
* boots that Chromium build (decompressing into `/tmp` per the library's
* runtime contract), navigates to a tiny static page, issues one
* `beginFrame` with a screenshot request, and asserts the response
* carries a PNG buffer.
*
* The script is the contract test, not a one-shot verification — every
* release should run it inside the Docker container at
* `scripts/probe-beginframe.dockerfile` to catch any future
* `@sparticuz/chromium` rebuild that drops `HeadlessExperimental` support.
*
* Exits 0 on pass, 1 on fail. Run via:
*
* bun run --cwd packages/aws-lambda probe:beginframe # host
* bun run --cwd packages/aws-lambda probe:beginframe:docker # Lambda-like
*/
import { mkdtempSync, promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
interface ProbeResult {
passed: boolean;
durationMs: number;
chromiumPath: string;
screenshotBytes: number;
hasDamage: boolean;
detail: string;
}
const PROBE_HTML = `<!doctype html>
<html><head><meta charset="utf-8"><title>hf-beginframe-probe</title>
<style>html,body{margin:0;background:#173;color:#fff;font:48px/1 sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}</style>
</head><body><div id="x">hf-beginframe-probe</div></body></html>`;
async function main(): Promise<void> {
const start = Date.now();
const result = await probe();
result.durationMs = Date.now() - start;
console.log(JSON.stringify(result, null, 2));
if (!result.passed) {
process.exit(1);
}
}
async function probe(): Promise<ProbeResult> {
let chromiumPath = "";
try {
const { default: chromium } = await import("@sparticuz/chromium");
chromiumPath = await chromium.executablePath();
const args = chromium.args;
const puppeteer = await import("puppeteer-core");
// Write probe HTML to /tmp + serve via file:// — no HTTP server in the
// probe so we don't add a dependency surface that could mask a
// Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives
// an unguessable directory name so two concurrent probes on the same
// host don't collide and CodeQL's insecure-tempfile rule clears.
const tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
const htmlPath = join(tmpHtmlDir, "probe.html");
await fs.writeFile(htmlPath, PROBE_HTML, "utf-8");
// BeginFrame requires the full compositor-driving flag set. These match
// the args the engine's `browserManager` passes when `captureMode !==
// "screenshot"`. Without the surface-synchronization + threaded-disable
// flags, Chrome's compositor returns `hasDamage: false` and skips the
// screenshot — the same observation pinned in the hyperframes memory
// ("Chrome's beginFrame with `screenshot` param always reports
// hasDamage=true").
const beginFrameFlags = [
"--deterministic-mode",
"--enable-begin-frame-control",
"--disable-new-content-rendering-timeout",
"--run-all-compositor-stages-before-draw",
"--disable-threaded-animation",
"--disable-threaded-scrolling",
"--disable-checker-imaging",
"--disable-image-animation-resync",
"--enable-surface-synchronization",
// Software GL — Lambda has no GPU; matches the in-process renderer's
// software-locked path.
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
];
const browser = await puppeteer.launch({
executablePath: chromiumPath,
headless: "shell",
args: [...args, ...beginFrameFlags],
defaultViewport: { width: 800, height: 600 },
});
try {
const page = await browser.newPage();
await page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: 30_000 });
const session = await page.createCDPSession();
await session.send("HeadlessExperimental.enable");
// Warm-up beginFrame with noDisplayUpdates: true — drives the
// compositor without producing a screenshot, matching how the engine
// primes a capture loop.
await session.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: 0,
interval: 33,
noDisplayUpdates: true,
});
const response = await session.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: 1000,
interval: 33,
screenshot: { format: "png" },
});
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
const screenshot = response.screenshotData ?? "";
const bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
const isPng =
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47;
return {
passed: isPng && bytes.length > 0,
durationMs: 0,
chromiumPath,
screenshotBytes: bytes.length,
hasDamage: response.hasDamage,
detail: isPng
? "OK — BeginFrame returned a PNG buffer."
: `FAIL — BeginFrame returned ${bytes.length} bytes, PNG signature ${
bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"
}`,
};
} finally {
await browser.close().catch(() => {});
}
} catch (err) {
return {
passed: false,
durationMs: 0,
chromiumPath,
screenshotBytes: 0,
hasDamage: false,
detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`,
};
}
}
void main().catch((err) => {
console.error("[probe-beginframe] unexpected:", err);
process.exit(2);
});