@hyperframes/aws-lambda 0.7.73 → 0.7.74

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/aws-lambda",
3
- "version": "0.7.73",
3
+ "version": "0.7.74",
4
4
  "description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,7 +45,7 @@
45
45
  "ffprobe-static": "^3.1.0",
46
46
  "puppeteer-core": "^25.2.1",
47
47
  "tar": "^7.4.3",
48
- "@hyperframes/producer": "^0.7.73"
48
+ "@hyperframes/producer": "^0.7.74"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/aws-lambda": "^8.10.146",
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import {
6
+ _awaitBeforeDeadlineForTests,
7
+ _closeBrowserForProbeTests,
8
+ parseProbeArgs,
9
+ } from "./probe-beginframe.js";
10
+
11
+ describe("parseProbeArgs", () => {
12
+ it("defaults to the @sparticuz/chromium source", () => {
13
+ expect(parseProbeArgs([])).toEqual({});
14
+ });
15
+
16
+ it("accepts a standalone executable path", () => {
17
+ expect(parseProbeArgs(["--executable-path", "/opt/chrome/chrome-headless-shell"])).toEqual({
18
+ executablePath: "/opt/chrome/chrome-headless-shell",
19
+ });
20
+ });
21
+
22
+ it("accepts the equals form and resolves relative paths", () => {
23
+ expect(parseProbeArgs(["--executable-path=./chrome"])).toEqual({
24
+ executablePath: resolve("./chrome"),
25
+ });
26
+ });
27
+
28
+ it("loads exact production launch arguments from JSON", () => {
29
+ const dir = mkdtempSync(join(tmpdir(), "hf-probe-args-"));
30
+ const argsPath = join(dir, "args.json");
31
+ writeFileSync(argsPath, JSON.stringify(["--enable-begin-frame-control", "--no-sandbox"]));
32
+ try {
33
+ expect(parseProbeArgs(["--launch-args-json", argsPath])).toEqual({
34
+ launchArgs: ["--enable-begin-frame-control", "--no-sandbox"],
35
+ });
36
+ } finally {
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ it("rejects missing values and unknown arguments", () => {
42
+ expect(() => parseProbeArgs(["--executable-path"])).toThrow(
43
+ "--executable-path requires a path",
44
+ );
45
+ expect(() => parseProbeArgs(["--source", "chrome"])).toThrow("Unknown argument: --source");
46
+ expect(() => parseProbeArgs(["--launch-args-json"])).toThrow(
47
+ "--launch-args-json requires a path",
48
+ );
49
+ });
50
+
51
+ it("bounds a CDP operation that never resolves", async () => {
52
+ const never = new Promise<never>(() => {});
53
+ await expect(
54
+ _awaitBeforeDeadlineForTests(never, Date.now() + 25, "screenshot beginFrame"),
55
+ ).rejects.toThrow("timeout during screenshot beginFrame");
56
+ });
57
+
58
+ it("force-kills and disconnects when graceful cleanup never resolves", async () => {
59
+ let killedWith: NodeJS.Signals | number | undefined;
60
+ let disconnected = false;
61
+ await _closeBrowserForProbeTests(
62
+ {
63
+ close: () => new Promise<never>(() => {}),
64
+ process: () => ({
65
+ kill: (signal) => {
66
+ killedWith = signal;
67
+ return true;
68
+ },
69
+ }),
70
+ disconnect: async () => {
71
+ disconnected = true;
72
+ },
73
+ },
74
+ 25,
75
+ );
76
+
77
+ expect(killedWith).toBe("SIGKILL");
78
+ expect(disconnected).toBe(true);
79
+ });
80
+ });
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env tsx
2
+ // fallow-ignore-file code-duplication
2
3
  /**
3
- * BeginFrame regression guard for `@sparticuz/chromium`.
4
+ * BeginFrame regression guard for a Chromium executable.
4
5
  *
5
- * The load-bearing assumption of `@hyperframes/aws-lambda` is that the
6
- * Chromium build shipped by `@sparticuz/chromium` honours CDP
7
- * `HeadlessExperimental.beginFrame` with `screenshot: true`. This script
8
- * boots that Chromium build (decompressing into `/tmp` per the library's
9
- * runtime contract), navigates to a tiny static page, issues one
10
- * `beginFrame` with a screenshot request, and asserts the response
11
- * carries a PNG buffer.
6
+ * With no arguments, this boots the build shipped by `@sparticuz/chromium`
7
+ * (decompressing into `/tmp` per the library's runtime contract). Passing
8
+ * `--executable-path /path/to/chrome-headless-shell` probes an arbitrary
9
+ * executable instead; the GCP image build uses that form against the exact
10
+ * binary copied into the image.
12
11
  *
13
12
  * The script is the contract test, not a one-shot verification — every
14
13
  * release should run it inside the Docker container at
@@ -17,15 +16,18 @@
17
16
  *
18
17
  * Exits 0 on pass, 1 on fail. Run via:
19
18
  *
20
- * bun run --cwd packages/aws-lambda probe:beginframe # host
21
- * bun run --cwd packages/aws-lambda probe:beginframe:docker # Lambda-like
19
+ * bun run --cwd packages/aws-lambda probe:beginframe
20
+ * bun run --cwd packages/aws-lambda probe:beginframe -- \
21
+ * --executable-path /opt/chrome/chrome-headless-shell
22
+ * bun run --cwd packages/aws-lambda probe:beginframe:docker
22
23
  */
23
24
 
24
- import { mkdtempSync, promises as fs } from "node:fs";
25
+ import { mkdtempSync, promises as fs, readFileSync } from "node:fs";
25
26
  import { tmpdir } from "node:os";
26
- import { join } from "node:path";
27
+ import { join, resolve } from "node:path";
28
+ import { fileURLToPath } from "node:url";
27
29
 
28
- interface ProbeResult {
30
+ export interface ProbeResult {
29
31
  passed: boolean;
30
32
  durationMs: number;
31
33
  chromiumPath: string;
@@ -38,10 +40,140 @@ const PROBE_HTML = `<!doctype html>
38
40
  <html><head><meta charset="utf-8"><title>hf-beginframe-probe</title>
39
41
  <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>
40
42
  </head><body><div id="x">hf-beginframe-probe</div></body></html>`;
43
+ const SCREENSHOT_ATTEMPTS = 10;
44
+ const PROBE_OPERATION_TIMEOUT_MS = 5000;
45
+ const PROBE_CLEANUP_TIMEOUT_MS = 250;
46
+
47
+ export interface ProbeOptions {
48
+ executablePath?: string;
49
+ /** Exact launch arguments to probe instead of the standalone default profile. */
50
+ launchArgs?: string[];
51
+ /** Test override for the renderer/CDP operation deadline. */
52
+ timeoutMs?: number;
53
+ }
54
+
55
+ // The CLI accepts paired and equals forms for two independent path options.
56
+ // fallow-ignore-next-line complexity
57
+ export function parseProbeArgs(args: string[]): ProbeOptions {
58
+ let executablePath: string | undefined;
59
+ let launchArgs: string[] | undefined;
60
+ for (let i = 0; i < args.length; i += 1) {
61
+ const arg = args[i];
62
+ if (arg === "--executable-path") {
63
+ const value = args[i + 1];
64
+ if (!value || value.startsWith("--")) {
65
+ throw new Error("--executable-path requires a path");
66
+ }
67
+ executablePath = resolve(value);
68
+ i += 1;
69
+ continue;
70
+ }
71
+ if (arg.startsWith("--executable-path=")) {
72
+ const value = arg.slice("--executable-path=".length);
73
+ if (!value) throw new Error("--executable-path requires a path");
74
+ executablePath = resolve(value);
75
+ continue;
76
+ }
77
+ if (arg === "--launch-args-json") {
78
+ const value = args[i + 1];
79
+ if (!value || value.startsWith("--")) {
80
+ throw new Error("--launch-args-json requires a path");
81
+ }
82
+ launchArgs = readLaunchArgs(value);
83
+ i += 1;
84
+ continue;
85
+ }
86
+ if (arg.startsWith("--launch-args-json=")) {
87
+ const value = arg.slice("--launch-args-json=".length);
88
+ if (!value) throw new Error("--launch-args-json requires a path");
89
+ launchArgs = readLaunchArgs(value);
90
+ continue;
91
+ }
92
+ throw new Error(`Unknown argument: ${arg}`);
93
+ }
94
+ return {
95
+ ...(executablePath ? { executablePath } : {}),
96
+ ...(launchArgs ? { launchArgs } : {}),
97
+ };
98
+ }
99
+
100
+ function readLaunchArgs(path: string): string[] {
101
+ const resolved = resolve(path);
102
+ const value: unknown = JSON.parse(readFileSync(resolved, "utf-8"));
103
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
104
+ throw new Error(`--launch-args-json must contain a JSON string array: ${resolved}`);
105
+ }
106
+ return value;
107
+ }
108
+
109
+ async function awaitBeforeDeadline<T>(
110
+ operation: Promise<T>,
111
+ deadline: number,
112
+ label: string,
113
+ ): Promise<T> {
114
+ const remainingMs = deadline - Date.now();
115
+ if (remainingMs <= 0) throw new Error(`BeginFrame probe timeout before ${label}`);
116
+ let timeout: ReturnType<typeof setTimeout> | undefined;
117
+ try {
118
+ return await Promise.race([
119
+ operation,
120
+ new Promise<never>((_, reject) => {
121
+ timeout = setTimeout(
122
+ () => reject(new Error(`BeginFrame probe timeout during ${label}`)),
123
+ remainingMs,
124
+ );
125
+ }),
126
+ ]);
127
+ } finally {
128
+ if (timeout) clearTimeout(timeout);
129
+ }
130
+ }
131
+
132
+ /** Test-only export for the standalone probe's bounded-operation contract. */
133
+ export const _awaitBeforeDeadlineForTests = awaitBeforeDeadline;
134
+
135
+ interface ProbeBrowserCleanup {
136
+ close(): Promise<void>;
137
+ disconnect(): Promise<void>;
138
+ process(): { kill(signal?: NodeJS.Signals | number): boolean } | null;
139
+ }
140
+
141
+ async function settleWithin(operation: Promise<unknown>, timeoutMs: number): Promise<boolean> {
142
+ let timeout: ReturnType<typeof setTimeout> | undefined;
143
+ try {
144
+ return await Promise.race([
145
+ operation.then(
146
+ () => true,
147
+ () => false,
148
+ ),
149
+ new Promise<false>((resolveTimeout) => {
150
+ timeout = setTimeout(() => resolveTimeout(false), timeoutMs);
151
+ }),
152
+ ]);
153
+ } finally {
154
+ if (timeout) clearTimeout(timeout);
155
+ }
156
+ }
157
+
158
+ async function closeBrowserForProbe(
159
+ browser: ProbeBrowserCleanup,
160
+ timeoutMs = PROBE_CLEANUP_TIMEOUT_MS,
161
+ ): Promise<void> {
162
+ if (await settleWithin(browser.close(), timeoutMs)) return;
163
+ try {
164
+ browser.process()?.kill("SIGKILL");
165
+ } catch {
166
+ // Best effort; disconnect below still releases Puppeteer's transport.
167
+ }
168
+ await settleWithin(browser.disconnect(), timeoutMs);
169
+ }
170
+
171
+ /** Test-only export for bounded standalone-probe cleanup. */
172
+ export const _closeBrowserForProbeTests = closeBrowserForProbe;
41
173
 
42
174
  async function main(): Promise<void> {
43
175
  const start = Date.now();
44
- const result = await probe();
176
+ const result = await probe(parseProbeArgs(process.argv.slice(2)));
45
177
  result.durationMs = Date.now() - start;
46
178
  console.log(JSON.stringify(result, null, 2));
47
179
  if (!result.passed) {
@@ -49,12 +181,21 @@ async function main(): Promise<void> {
49
181
  }
50
182
  }
51
183
 
52
- async function probe(): Promise<ProbeResult> {
184
+ // This intentionally linear contract owns launch, renderer setup, CDP
185
+ // validation, diagnostics, and cleanup in one fail-closed lifecycle.
186
+ // fallow-ignore-next-line complexity
187
+ export async function probe(options: ProbeOptions = {}): Promise<ProbeResult> {
53
188
  let chromiumPath = "";
189
+ let tmpHtmlDir = "";
54
190
  try {
55
- const { default: chromium } = await import("@sparticuz/chromium");
56
- chromiumPath = await chromium.executablePath();
57
- const args = chromium.args;
191
+ let sourceArgs: string[] = [];
192
+ if (options.executablePath) {
193
+ chromiumPath = options.executablePath;
194
+ } else {
195
+ const { default: chromium } = await import("@sparticuz/chromium");
196
+ chromiumPath = await chromium.executablePath();
197
+ sourceArgs = chromium.args;
198
+ }
58
199
 
59
200
  const puppeteer = await import("puppeteer-core");
60
201
 
@@ -63,7 +204,7 @@ async function probe(): Promise<ProbeResult> {
63
204
  // Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives
64
205
  // an unguessable directory name so two concurrent probes on the same
65
206
  // host don't collide and CodeQL's insecure-tempfile rule clears.
66
- const tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
207
+ tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
67
208
  const htmlPath = join(tmpHtmlDir, "probe.html");
68
209
  await fs.writeFile(htmlPath, PROBE_HTML, "utf-8");
69
210
 
@@ -75,6 +216,11 @@ async function probe(): Promise<ProbeResult> {
75
216
  // ("Chrome's beginFrame with `screenshot` param always reports
76
217
  // hasDamage=true").
77
218
  const beginFrameFlags = [
219
+ "--no-sandbox",
220
+ "--disable-setuid-sandbox",
221
+ "--disable-dev-shm-usage",
222
+ "--enable-webgl",
223
+ "--ignore-gpu-blocklist",
78
224
  "--deterministic-mode",
79
225
  "--enable-begin-frame-control",
80
226
  "--disable-new-content-rendering-timeout",
@@ -89,55 +235,97 @@ async function probe(): Promise<ProbeResult> {
89
235
  "--use-gl=angle",
90
236
  "--use-angle=swiftshader",
91
237
  "--enable-unsafe-swiftshader",
238
+ // Distributed Linux rendering explicitly uses software compositing to
239
+ // avoid stale transformed layers in SwiftShader (see browserManager).
240
+ "--disable-gpu-compositing",
92
241
  ];
93
242
 
94
243
  const browser = await puppeteer.launch({
95
244
  executablePath: chromiumPath,
96
245
  headless: "shell",
97
- args: [...args, ...beginFrameFlags],
246
+ args: options.launchArgs ?? [...sourceArgs, ...beginFrameFlags],
98
247
  defaultViewport: { width: 800, height: 600 },
99
248
  });
100
249
  try {
101
- const page = await browser.newPage();
102
- await page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: 30_000 });
103
- const session = await page.createCDPSession();
104
- await session.send("HeadlessExperimental.enable");
250
+ const timeoutMs = options.timeoutMs ?? PROBE_OPERATION_TIMEOUT_MS;
251
+ const deadline = Date.now() + timeoutMs;
252
+ const page = await awaitBeforeDeadline(browser.newPage(), deadline, "newPage");
253
+ await awaitBeforeDeadline(
254
+ page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: timeoutMs }),
255
+ deadline,
256
+ "navigation",
257
+ );
258
+ const session = await awaitBeforeDeadline(
259
+ page.createCDPSession(),
260
+ deadline,
261
+ "CDP session creation",
262
+ );
263
+ await awaitBeforeDeadline(
264
+ session.send("HeadlessExperimental.enable"),
265
+ deadline,
266
+ "HeadlessExperimental.enable",
267
+ );
105
268
  // Warm-up beginFrame with noDisplayUpdates: true — drives the
106
269
  // compositor without producing a screenshot, matching how the engine
107
270
  // primes a capture loop.
108
- await session.send("HeadlessExperimental.beginFrame", {
109
- frameTimeTicks: 0,
110
- interval: 33,
111
- noDisplayUpdates: true,
112
- });
113
- const response = await session.send("HeadlessExperimental.beginFrame", {
114
- frameTimeTicks: 1000,
115
- interval: 33,
116
- screenshot: { format: "png" },
117
- });
118
- await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
119
- const screenshot = response.screenshotData ?? "";
120
- const bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
121
- const isPng =
122
- bytes.length >= 8 &&
123
- bytes[0] === 0x89 &&
124
- bytes[1] === 0x50 &&
125
- bytes[2] === 0x4e &&
126
- bytes[3] === 0x47;
271
+ await awaitBeforeDeadline(
272
+ session.send("HeadlessExperimental.beginFrame", {
273
+ frameTimeTicks: 0,
274
+ interval: 33,
275
+ noDisplayUpdates: true,
276
+ }),
277
+ deadline,
278
+ "warm-up beginFrame",
279
+ );
280
+ let hasDamage = false;
281
+ let bytes = Buffer.alloc(0);
282
+ let isPng = false;
283
+ let attempts = 0;
284
+ // A renderer-ready document can still need more than one controlled
285
+ // frame before it submits a screenshot surface. Chromium explicitly
286
+ // permits screenshotData to be absent during renderer initialization,
287
+ // so retry a small bounded sequence with monotonically increasing ticks.
288
+ for (attempts = 1; attempts <= SCREENSHOT_ATTEMPTS; attempts += 1) {
289
+ const response = await awaitBeforeDeadline(
290
+ session.send("HeadlessExperimental.beginFrame", {
291
+ frameTimeTicks: 1000 + (attempts - 1) * 33,
292
+ interval: 33,
293
+ screenshot: { format: "png" },
294
+ }),
295
+ deadline,
296
+ `screenshot beginFrame attempt ${attempts}`,
297
+ );
298
+ hasDamage = response.hasDamage;
299
+ const screenshot = response.screenshotData ?? "";
300
+ bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
301
+ isPng =
302
+ bytes.length >= 8 &&
303
+ bytes[0] === 0x89 &&
304
+ bytes[1] === 0x50 &&
305
+ bytes[2] === 0x4e &&
306
+ bytes[3] === 0x47;
307
+ if (isPng) break;
308
+ await awaitBeforeDeadline(
309
+ new Promise((resolveDelay) => setTimeout(resolveDelay, 10)),
310
+ deadline,
311
+ `screenshot retry delay ${attempts}`,
312
+ );
313
+ }
127
314
  return {
128
315
  passed: isPng && bytes.length > 0,
129
316
  durationMs: 0,
130
317
  chromiumPath,
131
318
  screenshotBytes: bytes.length,
132
- hasDamage: response.hasDamage,
319
+ hasDamage,
133
320
  detail: isPng
134
- ? "OK — BeginFrame returned a PNG buffer."
135
- : `FAIL — BeginFrame returned ${bytes.length} bytes, PNG signature ${
321
+ ? `OK — BeginFrame returned a PNG buffer after ${attempts} attempt(s).`
322
+ : `FAIL — BeginFrame returned ${bytes.length} bytes after ${SCREENSHOT_ATTEMPTS} ` +
323
+ `attempts, PNG signature ${
136
324
  bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"
137
325
  }`,
138
326
  };
139
327
  } finally {
140
- await browser.close().catch(() => {});
328
+ await closeBrowserForProbe(browser);
141
329
  }
142
330
  } catch (err) {
143
331
  return {
@@ -148,10 +336,17 @@ async function probe(): Promise<ProbeResult> {
148
336
  hasDamage: false,
149
337
  detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`,
150
338
  };
339
+ } finally {
340
+ if (tmpHtmlDir) {
341
+ await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
342
+ }
151
343
  }
152
344
  }
153
345
 
154
- void main().catch((err) => {
155
- console.error("[probe-beginframe] unexpected:", err);
156
- process.exit(2);
157
- });
346
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
347
+ if (invokedPath === fileURLToPath(import.meta.url)) {
348
+ void main().catch((err) => {
349
+ console.error("[probe-beginframe] unexpected:", err);
350
+ process.exit(2);
351
+ });
352
+ }