Files
hyperframes/packages/aws-lambda/src/chromium.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

125 lines
4.8 KiB
TypeScript

/**
* Lambda-runtime Chrome resolver.
*
* `renderChunk()` (the only primitive that needs a browser) launches Chrome
* via the engine's `BrowserManager`. In Lambda we can't ship the full
* Puppeteer-managed Chrome download — Puppeteer's Chrome binary is ~330 MB
* unzipped, well over Lambda's 250 MB ZIP-deploy ceiling.
*
* Two valid runtime sources:
*
* 1. `@sparticuz/chromium` (primary). Decompresses a Lambda-optimised
* `chrome-headless-shell` build into `/tmp` at runtime. ~70 MB
* compressed; the same binary the rest of the ecosystem uses for
* headless-Chrome-in-Lambda. CDP-level BeginFrame works because the
* command lives in the protocol, not the binary; the
* `scripts/probe-beginframe.ts` regression guard pins this.
*
* 2. A bundled `chrome-headless-shell` binary (fallback). If
* `@sparticuz/chromium`'s build ever drops `HeadlessExperimental`
* support, we fall back to the same `chrome-headless-shell` build
* the K8s deploy uses. The fallback raises the ZIP from ~70 MB
* Chrome to ~140 MB Chrome — still well under 250 MB.
*
* The runtime path is selected by the `HYPERFRAMES_LAMBDA_CHROME_SOURCE`
* env var (set by `build-zip.ts`):
*
* "sparticuz" → use `@sparticuz/chromium.executablePath()`
* "chrome-headless-shell" → use `process.env.HYPERFRAMES_LAMBDA_CHROME_PATH`
*
* Adapters that bundle this package can override
* `HYPERFRAMES_LAMBDA_CHROME_PATH` directly when running outside Lambda
* (e.g. the SAM-local RIE smoke).
*/
import { existsSync } from "node:fs";
/** Discriminator for the two supported Chrome sources. */
export type ChromeSource = "sparticuz" | "chrome-headless-shell";
/**
* Read which Chrome source the bundled ZIP was built against. Defaults to
* `"sparticuz"` so a fresh build with no env override picks the primary
* path.
*/
export function resolveChromeSource(): ChromeSource {
const raw = process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE?.toLowerCase();
if (raw === "chrome-headless-shell" || raw === "shell") return "chrome-headless-shell";
return "sparticuz";
}
/**
* Resolve the absolute path to a Chrome binary suitable for BeginFrame.
*
* For `"sparticuz"`: dynamically import `@sparticuz/chromium` and call
* `chromium.executablePath()`. The module is dynamic so a build-zip that
* never reaches the import (because the fallback Chrome is bundled) can
* tree-shake it out.
*
* For `"chrome-headless-shell"`: read the path from
* `HYPERFRAMES_LAMBDA_CHROME_PATH`. Throws if absent or non-existent so a
* misconfigured deploy fails loudly at boot rather than at first frame.
*/
export async function resolveChromeExecutablePath(): Promise<string> {
const source = resolveChromeSource();
if (source === "sparticuz") {
const mod = await loadSparticuzChromium();
return mod.executablePath();
}
const explicit = process.env.HYPERFRAMES_LAMBDA_CHROME_PATH;
if (!explicit) {
throw new Error(
"[chromium] HYPERFRAMES_LAMBDA_CHROME_SOURCE=chrome-headless-shell requires " +
"HYPERFRAMES_LAMBDA_CHROME_PATH to be set to the absolute path of the bundled binary.",
);
}
if (!existsSync(explicit)) {
throw new Error(
`[chromium] HYPERFRAMES_LAMBDA_CHROME_PATH=${JSON.stringify(explicit)} does not exist`,
);
}
return explicit;
}
/**
* Resolve the Chromium launch args for the selected source. For
* `@sparticuz/chromium` we forward `chromium.args` (Lambda-tuned defaults
* — single-process, no-sandbox, /tmp paths). For the shell fallback the
* engine's own arg builder owns it; we return an empty array so the
* engine's defaults apply.
*/
export async function resolveChromeArgs(): Promise<string[]> {
if (resolveChromeSource() !== "sparticuz") return [];
const mod = await loadSparticuzChromium();
return mod.args;
}
/**
* Dynamic import wrapper isolated so unit tests can stub the module without
* jest-style module mocking gymnastics. The narrow type here pins the
* subset of `@sparticuz/chromium`'s surface this package depends on; if
* the upstream module ever changes shape the type error here surfaces
* before runtime.
*/
interface SparticuzChromiumModule {
args: string[];
executablePath(): Promise<string>;
}
let cachedSparticuz: SparticuzChromiumModule | null = null;
async function loadSparticuzChromium(): Promise<SparticuzChromiumModule> {
if (cachedSparticuz) return cachedSparticuz;
const mod = (await import("@sparticuz/chromium")) as
| SparticuzChromiumModule
| { default: SparticuzChromiumModule };
const resolved = "default" in mod ? mod.default : mod;
cachedSparticuz = resolved;
return resolved;
}
/** Test-only seam: replace the cached `@sparticuz/chromium` module. */
export function _setSparticuzChromiumForTests(mod: SparticuzChromiumModule | null): void {
cachedSparticuz = mod;
}