chore: import upstream snapshot with attribution
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
@@ -0,0 +1,15 @@
/**
* Shared binary-unit byte formatter for the build/verify scripts.
*
* The Lambda ZIP-size budget is in mebibytes (Lambda's 250 MB / 248 MiB
* gate is binary, not decimal), so logs and CI failure messages use
* KiB / MiB / GiB. This is intentionally a different unit system from
* `packages/cli/src/ui/format.ts`'s `formatBytes` (KB / MB, decimal) —
* don't conflate them.
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
}
@@ -0,0 +1,30 @@
/**
* CJS-interop banner prepended to the ESM handler bundle.
*
* Lambda's Node 22 runtime treats `.mjs` as ESM, which has no `require`,
* `__filename`, or `__dirname`. The handler bundle inlines CJS deps that
* assume all three exist at module scope:
* - postcss et al. call top-level `require(...)`
* - wawoff2's emscripten build (pulled in unconditionally via
* producer → fontCompression) reads `__dirname` at module scope
* Without the shims the handler throws "Dynamic require of <X> is not
* supported" / "__dirname is not defined in ES module scope" at import time,
* before it can run — which is exactly how a freshly deployed stack crashed
* on every render (#1932).
*
* This mirrors the producer's own CJS banner (packages/producer/build.mjs);
* the handler bundle inlines producer source, so it needs the same shim.
*
* Kept in its own module (not inline in build-zip.ts, which self-executes on
* import) so build-zip.test.ts can import the exact banner, bundle a fixture
* with it, and assert the globals actually resolve.
*/
export const HANDLER_BANNER = [
"// hyperframes-aws-lambda handler bundle",
'import { createRequire as __hf_createRequire } from "module";',
'import { fileURLToPath as __hf_fileURLToPath } from "url";',
'import { dirname as __hf_dirname } from "path";',
"const require = __hf_createRequire(import.meta.url);",
"const __filename = __hf_fileURLToPath(import.meta.url);",
"const __dirname = __hf_dirname(__filename);",
].join("\n");
@@ -0,0 +1,77 @@
import { describe, expect, it } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import * as esbuild from "esbuild";
import { HANDLER_BANNER } from "./_handlerBanner.js";
// The handler ships as ESM (.mjs) but inlines CJS deps that assume Node's CJS
// globals exist at module scope: postcss et al. call top-level `require(...)`,
// and wawoff2's emscripten build reads `__dirname`. A freshly deployed stack
// crashed on every render (#1932) with "__dirname is not defined in ES module
// scope"; the fix is the require/__filename/__dirname shim in HANDLER_BANNER.
//
// This bundles a fixture touching all three globals with the REAL banner and
// imports the output, so it catches a dropped/renamed shim behaviourally
// rather than by grepping for literals (which survives a broken refactor).
//
// The import MUST run under Node, not the `bun test` runtime: Bun defines
// `__dirname`/`__filename` even in ESM, which would mask a missing shim and
// green-light a broken bundle. Lambda runs Node, so we spawn `node` (guaranteed
// present in CI alongside bun) to reproduce the deploy target faithfully.
describe("build-zip handler banner", () => {
it("shims require/__filename/__dirname so inlined CJS deps import under Node", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-banner-test-"));
try {
const entry = join(dir, "fixture.ts");
const outfile = join(dir, "out.mjs");
// Reference each CJS global at module top level, the way inlined deps do.
// If any shim is missing, importing `out.mjs` throws at eval time.
writeFileSync(
entry,
[
"const cjsDir = __dirname;",
"const cjsFile = __filename;",
"const path = require('node:path');",
"if (typeof cjsDir !== 'string') throw new Error('__dirname missing');",
"if (typeof cjsFile !== 'string') throw new Error('__filename missing');",
"if (typeof path.join !== 'function') throw new Error('require missing');",
"console.log('BANNER_OK');",
].join("\n"),
);
esbuild.buildSync({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
entryPoints: [entry],
outfile,
banner: { js: HANDLER_BANNER },
});
// Import under real Node — Lambda's runtime — not the bun test runtime.
const res = spawnSync(
"node",
[
"--input-type=module",
"-e",
`await import(${JSON.stringify(pathToFileURL(outfile).href)});`,
],
{ encoding: "utf8" },
);
// A missing shim surfaces as a non-zero exit + ReferenceError on stderr.
// Guard against a silent skip if `node` isn't on PATH (it is in CI).
expect(res.error).toBeUndefined();
expect(res.stderr).not.toContain("is not defined in ES module scope");
expect(res.stderr).not.toContain("Dynamic require");
expect(res.status).toBe(0);
expect(res.stdout).toContain("BANNER_OK");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+530
View File
@@ -0,0 +1,530 @@
#!/usr/bin/env tsx
/**
* Build the AWS Lambda deployment ZIP.
*
* Pack layout (paths inside the ZIP are relative to Lambda's
* `/var/task/`):
*
* handler.mjs — bundled entry, set as Lambda's Handler
* handler.mjs.map — sourcemap (debugging aid; small)
* bin/ffmpeg — ffmpeg-static binary
* bin/chrome-headless-shell — fallback Chrome (only when CHROME_SOURCE=shell)
* node_modules/@sparticuz/chromium/
* — primary Chrome (lives under node_modules so
* runtime `import("@sparticuz/chromium")`
* resolves; the package's own tarball stays
* inside).
*
* The handler bundle (esbuild) externalises modules whose binary assets
* must be present at runtime — `@sparticuz/chromium` for its bin tarball,
* `puppeteer-core` because Lambda runtime resolves it via Node module
* resolution from `node_modules/`. Everything else is inlined for cold
* start speed.
*
* Run:
* bun run --cwd packages/aws-lambda build:zip
* bun run --cwd packages/aws-lambda build:zip -- --source=chrome-headless-shell
*
* Outputs the resolved ZIP path + size to stdout and writes a sidecar
* JSON (`dist/handler.zip.manifest.json`) describing the contents.
*/
import { spawnSync } from "node:child_process";
import {
chmodSync,
closeSync,
cpSync,
existsSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
readSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { formatBytes } from "./_formatBytes.js";
import { HANDLER_BANNER } from "./_handlerBanner.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(scriptDir, "..");
const monorepoRoot = resolve(packageRoot, "../..");
const distDir = join(packageRoot, "dist");
interface BuildOptions {
source: "sparticuz" | "chrome-headless-shell";
/** Hard upper bound on the unzipped bundle size in bytes (Lambda limit is 250 MiB). */
maxUnzippedBytes: number;
/** Hard upper bound on the ZIP file size in bytes. */
maxZippedBytes: number;
}
const DEFAULT_OPTIONS: BuildOptions = {
source: "sparticuz",
// Lambda's hard ceiling for ZIP-deployed functions is 250 MiB unzipped
// (AWS docs label it "250 MB" but the 262144000-byte value is 250
// binary mebibytes). We gate at 248 MiB to keep ~2 MiB of headroom —
// the sparticuz Chrome (~70 MiB) + ffmpeg (~80 MiB) + ffprobe (~62
// MiB) + bundled Node deps put us close to the ceiling. Chrome itself
// decompresses into Lambda's `/tmp` at cold start, which has its own
// 10 GiB budget, so the unzipped /var/task footprint above is what
// actually competes with Lambda's 250 MiB limit.
maxUnzippedBytes: 248 * 1024 * 1024,
// Lambda's only zipped-size cap is for direct console/CLI uploads (50
// MiB); S3-deployed functions are bounded by the unzipped ceiling. We
// gate at 150 MiB to flag a sudden bundle-size regression without
// false-failing on the natural ~100 MiB sparticuz + ffmpeg payload.
maxZippedBytes: 150 * 1024 * 1024,
};
function parseArgs(argv: string[]): BuildOptions {
const opts = { ...DEFAULT_OPTIONS };
for (const arg of argv.slice(2)) {
if (arg.startsWith("--source=")) {
const v = arg.slice("--source=".length);
if (v !== "sparticuz" && v !== "chrome-headless-shell") {
throw new Error(`--source must be 'sparticuz' or 'chrome-headless-shell' (got ${v})`);
}
opts.source = v;
} else if (arg.startsWith("--max-unzipped=")) {
opts.maxUnzippedBytes = Number.parseInt(arg.slice("--max-unzipped=".length), 10);
} else if (arg.startsWith("--max-zipped=")) {
opts.maxZippedBytes = Number.parseInt(arg.slice("--max-zipped=".length), 10);
} else if (arg === "--help") {
console.log(
"Usage: tsx build-zip.ts [--source=sparticuz|chrome-headless-shell]\n" +
" [--max-unzipped=<bytes>] [--max-zipped=<bytes>]",
);
process.exit(0);
} else {
throw new Error(`Unknown flag: ${arg}`);
}
}
return opts;
}
async function main(): Promise<void> {
const opts = parseArgs(process.argv);
const start = Date.now();
rmSync(distDir, { recursive: true, force: true });
mkdirSync(distDir, { recursive: true });
const stagingDir = join(distDir, "staging");
mkdirSync(stagingDir, { recursive: true });
console.log(`[build-zip] source=${opts.source}`);
// 1. Bundle the handler.
await bundleHandler(stagingDir);
// 2. Stage runtime modules (puppeteer-core + @sparticuz/chromium or the
// fallback chrome-headless-shell tar).
stageRuntimeModules(stagingDir, opts.source);
// 3. Stage the ffmpeg binary.
stageFfmpeg(stagingDir);
// 3b. Stage the hyperframe runtime manifest + IIFE as siblings of
// handler.mjs. The producer's `hyperframeRuntimeLoader` checks
// SIBLING_MANIFEST_PATH first, so dropping the manifest alongside
// the bundled handler at /var/task/hyperframe.manifest.json lets
// renderChunk find it without needing PRODUCER_HYPERFRAME_MANIFEST_PATH.
stageHyperframeRuntime(stagingDir);
// 4. If we're on the chrome-headless-shell fallback, stage that binary.
if (opts.source === "chrome-headless-shell") {
stageChromeHeadlessShell(stagingDir);
}
// 5. Compute the unzipped size BEFORE zipping so we fail loud when over budget.
const unzippedBytes = directorySizeBytes(stagingDir);
console.log(`[build-zip] unzipped staging size: ${formatBytes(unzippedBytes)}`);
if (unzippedBytes > opts.maxUnzippedBytes) {
throw new Error(
`[build-zip] unzipped bundle ${formatBytes(unzippedBytes)} exceeds limit ${formatBytes(
opts.maxUnzippedBytes,
)} (Lambda ZIP ceiling: 250 MiB unzipped). ` +
`Switch --source to the lighter option, or move Chrome to a Lambda Layer.`,
);
}
// 6. Build the ZIP.
const zipPath = join(distDir, "handler.zip");
zipDirectory(stagingDir, zipPath);
const zippedBytes = statSync(zipPath).size;
console.log(`[build-zip] zip size: ${formatBytes(zippedBytes)}${zipPath}`);
if (zippedBytes > opts.maxZippedBytes) {
throw new Error(
`[build-zip] zip ${formatBytes(zippedBytes)} exceeds ZIP size limit ${formatBytes(
opts.maxZippedBytes,
)}.`,
);
}
// 7. Sidecar manifest.
const manifest = {
builtAt: new Date().toISOString(),
durationMs: Date.now() - start,
source: opts.source,
unzippedBytes,
zippedBytes,
maxUnzippedBytes: opts.maxUnzippedBytes,
maxZippedBytes: opts.maxZippedBytes,
};
writeFileSync(join(distDir, "handler.zip.manifest.json"), JSON.stringify(manifest, null, 2));
// 8. Cleanup staging.
rmSync(stagingDir, { recursive: true, force: true });
console.log(`[build-zip] done in ${Date.now() - start}ms`);
}
async function bundleHandler(stagingDir: string): Promise<void> {
const entry = join(packageRoot, "src/handler.ts");
const outfile = join(stagingDir, "handler.mjs");
const workspaceAliasPlugin: esbuild.Plugin = {
name: "workspace-alias",
setup(build) {
build.onResolve({ filter: /^@hyperframes\/producer\/distributed$/ }, () => ({
path: resolve(monorepoRoot, "packages/producer/src/distributed.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/producer$/ }, () => ({
path: resolve(monorepoRoot, "packages/producer/src/index.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/engine$/ }, () => ({
path: resolve(monorepoRoot, "packages/engine/src/index.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
path: resolve(monorepoRoot, "packages/engine/src/utils/alphaBlit.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/engine\/shader-transitions$/ }, () => ({
path: resolve(monorepoRoot, "packages/engine/src/utils/shaderTransitions.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
path: resolve(monorepoRoot, "packages/core/src/index.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/core\/lint$/ }, () => ({
path: resolve(monorepoRoot, "packages/core/src/lint/index.ts"),
}));
},
};
await esbuild.build({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
// Externalise binary-shipped modules so node module resolution picks
// them up at runtime. esbuild would otherwise try to inline their
// postinstall-extracted binaries, which it cannot do.
external: [
"@sparticuz/chromium",
"puppeteer-core",
"puppeteer",
// AWS SDK v3 is pre-installed in the Lambda Node 22 runtime; mark
// external so we don't double-bundle 3+ MiB of SDK.
"@aws-sdk/client-s3",
],
plugins: [workspaceAliasPlugin],
minify: false,
// sourcemap=false: the ZIP is tight on Lambda's 250 MiB unzipped cap
// (Chrome ~70 MiB + ffmpeg ~80 MiB + ffprobe ~62 MiB + Node deps). A
// 4-5 MiB sourcemap puts us over. Re-enable for local debugging by passing
// --sourcemap; the bundle's stack traces stay readable enough without
// it because we don't minify.
sourcemap: false,
entryPoints: [entry],
outfile,
// See HANDLER_BANNER (_handlerBanner.ts) for why the ESM bundle needs the
// CJS require/__filename/__dirname shims.
banner: {
js: HANDLER_BANNER,
},
});
console.log(`[build-zip] bundled handler → ${outfile}`);
}
function stageRuntimeModules(stagingDir: string, source: BuildOptions["source"]): void {
// Bun's isolated-install layout means cpSync(@sparticuz/chromium) only
// copies the package's own files, missing transitive deps like `tar-fs`.
// The clean cross-package-manager solution: write a tiny package.json
// into staging/ that declares the production deps, then `npm install`
// there. npm flattens transitive deps into staging/node_modules/.
const pkg: Record<string, unknown> = {
name: "hyperframes-aws-lambda-bundled",
version: "0.0.0",
private: true,
dependencies: {
"puppeteer-core": readDepVersion("puppeteer-core"),
},
};
if (source === "sparticuz") {
(pkg.dependencies as Record<string, string>)["@sparticuz/chromium"] =
readDepVersion("@sparticuz/chromium");
}
writeFileSync(join(stagingDir, "package.json"), JSON.stringify(pkg, null, 2));
// --no-package-lock so we don't pollute staging with a lockfile we don't
// ship; --no-audit/--no-fund just for log noise.
const result = spawnSync(
"npm",
["install", "--no-package-lock", "--no-audit", "--no-fund", "--omit=dev", "--omit=optional"],
{
cwd: stagingDir,
stdio: "inherit",
},
);
if (result.status !== 0) {
throw new Error(`[build-zip] npm install into staging failed (status ${result.status})`);
}
console.log(`[build-zip] staged node_modules via npm install`);
}
function readDepVersion(moduleName: string): string {
// Resolve the EXACT version bun installed into the workspace, not the
// semver range declared in package.json. The staging-dir npm install
// runs with `--no-package-lock`, so a caret range would float to the
// latest registry version at build time — diverging from what the
// workspace tests ran against and breaking ZIP-content determinism
// across consecutive builds. The lockfile pin gives us reproducibility.
const lockText = readFileSync(join(monorepoRoot, "bun.lock"), "utf-8");
// bun.lock lines look like:
// "puppeteer-core": ["puppeteer-core@24.43.1", "", { ... }, "sha512-..."],
const re = new RegExp(
`"${moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}":\\s*\\["${moduleName.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
)}@([^"]+)"`,
);
const match = re.exec(lockText);
if (!match || !match[1]) {
// Fall back to the manifest range — better than failing the build
// entirely if bun.lock's format changes between bun versions.
const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8")) as {
dependencies?: Record<string, string>;
};
return manifest.dependencies?.[moduleName] ?? "latest";
}
return match[1];
}
function resolveModuleDir(moduleName: string): string {
// Walk up from packageRoot to find a matching node_modules entry.
// Used by stageFfmpeg below; the @sparticuz/chromium + puppeteer-core
// paths now go through npm install instead.
let dir = packageRoot;
for (let i = 0; i < 5; i++) {
const candidate = join(dir, "node_modules", moduleName);
if (existsSync(candidate)) return candidate;
dir = dirname(dir);
}
throw new Error(
`[build-zip] could not resolve ${moduleName} from ${packageRoot} — run 'bun install' first.`,
);
}
function stageHyperframeRuntime(stagingDir: string): void {
const coreDist = resolve(monorepoRoot, "packages/core/dist");
const manifestSrc = join(coreDist, "hyperframe.manifest.json");
const iifeSrc = join(coreDist, "hyperframe.runtime.iife.js");
if (!existsSync(manifestSrc) || !existsSync(iifeSrc)) {
throw new Error(
`[build-zip] hyperframe runtime artifacts missing under ${coreDist}. ` +
`Run 'bun run --filter @hyperframes/core build:hyperframes-runtime:modular' first.`,
);
}
cpSync(manifestSrc, join(stagingDir, "hyperframe.manifest.json"));
cpSync(iifeSrc, join(stagingDir, "hyperframe.runtime.iife.js"));
console.log(`[build-zip] staged hyperframe.manifest.json + hyperframe.runtime.iife.js`);
}
// ELF header constants used by `assertLinuxX86_64Elf`. Header layout:
// bytes 0..3 magic `0x7F 'E' 'L' 'F'`, byte 4 EI_CLASS (`2` = ELFCLASS64),
// bytes 18..19 e_machine little-endian (`0x3E` = EM_X86_64).
const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
const ELF_CLASS_64 = 2;
const ELF_MACHINE_X86_64 = 0x3e;
const ELF_HEADER_BYTES = 20;
/**
* Verify the binary at `path` is a Linux x86-64 ELF executable, the only
* shape the Lambda runtime can exec. Throws with the canonical workaround
* (Docker `--platform=linux/amd64` or `npm_config_platform` /
* `npm_config_arch` overrides) so the build doesn't ship a silently
* broken zip when a non-Linux host's postinstall fetched the wrong arch.
*/
function assertLinuxX86_64Elf(path: string, label: string): void {
const head = readFileHead(path, ELF_HEADER_BYTES, label);
const isElf = head.subarray(0, 4).equals(ELF_MAGIC);
const isElf64 = head[4] === ELF_CLASS_64;
const machine = head.readUInt16LE(18);
if (isElf && isElf64 && machine === ELF_MACHINE_X86_64) return;
const magicHex = head.subarray(0, 4).toString("hex");
throw new Error(
`[build-zip] ${label} at ${path} is not a Linux x86-64 ELF executable ` +
`(magic=0x${magicHex}, ei_class=${head[4]}, e_machine=0x${machine.toString(16)}). ` +
`This usually means the deploy host's postinstall fetched a host-platform binary ` +
`(e.g. macOS arm64 ffmpeg) instead of the linux/x64 binary Lambda needs. ` +
`Re-run the build inside a linux/amd64 container, or pre-install with ` +
`\`npm_config_platform=linux npm_config_arch=x64\` so the package fetches the right binary.`,
);
}
function readFileHead(path: string, byteCount: number, label: string): Buffer {
const fd = openSync(path, "r");
try {
const buf = Buffer.alloc(byteCount);
const bytesRead = readSync(fd, buf, 0, byteCount, 0);
if (bytesRead < byteCount) {
throw new Error(
`[build-zip] ${label} at ${path} is too short — read ${bytesRead} of ${byteCount} bytes.`,
);
}
return buf;
} finally {
closeSync(fd);
}
}
function stageFfmpeg(stagingDir: string): void {
const binDir = join(stagingDir, "bin");
mkdirSync(binDir, { recursive: true });
// ffmpeg from `ffmpeg-static`. The package only ships the encoder
// binary; the audio pad/trim path also needs ffprobe, which comes
// from `ffprobe-static`.
//
// `ffmpeg-static`'s postinstall fetches a binary for the host platform
// (e.g. arm64 Mach-O on Apple Silicon macOS). Lambda runs Linux x86-64,
// so a build from a non-Linux host silently produces a zip that boots
// but fails at first ffmpeg invocation with `cannot execute binary file`.
// Verify the ELF header up front and bail with a clear message instead.
const ffmpegBinary = join(resolveModuleDir("ffmpeg-static"), "ffmpeg");
if (!existsSync(ffmpegBinary)) {
throw new Error(
`[build-zip] ffmpeg-static binary missing at ${ffmpegBinary}. Did postinstall run?`,
);
}
assertLinuxX86_64Elf(ffmpegBinary, "ffmpeg-static binary");
const ffmpegDest = join(binDir, "ffmpeg");
cpSync(ffmpegBinary, ffmpegDest);
chmodSync(ffmpegDest, 0o755);
// ffprobe lives at `ffprobe-static/bin/<platform>/<arch>/ffprobe`.
// The producer's `audioPadTrim` spawns `ffprobe` from PATH so we need
// it alongside ffmpeg under /var/task/bin/.
const ffprobeModule = resolveModuleDir("ffprobe-static");
const ffprobeCandidates = [
join(ffprobeModule, "bin", "linux", "x64", "ffprobe"),
join(ffprobeModule, "bin", "linux", "arm64", "ffprobe"),
];
const ffprobeBinary = ffprobeCandidates.find((p) => existsSync(p));
if (!ffprobeBinary) {
throw new Error(
`[build-zip] ffprobe-static binary not found under ${ffprobeModule}/bin/linux/. Did postinstall run?`,
);
}
const ffprobeDest = join(binDir, "ffprobe");
cpSync(ffprobeBinary, ffprobeDest);
chmodSync(ffprobeDest, 0o755);
console.log(`[build-zip] staged ffmpeg + ffprobe → bin/`);
}
function stageChromeHeadlessShell(stagingDir: string): void {
// The fallback path bundles the same chrome-headless-shell binary the
// K8s deploy uses. The binary is fetched via `@puppeteer/browsers` on
// first build into the host's `~/.cache/puppeteer/`; the build script
// re-uses that cache rather than redownloading.
const home = process.env.HOME ?? "/root";
const baseDir = join(home, ".cache", "puppeteer", "chrome-headless-shell");
if (!existsSync(baseDir)) {
throw new Error(
`[build-zip] chrome-headless-shell cache missing at ${baseDir}. Run\n` +
` npx --yes @puppeteer/browsers install chrome-headless-shell@stable --path ${home}/.cache/puppeteer\n` +
`before --source=chrome-headless-shell.`,
);
}
// Sort by numeric semver descending. `sort().reverse()` is lexicographic,
// which silently picks "99.0.0" over "131.0.0" once Chrome ships
// three-digit majors that aren't strictly width-aligned. `compareSemver`
// returns negative/zero/positive on (a, b), so descending = `b - a`.
const versions = readdirSync(baseDir).sort((a, b) => compareSemver(b, a));
for (const v of versions) {
const candidate = join(baseDir, v, "chrome-headless-shell-linux64", "chrome-headless-shell");
if (existsSync(candidate)) {
const dest = join(stagingDir, "bin", "chrome-headless-shell");
mkdirSync(dirname(dest), { recursive: true });
cpSync(candidate, dest);
chmodSync(dest, 0o755);
console.log(`[build-zip] staged chrome-headless-shell (${v}) → bin/chrome-headless-shell`);
return;
}
}
throw new Error(`[build-zip] no linux64 chrome-headless-shell binary found under ${baseDir}.`);
}
/**
* Compare two semver-shaped strings like "131.0.6778.108". Treats any
* non-numeric directory name as `-Infinity` so it sorts to the bottom
* (Puppeteer's cache layout sometimes includes `latest` or branch tags).
* Used by `stageChromeHeadlessShell` to pick the newest cached Chrome
* without tripping on the lexicographic "99 > 131" trap.
*/
function compareSemver(a: string, b: string): number {
const partsA = a.split(".").map((s) => Number.parseInt(s, 10));
const partsB = b.split(".").map((s) => Number.parseInt(s, 10));
const len = Math.max(partsA.length, partsB.length);
for (let i = 0; i < len; i++) {
const ai = partsA[i] ?? 0;
const bi = partsB[i] ?? 0;
if (Number.isNaN(ai) && Number.isNaN(bi)) continue;
if (Number.isNaN(ai)) return -1;
if (Number.isNaN(bi)) return 1;
if (ai !== bi) return ai - bi;
}
return 0;
}
function zipDirectory(sourceDir: string, zipPath: string): void {
const result = spawnSync("zip", ["-rq", zipPath, "."], { cwd: sourceDir, stdio: "inherit" });
if (result.status !== 0) {
throw new Error(`[build-zip] zip exited with status ${result.status}`);
}
}
function directorySizeBytes(dir: string): number {
// Use spawnSync (no shell) instead of execSync so `dir` is passed as
// an argv element rather than interpolated into a shell command —
// CodeQL's `js/shell-command-injected-from-environment` rule fires
// on the latter even with JSON-quoting. `du -sb` is Linux-only;
// build-zip is CI-side where Linux coreutils is present.
const result = spawnSync("du", ["-sb", dir], { encoding: "utf-8" });
if (result.status === 0 && result.stdout) {
const bytes = Number.parseInt(result.stdout.split(/\s+/)[0] ?? "0", 10);
if (!Number.isNaN(bytes)) return bytes;
}
return walkSize(dir);
}
function walkSize(dir: string): number {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) total += walkSize(full);
else if (entry.isFile()) total += statSync(full).size;
}
return total;
}
void main().catch((err) => {
console.error("[build-zip] failed:", err instanceof Error ? err.message : String(err));
if (err instanceof Error && err.stack) console.error(err.stack);
process.exit(1);
});
@@ -0,0 +1,61 @@
# BeginFrame regression-guard container.
#
# Uses the official AWS Lambda Node 22 image as the base so the probe
# exercises @sparticuz/chromium against the SAME glibc, kernel feature
# set, and `/tmp` filesystem layout that real Lambda invocations see. If
# this Dockerfile passes, the bundled handler is on solid footing for
# real AWS.
#
# Build context: monorepo root (../../). Build + run:
#
# bun run --cwd packages/aws-lambda probe:beginframe:docker
#
# The default CMD runs `tsx scripts/probe-beginframe.ts` and exits 0 on
# pass, 1 on BeginFrame failure, 2 on harness failure.
FROM public.ecr.aws/lambda/nodejs:22
# Shared libraries @sparticuz/chromium expects but the Lambda base image
# does not bring in by default. Versions are pinned to whatever
# `dnf install` resolves on the Lambda base image at build time; we just
# need them present.
RUN dnf install -y \
alsa-lib \
atk \
cups-libs \
gtk3 \
libdrm \
libxkbcommon \
libXcomposite \
libXdamage \
libXrandr \
mesa-libgbm \
nss \
pango \
tar \
gzip \
unzip \
&& dnf clean all
WORKDIR /var/task
# The probe is self-contained — we install the three deps it needs into a
# fresh package directory rather than re-using the monorepo's
# workspace-rooted manifests (which carry `workspace:` protocol deps npm
# can't resolve).
COPY packages/aws-lambda/scripts/ scripts/
RUN printf '{"name":"hf-lambda-probe","version":"1.0.0","type":"module"}\n' > package.json \
&& npm install --no-audit --no-fund --omit=optional \
@sparticuz/chromium@148.0.0 \
puppeteer-core@^24.39.1 \
tsx@^4.21.0
ENV NODE_PATH=/var/task/node_modules
ENV PATH="/var/task/node_modules/.bin:${PATH}"
# Lambda's `tmpfs` is mounted at /tmp; sparticuz decompresses into /tmp
# at runtime. The base image already has /tmp writable.
ENTRYPOINT []
CMD ["node", "--experimental-strip-types", "scripts/probe-beginframe.ts"]
@@ -0,0 +1,157 @@
#!/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);
});
@@ -0,0 +1,83 @@
#!/usr/bin/env tsx
/**
* CI gate on the Lambda ZIP size.
*
* Reads `dist/handler.zip.manifest.json` (written by `build-zip.ts`) and
* exits non-zero if either the unzipped or zipped size exceeds the
* declared limits. Lambda's hard ceiling for ZIP-deployed functions is
* 250 MiB unzipped (262144000 bytes — AWS docs label it "250 MB" but use
* binary mebibytes); the in-house budget is 248 MiB to keep headroom for
* the Chrome tarball decompression that happens at cold start.
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { formatBytes } from "./_formatBytes.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(scriptDir, "..");
const distDir = join(packageRoot, "dist");
const zipPath = join(distDir, "handler.zip");
const manifestPath = join(distDir, "handler.zip.manifest.json");
interface Manifest {
unzippedBytes: number;
zippedBytes: number;
source: string;
}
const IN_HOUSE_UNZIPPED_LIMIT = 248 * 1024 * 1024;
const IN_HOUSE_ZIPPED_LIMIT = 150 * 1024 * 1024;
function main(): void {
if (!existsSync(zipPath)) {
console.error(`[verify-zip-size] ${zipPath} not found. Run 'bun run build:zip' first.`);
process.exit(1);
}
if (!existsSync(manifestPath)) {
console.error(
`[verify-zip-size] ${manifestPath} not found. The manifest is written by build-zip.ts; ` +
`re-run the build.`,
);
process.exit(1);
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as Manifest;
const actualZipped = statSync(zipPath).size;
if (actualZipped !== manifest.zippedBytes) {
console.warn(
`[verify-zip-size] note: zip file size on disk (${actualZipped}) differs from ` +
`manifest (${manifest.zippedBytes}). Using on-disk value.`,
);
}
let failed = false;
if (manifest.unzippedBytes > IN_HOUSE_UNZIPPED_LIMIT) {
console.error(
`[verify-zip-size] FAIL unzipped: ${formatBytes(manifest.unzippedBytes)} > ` +
`${formatBytes(IN_HOUSE_UNZIPPED_LIMIT)} (in-house limit; Lambda hard ceiling is 250 MiB).`,
);
failed = true;
}
if (actualZipped > IN_HOUSE_ZIPPED_LIMIT) {
console.error(
`[verify-zip-size] FAIL zipped: ${formatBytes(actualZipped)} > ` +
`${formatBytes(IN_HOUSE_ZIPPED_LIMIT)} (in-house limit; Lambda direct-upload ceiling is 50 MiB, ` +
`S3-deploy ceiling is 250 MiB).`,
);
failed = true;
}
if (failed) {
console.error("[verify-zip-size] FAILED — bundle is too large for Lambda ZIP deploy.");
process.exit(1);
}
console.log(
`[verify-zip-size] OK source=${manifest.source} unzipped=${formatBytes(
manifest.unzippedBytes,
)} zipped=${formatBytes(actualZipped)}`,
);
}
main();