@hyperframes/aws-lambda 0.7.72 → 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/dist/events.d.ts +2 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/handler.d.ts +2 -2
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +234 -111
- package/dist/handler.js.map +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +243 -119
- package/dist/index.js.map +4 -4
- package/dist/s3PlanV2Publisher.d.ts +26 -0
- package/dist/s3PlanV2Publisher.d.ts.map +1 -0
- package/dist/s3Transport.d.ts.map +1 -1
- package/dist/sdk/index.js.map +2 -2
- package/package.json +2 -2
- package/scripts/probe-beginframe.test.ts +80 -0
- package/scripts/probe-beginframe.ts +246 -51
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
/**
|
|
3
|
-
* BeginFrame regression guard for
|
|
4
|
+
* BeginFrame regression guard for a Chromium executable.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
21
|
-
* bun run --cwd packages/aws-lambda probe:beginframe
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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: [...
|
|
246
|
+
args: options.launchArgs ?? [...sourceArgs, ...beginFrameFlags],
|
|
98
247
|
defaultViewport: { width: 800, height: 600 },
|
|
99
248
|
});
|
|
100
249
|
try {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
await
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
319
|
+
hasDamage,
|
|
133
320
|
detail: isPng
|
|
134
|
-
?
|
|
135
|
-
: `FAIL — BeginFrame returned ${bytes.length} bytes
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|