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
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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user