@hyperframes/aws-lambda 0.6.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +191 -0
  2. package/dist/cdk/HyperframesRenderStack.d.ts +65 -0
  3. package/dist/cdk/HyperframesRenderStack.d.ts.map +1 -0
  4. package/dist/cdk/index.d.ts +10 -0
  5. package/dist/cdk/index.d.ts.map +1 -0
  6. package/dist/cdk/index.js +263 -0
  7. package/dist/cdk/index.js.map +7 -0
  8. package/dist/chromium.d.ts +77 -0
  9. package/dist/chromium.d.ts.map +1 -0
  10. package/dist/events.d.ts +120 -0
  11. package/dist/events.d.ts.map +1 -0
  12. package/dist/formatExtension.d.ts +10 -0
  13. package/dist/formatExtension.d.ts.map +1 -0
  14. package/dist/handler.d.ts +42 -0
  15. package/dist/handler.d.ts.map +1 -0
  16. package/dist/handler.js +432 -0
  17. package/dist/handler.js.map +7 -0
  18. package/dist/index.d.ts +33 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +951 -0
  21. package/dist/index.js.map +7 -0
  22. package/dist/s3Transport.d.ts +55 -0
  23. package/dist/s3Transport.d.ts.map +1 -0
  24. package/dist/sdk/costAccounting.d.ts +51 -0
  25. package/dist/sdk/costAccounting.d.ts.map +1 -0
  26. package/dist/sdk/deploySite.d.ts +55 -0
  27. package/dist/sdk/deploySite.d.ts.map +1 -0
  28. package/dist/sdk/getRenderProgress.d.ts +72 -0
  29. package/dist/sdk/getRenderProgress.d.ts.map +1 -0
  30. package/dist/sdk/index.d.ts +16 -0
  31. package/dist/sdk/index.d.ts.map +1 -0
  32. package/dist/sdk/index.js +578 -0
  33. package/dist/sdk/index.js.map +7 -0
  34. package/dist/sdk/renderToLambda.d.ts +66 -0
  35. package/dist/sdk/renderToLambda.d.ts.map +1 -0
  36. package/dist/sdk/validateConfig.d.ts +35 -0
  37. package/dist/sdk/validateConfig.d.ts.map +1 -0
  38. package/package.json +84 -0
  39. package/scripts/_formatBytes.ts +15 -0
  40. package/scripts/build-zip.ts +480 -0
  41. package/scripts/probe-beginframe.dockerfile +61 -0
  42. package/scripts/probe-beginframe.ts +157 -0
  43. package/scripts/verify-zip-size.ts +83 -0
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Build the AWS Lambda deployment ZIP.
4
+ *
5
+ * Pack layout (paths inside the ZIP are relative to Lambda's
6
+ * `/var/task/`):
7
+ *
8
+ * handler.mjs — bundled entry, set as Lambda's Handler
9
+ * handler.mjs.map — sourcemap (debugging aid; small)
10
+ * bin/ffmpeg — ffmpeg-static binary
11
+ * bin/chrome-headless-shell — fallback Chrome (only when CHROME_SOURCE=shell)
12
+ * node_modules/@sparticuz/chromium/
13
+ * — primary Chrome (lives under node_modules so
14
+ * runtime `import("@sparticuz/chromium")`
15
+ * resolves; the package's own tarball stays
16
+ * inside).
17
+ *
18
+ * The handler bundle (esbuild) externalises modules whose binary assets
19
+ * must be present at runtime — `@sparticuz/chromium` for its bin tarball,
20
+ * `puppeteer-core` because Lambda runtime resolves it via Node module
21
+ * resolution from `node_modules/`. Everything else is inlined for cold
22
+ * start speed.
23
+ *
24
+ * Run:
25
+ * bun run --cwd packages/aws-lambda build:zip
26
+ * bun run --cwd packages/aws-lambda build:zip -- --source=chrome-headless-shell
27
+ *
28
+ * Outputs the resolved ZIP path + size to stdout and writes a sidecar
29
+ * JSON (`dist/handler.zip.manifest.json`) describing the contents.
30
+ */
31
+
32
+ import { spawnSync } from "node:child_process";
33
+ import {
34
+ chmodSync,
35
+ cpSync,
36
+ existsSync,
37
+ mkdirSync,
38
+ readdirSync,
39
+ readFileSync,
40
+ rmSync,
41
+ statSync,
42
+ writeFileSync,
43
+ } from "node:fs";
44
+ import { dirname, join, resolve } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+ import * as esbuild from "esbuild";
47
+ import { formatBytes } from "./_formatBytes.js";
48
+
49
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
50
+ const packageRoot = resolve(scriptDir, "..");
51
+ const monorepoRoot = resolve(packageRoot, "../..");
52
+ const distDir = join(packageRoot, "dist");
53
+
54
+ interface BuildOptions {
55
+ source: "sparticuz" | "chrome-headless-shell";
56
+ /** Hard upper bound on the unzipped bundle size in bytes (Lambda limit is 250 MiB). */
57
+ maxUnzippedBytes: number;
58
+ /** Hard upper bound on the ZIP file size in bytes. */
59
+ maxZippedBytes: number;
60
+ }
61
+
62
+ const DEFAULT_OPTIONS: BuildOptions = {
63
+ source: "sparticuz",
64
+ // Lambda's hard ceiling for ZIP-deployed functions is 250 MiB unzipped
65
+ // (AWS docs label it "250 MB" but the 262144000-byte value is 250
66
+ // binary mebibytes). We gate at 248 MiB to keep ~2 MiB of headroom —
67
+ // the sparticuz Chrome (~70 MiB) + ffmpeg (~80 MiB) + ffprobe (~62
68
+ // MiB) + bundled Node deps put us close to the ceiling. Chrome itself
69
+ // decompresses into Lambda's `/tmp` at cold start, which has its own
70
+ // 10 GiB budget, so the unzipped /var/task footprint above is what
71
+ // actually competes with Lambda's 250 MiB limit.
72
+ maxUnzippedBytes: 248 * 1024 * 1024,
73
+ // Lambda's only zipped-size cap is for direct console/CLI uploads (50
74
+ // MiB); S3-deployed functions are bounded by the unzipped ceiling. We
75
+ // gate at 150 MiB to flag a sudden bundle-size regression without
76
+ // false-failing on the natural ~100 MiB sparticuz + ffmpeg payload.
77
+ maxZippedBytes: 150 * 1024 * 1024,
78
+ };
79
+
80
+ function parseArgs(argv: string[]): BuildOptions {
81
+ const opts = { ...DEFAULT_OPTIONS };
82
+ for (const arg of argv.slice(2)) {
83
+ if (arg.startsWith("--source=")) {
84
+ const v = arg.slice("--source=".length);
85
+ if (v !== "sparticuz" && v !== "chrome-headless-shell") {
86
+ throw new Error(`--source must be 'sparticuz' or 'chrome-headless-shell' (got ${v})`);
87
+ }
88
+ opts.source = v;
89
+ } else if (arg.startsWith("--max-unzipped=")) {
90
+ opts.maxUnzippedBytes = Number.parseInt(arg.slice("--max-unzipped=".length), 10);
91
+ } else if (arg.startsWith("--max-zipped=")) {
92
+ opts.maxZippedBytes = Number.parseInt(arg.slice("--max-zipped=".length), 10);
93
+ } else if (arg === "--help") {
94
+ console.log(
95
+ "Usage: tsx build-zip.ts [--source=sparticuz|chrome-headless-shell]\n" +
96
+ " [--max-unzipped=<bytes>] [--max-zipped=<bytes>]",
97
+ );
98
+ process.exit(0);
99
+ } else {
100
+ throw new Error(`Unknown flag: ${arg}`);
101
+ }
102
+ }
103
+ return opts;
104
+ }
105
+
106
+ async function main(): Promise<void> {
107
+ const opts = parseArgs(process.argv);
108
+ const start = Date.now();
109
+
110
+ rmSync(distDir, { recursive: true, force: true });
111
+ mkdirSync(distDir, { recursive: true });
112
+
113
+ const stagingDir = join(distDir, "staging");
114
+ mkdirSync(stagingDir, { recursive: true });
115
+
116
+ console.log(`[build-zip] source=${opts.source}`);
117
+
118
+ // 1. Bundle the handler.
119
+ await bundleHandler(stagingDir);
120
+
121
+ // 2. Stage runtime modules (puppeteer-core + @sparticuz/chromium or the
122
+ // fallback chrome-headless-shell tar).
123
+ stageRuntimeModules(stagingDir, opts.source);
124
+
125
+ // 3. Stage the ffmpeg binary.
126
+ stageFfmpeg(stagingDir);
127
+
128
+ // 3b. Stage the hyperframe runtime manifest + IIFE as siblings of
129
+ // handler.mjs. The producer's `hyperframeRuntimeLoader` checks
130
+ // SIBLING_MANIFEST_PATH first, so dropping the manifest alongside
131
+ // the bundled handler at /var/task/hyperframe.manifest.json lets
132
+ // renderChunk find it without needing PRODUCER_HYPERFRAME_MANIFEST_PATH.
133
+ stageHyperframeRuntime(stagingDir);
134
+
135
+ // 4. If we're on the chrome-headless-shell fallback, stage that binary.
136
+ if (opts.source === "chrome-headless-shell") {
137
+ stageChromeHeadlessShell(stagingDir);
138
+ }
139
+
140
+ // 5. Compute the unzipped size BEFORE zipping so we fail loud when over budget.
141
+ const unzippedBytes = directorySizeBytes(stagingDir);
142
+ console.log(`[build-zip] unzipped staging size: ${formatBytes(unzippedBytes)}`);
143
+ if (unzippedBytes > opts.maxUnzippedBytes) {
144
+ throw new Error(
145
+ `[build-zip] unzipped bundle ${formatBytes(unzippedBytes)} exceeds limit ${formatBytes(
146
+ opts.maxUnzippedBytes,
147
+ )} (Lambda ZIP ceiling: 250 MiB unzipped). ` +
148
+ `Switch --source to the lighter option, or move Chrome to a Lambda Layer.`,
149
+ );
150
+ }
151
+
152
+ // 6. Build the ZIP.
153
+ const zipPath = join(distDir, "handler.zip");
154
+ zipDirectory(stagingDir, zipPath);
155
+ const zippedBytes = statSync(zipPath).size;
156
+ console.log(`[build-zip] zip size: ${formatBytes(zippedBytes)} → ${zipPath}`);
157
+ if (zippedBytes > opts.maxZippedBytes) {
158
+ throw new Error(
159
+ `[build-zip] zip ${formatBytes(zippedBytes)} exceeds ZIP size limit ${formatBytes(
160
+ opts.maxZippedBytes,
161
+ )}.`,
162
+ );
163
+ }
164
+
165
+ // 7. Sidecar manifest.
166
+ const manifest = {
167
+ builtAt: new Date().toISOString(),
168
+ durationMs: Date.now() - start,
169
+ source: opts.source,
170
+ unzippedBytes,
171
+ zippedBytes,
172
+ maxUnzippedBytes: opts.maxUnzippedBytes,
173
+ maxZippedBytes: opts.maxZippedBytes,
174
+ };
175
+ writeFileSync(join(distDir, "handler.zip.manifest.json"), JSON.stringify(manifest, null, 2));
176
+
177
+ // 8. Cleanup staging.
178
+ rmSync(stagingDir, { recursive: true, force: true });
179
+ console.log(`[build-zip] done in ${Date.now() - start}ms`);
180
+ }
181
+
182
+ async function bundleHandler(stagingDir: string): Promise<void> {
183
+ const entry = join(packageRoot, "src/handler.ts");
184
+ const outfile = join(stagingDir, "handler.mjs");
185
+
186
+ const workspaceAliasPlugin: esbuild.Plugin = {
187
+ name: "workspace-alias",
188
+ setup(build) {
189
+ build.onResolve({ filter: /^@hyperframes\/producer\/distributed$/ }, () => ({
190
+ path: resolve(monorepoRoot, "packages/producer/src/distributed.ts"),
191
+ }));
192
+ build.onResolve({ filter: /^@hyperframes\/producer$/ }, () => ({
193
+ path: resolve(monorepoRoot, "packages/producer/src/index.ts"),
194
+ }));
195
+ build.onResolve({ filter: /^@hyperframes\/engine$/ }, () => ({
196
+ path: resolve(monorepoRoot, "packages/engine/src/index.ts"),
197
+ }));
198
+ build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
199
+ path: resolve(monorepoRoot, "packages/engine/src/utils/alphaBlit.ts"),
200
+ }));
201
+ build.onResolve({ filter: /^@hyperframes\/engine\/shader-transitions$/ }, () => ({
202
+ path: resolve(monorepoRoot, "packages/engine/src/utils/shaderTransitions.ts"),
203
+ }));
204
+ build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
205
+ path: resolve(monorepoRoot, "packages/core/src/index.ts"),
206
+ }));
207
+ build.onResolve({ filter: /^@hyperframes\/core\/lint$/ }, () => ({
208
+ path: resolve(monorepoRoot, "packages/core/src/lint/index.ts"),
209
+ }));
210
+ },
211
+ };
212
+
213
+ await esbuild.build({
214
+ bundle: true,
215
+ platform: "node",
216
+ target: "node22",
217
+ format: "esm",
218
+ // Externalise binary-shipped modules so node module resolution picks
219
+ // them up at runtime. esbuild would otherwise try to inline their
220
+ // postinstall-extracted binaries, which it cannot do.
221
+ external: [
222
+ "@sparticuz/chromium",
223
+ "puppeteer-core",
224
+ "puppeteer",
225
+ // AWS SDK v3 is pre-installed in the Lambda Node 22 runtime; mark
226
+ // external so we don't double-bundle 3+ MiB of SDK.
227
+ "@aws-sdk/client-s3",
228
+ ],
229
+ plugins: [workspaceAliasPlugin],
230
+ minify: false,
231
+ // sourcemap=false: the ZIP is tight on Lambda's 250 MiB unzipped cap
232
+ // (Chrome ~70 MiB + ffmpeg ~80 MiB + ffprobe ~62 MiB + Node deps). A
233
+ // 4-5 MiB sourcemap puts us over. Re-enable for local debugging by passing
234
+ // --sourcemap; the bundle's stack traces stay readable enough without
235
+ // it because we don't minify.
236
+ sourcemap: false,
237
+ entryPoints: [entry],
238
+ outfile,
239
+ // Lambda's Node 22 runtime treats `.mjs` as ESM. Inject a real `require`
240
+ // via `createRequire` so esbuild's `__require` shim resolves to it
241
+ // instead of throwing "Dynamic require of <X> is not supported" on
242
+ // CommonJS modules in the dependency graph (postcss, etc. that ship
243
+ // top-level `require('path')` calls). The shim does
244
+ // `typeof require !== "undefined" ? require : <throwing-proxy>`, so
245
+ // making `require` a real value in module scope flips it onto the
246
+ // happy path.
247
+ banner: {
248
+ js: [
249
+ "// hyperframes-aws-lambda handler bundle",
250
+ 'import { createRequire as __hf_createRequire } from "module";',
251
+ "const require = __hf_createRequire(import.meta.url);",
252
+ ].join("\n"),
253
+ },
254
+ });
255
+ console.log(`[build-zip] bundled handler → ${outfile}`);
256
+ }
257
+
258
+ function stageRuntimeModules(stagingDir: string, source: BuildOptions["source"]): void {
259
+ // Bun's isolated-install layout means cpSync(@sparticuz/chromium) only
260
+ // copies the package's own files, missing transitive deps like `tar-fs`.
261
+ // The clean cross-package-manager solution: write a tiny package.json
262
+ // into staging/ that declares the production deps, then `npm install`
263
+ // there. npm flattens transitive deps into staging/node_modules/.
264
+ const pkg: Record<string, unknown> = {
265
+ name: "hyperframes-aws-lambda-bundled",
266
+ version: "0.0.0",
267
+ private: true,
268
+ dependencies: {
269
+ "puppeteer-core": readDepVersion("puppeteer-core"),
270
+ },
271
+ };
272
+ if (source === "sparticuz") {
273
+ (pkg.dependencies as Record<string, string>)["@sparticuz/chromium"] =
274
+ readDepVersion("@sparticuz/chromium");
275
+ }
276
+ writeFileSync(join(stagingDir, "package.json"), JSON.stringify(pkg, null, 2));
277
+
278
+ // --no-package-lock so we don't pollute staging with a lockfile we don't
279
+ // ship; --no-audit/--no-fund just for log noise.
280
+ const result = spawnSync(
281
+ "npm",
282
+ ["install", "--no-package-lock", "--no-audit", "--no-fund", "--omit=dev", "--omit=optional"],
283
+ {
284
+ cwd: stagingDir,
285
+ stdio: "inherit",
286
+ },
287
+ );
288
+ if (result.status !== 0) {
289
+ throw new Error(`[build-zip] npm install into staging failed (status ${result.status})`);
290
+ }
291
+ console.log(`[build-zip] staged node_modules via npm install`);
292
+ }
293
+
294
+ function readDepVersion(moduleName: string): string {
295
+ // Resolve the EXACT version bun installed into the workspace, not the
296
+ // semver range declared in package.json. The staging-dir npm install
297
+ // runs with `--no-package-lock`, so a caret range would float to the
298
+ // latest registry version at build time — diverging from what the
299
+ // workspace tests ran against and breaking ZIP-content determinism
300
+ // across consecutive builds. The lockfile pin gives us reproducibility.
301
+ const lockText = readFileSync(join(monorepoRoot, "bun.lock"), "utf-8");
302
+ // bun.lock lines look like:
303
+ // "puppeteer-core": ["puppeteer-core@24.43.1", "", { ... }, "sha512-..."],
304
+ const re = new RegExp(
305
+ `"${moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}":\\s*\\["${moduleName.replace(
306
+ /[.*+?^${}()|[\]\\]/g,
307
+ "\\$&",
308
+ )}@([^"]+)"`,
309
+ );
310
+ const match = re.exec(lockText);
311
+ if (!match || !match[1]) {
312
+ // Fall back to the manifest range — better than failing the build
313
+ // entirely if bun.lock's format changes between bun versions.
314
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8")) as {
315
+ dependencies?: Record<string, string>;
316
+ };
317
+ return manifest.dependencies?.[moduleName] ?? "latest";
318
+ }
319
+ return match[1];
320
+ }
321
+
322
+ function resolveModuleDir(moduleName: string): string {
323
+ // Walk up from packageRoot to find a matching node_modules entry.
324
+ // Used by stageFfmpeg below; the @sparticuz/chromium + puppeteer-core
325
+ // paths now go through npm install instead.
326
+ let dir = packageRoot;
327
+ for (let i = 0; i < 5; i++) {
328
+ const candidate = join(dir, "node_modules", moduleName);
329
+ if (existsSync(candidate)) return candidate;
330
+ dir = dirname(dir);
331
+ }
332
+ throw new Error(
333
+ `[build-zip] could not resolve ${moduleName} from ${packageRoot} — run 'bun install' first.`,
334
+ );
335
+ }
336
+
337
+ function stageHyperframeRuntime(stagingDir: string): void {
338
+ const coreDist = resolve(monorepoRoot, "packages/core/dist");
339
+ const manifestSrc = join(coreDist, "hyperframe.manifest.json");
340
+ const iifeSrc = join(coreDist, "hyperframe.runtime.iife.js");
341
+ if (!existsSync(manifestSrc) || !existsSync(iifeSrc)) {
342
+ throw new Error(
343
+ `[build-zip] hyperframe runtime artifacts missing under ${coreDist}. ` +
344
+ `Run 'bun run --filter @hyperframes/core build:hyperframes-runtime:modular' first.`,
345
+ );
346
+ }
347
+ cpSync(manifestSrc, join(stagingDir, "hyperframe.manifest.json"));
348
+ cpSync(iifeSrc, join(stagingDir, "hyperframe.runtime.iife.js"));
349
+ console.log(`[build-zip] staged hyperframe.manifest.json + hyperframe.runtime.iife.js`);
350
+ }
351
+
352
+ function stageFfmpeg(stagingDir: string): void {
353
+ const binDir = join(stagingDir, "bin");
354
+ mkdirSync(binDir, { recursive: true });
355
+
356
+ // ffmpeg from `ffmpeg-static`. The package only ships the encoder
357
+ // binary; the audio pad/trim path also needs ffprobe, which comes
358
+ // from `ffprobe-static`.
359
+ const ffmpegBinary = join(resolveModuleDir("ffmpeg-static"), "ffmpeg");
360
+ if (!existsSync(ffmpegBinary)) {
361
+ throw new Error(
362
+ `[build-zip] ffmpeg-static binary missing at ${ffmpegBinary}. Did postinstall run?`,
363
+ );
364
+ }
365
+ const ffmpegDest = join(binDir, "ffmpeg");
366
+ cpSync(ffmpegBinary, ffmpegDest);
367
+ chmodSync(ffmpegDest, 0o755);
368
+
369
+ // ffprobe lives at `ffprobe-static/bin/<platform>/<arch>/ffprobe`.
370
+ // The producer's `audioPadTrim` spawns `ffprobe` from PATH so we need
371
+ // it alongside ffmpeg under /var/task/bin/.
372
+ const ffprobeModule = resolveModuleDir("ffprobe-static");
373
+ const ffprobeCandidates = [
374
+ join(ffprobeModule, "bin", "linux", "x64", "ffprobe"),
375
+ join(ffprobeModule, "bin", "linux", "arm64", "ffprobe"),
376
+ ];
377
+ const ffprobeBinary = ffprobeCandidates.find((p) => existsSync(p));
378
+ if (!ffprobeBinary) {
379
+ throw new Error(
380
+ `[build-zip] ffprobe-static binary not found under ${ffprobeModule}/bin/linux/. Did postinstall run?`,
381
+ );
382
+ }
383
+ const ffprobeDest = join(binDir, "ffprobe");
384
+ cpSync(ffprobeBinary, ffprobeDest);
385
+ chmodSync(ffprobeDest, 0o755);
386
+
387
+ console.log(`[build-zip] staged ffmpeg + ffprobe → bin/`);
388
+ }
389
+
390
+ function stageChromeHeadlessShell(stagingDir: string): void {
391
+ // The fallback path bundles the same chrome-headless-shell binary the
392
+ // K8s deploy uses. The binary is fetched via `@puppeteer/browsers` on
393
+ // first build into the host's `~/.cache/puppeteer/`; the build script
394
+ // re-uses that cache rather than redownloading.
395
+ const home = process.env.HOME ?? "/root";
396
+ const baseDir = join(home, ".cache", "puppeteer", "chrome-headless-shell");
397
+ if (!existsSync(baseDir)) {
398
+ throw new Error(
399
+ `[build-zip] chrome-headless-shell cache missing at ${baseDir}. Run\n` +
400
+ ` npx --yes @puppeteer/browsers install chrome-headless-shell@stable --path ${home}/.cache/puppeteer\n` +
401
+ `before --source=chrome-headless-shell.`,
402
+ );
403
+ }
404
+ // Sort by numeric semver descending. `sort().reverse()` is lexicographic,
405
+ // which silently picks "99.0.0" over "131.0.0" once Chrome ships
406
+ // three-digit majors that aren't strictly width-aligned. `compareSemver`
407
+ // returns negative/zero/positive on (a, b), so descending = `b - a`.
408
+ const versions = readdirSync(baseDir).sort((a, b) => compareSemver(b, a));
409
+ for (const v of versions) {
410
+ const candidate = join(baseDir, v, "chrome-headless-shell-linux64", "chrome-headless-shell");
411
+ if (existsSync(candidate)) {
412
+ const dest = join(stagingDir, "bin", "chrome-headless-shell");
413
+ mkdirSync(dirname(dest), { recursive: true });
414
+ cpSync(candidate, dest);
415
+ chmodSync(dest, 0o755);
416
+ console.log(`[build-zip] staged chrome-headless-shell (${v}) → bin/chrome-headless-shell`);
417
+ return;
418
+ }
419
+ }
420
+ throw new Error(`[build-zip] no linux64 chrome-headless-shell binary found under ${baseDir}.`);
421
+ }
422
+
423
+ /**
424
+ * Compare two semver-shaped strings like "131.0.6778.108". Treats any
425
+ * non-numeric directory name as `-Infinity` so it sorts to the bottom
426
+ * (Puppeteer's cache layout sometimes includes `latest` or branch tags).
427
+ * Used by `stageChromeHeadlessShell` to pick the newest cached Chrome
428
+ * without tripping on the lexicographic "99 > 131" trap.
429
+ */
430
+ function compareSemver(a: string, b: string): number {
431
+ const partsA = a.split(".").map((s) => Number.parseInt(s, 10));
432
+ const partsB = b.split(".").map((s) => Number.parseInt(s, 10));
433
+ const len = Math.max(partsA.length, partsB.length);
434
+ for (let i = 0; i < len; i++) {
435
+ const ai = partsA[i] ?? 0;
436
+ const bi = partsB[i] ?? 0;
437
+ if (Number.isNaN(ai) && Number.isNaN(bi)) continue;
438
+ if (Number.isNaN(ai)) return -1;
439
+ if (Number.isNaN(bi)) return 1;
440
+ if (ai !== bi) return ai - bi;
441
+ }
442
+ return 0;
443
+ }
444
+
445
+ function zipDirectory(sourceDir: string, zipPath: string): void {
446
+ const result = spawnSync("zip", ["-rq", zipPath, "."], { cwd: sourceDir, stdio: "inherit" });
447
+ if (result.status !== 0) {
448
+ throw new Error(`[build-zip] zip exited with status ${result.status}`);
449
+ }
450
+ }
451
+
452
+ function directorySizeBytes(dir: string): number {
453
+ // Use spawnSync (no shell) instead of execSync so `dir` is passed as
454
+ // an argv element rather than interpolated into a shell command —
455
+ // CodeQL's `js/shell-command-injected-from-environment` rule fires
456
+ // on the latter even with JSON-quoting. `du -sb` is Linux-only;
457
+ // build-zip is CI-side where Linux coreutils is present.
458
+ const result = spawnSync("du", ["-sb", dir], { encoding: "utf-8" });
459
+ if (result.status === 0 && result.stdout) {
460
+ const bytes = Number.parseInt(result.stdout.split(/\s+/)[0] ?? "0", 10);
461
+ if (!Number.isNaN(bytes)) return bytes;
462
+ }
463
+ return walkSize(dir);
464
+ }
465
+
466
+ function walkSize(dir: string): number {
467
+ let total = 0;
468
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
469
+ const full = join(dir, entry.name);
470
+ if (entry.isDirectory()) total += walkSize(full);
471
+ else if (entry.isFile()) total += statSync(full).size;
472
+ }
473
+ return total;
474
+ }
475
+
476
+ void main().catch((err) => {
477
+ console.error("[build-zip] failed:", err instanceof Error ? err.message : String(err));
478
+ if (err instanceof Error && err.stack) console.error(err.stack);
479
+ process.exit(1);
480
+ });
@@ -0,0 +1,61 @@
1
+ # BeginFrame regression-guard container.
2
+ #
3
+ # Uses the official AWS Lambda Node 22 image as the base so the probe
4
+ # exercises @sparticuz/chromium against the SAME glibc, kernel feature
5
+ # set, and `/tmp` filesystem layout that real Lambda invocations see. If
6
+ # this Dockerfile passes, the bundled handler is on solid footing for
7
+ # real AWS.
8
+ #
9
+ # Build context: monorepo root (../../). Build + run:
10
+ #
11
+ # bun run --cwd packages/aws-lambda probe:beginframe:docker
12
+ #
13
+ # The default CMD runs `tsx scripts/probe-beginframe.ts` and exits 0 on
14
+ # pass, 1 on BeginFrame failure, 2 on harness failure.
15
+
16
+ FROM public.ecr.aws/lambda/nodejs:22
17
+
18
+ # Shared libraries @sparticuz/chromium expects but the Lambda base image
19
+ # does not bring in by default. Versions are pinned to whatever
20
+ # `dnf install` resolves on the Lambda base image at build time; we just
21
+ # need them present.
22
+ RUN dnf install -y \
23
+ alsa-lib \
24
+ atk \
25
+ cups-libs \
26
+ gtk3 \
27
+ libdrm \
28
+ libxkbcommon \
29
+ libXcomposite \
30
+ libXdamage \
31
+ libXrandr \
32
+ mesa-libgbm \
33
+ nss \
34
+ pango \
35
+ tar \
36
+ gzip \
37
+ unzip \
38
+ && dnf clean all
39
+
40
+ WORKDIR /var/task
41
+
42
+ # The probe is self-contained — we install the three deps it needs into a
43
+ # fresh package directory rather than re-using the monorepo's
44
+ # workspace-rooted manifests (which carry `workspace:` protocol deps npm
45
+ # can't resolve).
46
+ COPY packages/aws-lambda/scripts/ scripts/
47
+
48
+ RUN printf '{"name":"hf-lambda-probe","version":"1.0.0","type":"module"}\n' > package.json \
49
+ && npm install --no-audit --no-fund --omit=optional \
50
+ @sparticuz/chromium@148.0.0 \
51
+ puppeteer-core@^24.39.1 \
52
+ tsx@^4.21.0
53
+
54
+ ENV NODE_PATH=/var/task/node_modules
55
+ ENV PATH="/var/task/node_modules/.bin:${PATH}"
56
+
57
+ # Lambda's `tmpfs` is mounted at /tmp; sparticuz decompresses into /tmp
58
+ # at runtime. The base image already has /tmp writable.
59
+
60
+ ENTRYPOINT []
61
+ CMD ["node", "--experimental-strip-types", "scripts/probe-beginframe.ts"]