wb-browser-runtime 0.5.3 → 0.6.1
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/README.md +9 -3
- package/bin/wb-browser-runtime.js +472 -142
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,9 +28,15 @@ specific run.
|
|
|
28
28
|
Verb arguments support two substitutions at dispatch time:
|
|
29
29
|
|
|
30
30
|
- `{{ env.NAME }}` — reads `process.env.NAME`. Use for static secrets injected via Doppler / the agent's env.
|
|
31
|
-
- `{{ artifacts.NAME }}` — reads `$WB_ARTIFACTS_DIR/NAME.txt` (falling back to `$WB_ARTIFACTS_DIR/NAME`). Use for dynamic values produced by an earlier bash cell — OTPs, magic-link URLs, export IDs, anything polled from an external system mid-run.
|
|
31
|
+
- `{{ artifacts.NAME }}` — reads `$WB_ARTIFACTS_DIR/NAME.txt` (falling back to `$WB_ARTIFACTS_DIR/NAME`). Use for dynamic values produced by an earlier bash cell — OTPs, magic-link URLs, export IDs, anything polled from an external system mid-run. Reads are cached for the duration of one slice; a bash cell that runs *between* slices is always picked up by the next slice's verbs.
|
|
32
32
|
|
|
33
|
-
Both forms are redacted in stdout summaries — only the verb name + selector make it into the log.
|
|
33
|
+
Both forms are redacted in stdout summaries — only the verb name + selector make it into the log. Expanded values are also scrubbed from `verb.failed` / `slice.failed` error messages before they cross the stdio boundary.
|
|
34
|
+
|
|
35
|
+
**Missing-value policy.** Set `WB_SUBSTITUTION_ON_MISSING` to choose how a missing `env.X` or `artifacts.X` is handled:
|
|
36
|
+
|
|
37
|
+
- `warn` (default) — log a stderr warning and substitute an empty string; the verb continues.
|
|
38
|
+
- `error` — throw, failing the slice. Use in CI so a missing OTP doesn't silently dispatch an empty selector.
|
|
39
|
+
- `empty` — substitute empty silently (suppresses the warning).
|
|
34
40
|
|
|
35
41
|
## Optional: anti-detection
|
|
36
42
|
|
|
@@ -77,7 +83,7 @@ Each POST carries headers `Authorization: Bearer <secret>`,
|
|
|
77
83
|
`step.recording.*` on the callback stream:
|
|
78
84
|
|
|
79
85
|
- `step.recording.started` — once per session, payload includes `run_id`, `kinds`.
|
|
80
|
-
- `step.recording.uploaded` — on 2xx
|
|
86
|
+
- `step.recording.uploaded` — on 2xx POST, payload includes `kind`, `bytes`.
|
|
81
87
|
- `step.recording.failed` — on network/ffmpeg/upload error, payload includes `kind`, `status?`, `reason`. Non-fatal: the slice still completes.
|
|
82
88
|
|
|
83
89
|
## Usage
|
|
@@ -49,7 +49,7 @@ const SUPPORTS = [
|
|
|
49
49
|
];
|
|
50
50
|
|
|
51
51
|
const BB_BASE = "https://api.browserbase.com";
|
|
52
|
-
const VERSION = "0.
|
|
52
|
+
const VERSION = "0.6.0";
|
|
53
53
|
|
|
54
54
|
// --- Recording config -------------------------------------------------------
|
|
55
55
|
//
|
|
@@ -94,11 +94,25 @@ function loadRecordingConfig() {
|
|
|
94
94
|
(process.env.TRIGGER_RUN_ID || "").trim() ||
|
|
95
95
|
`wb-${randomUUID()}`;
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
// Clamp to ranges ffmpeg/libvpx-vp9 actually handles. Requesting fps=120
|
|
98
|
+
// silently blew up memory; quality=0 produced unwatchable garbage. Clamp
|
|
99
|
+
// + log so operators see the effective value.
|
|
100
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
101
|
+
const rawFps =
|
|
98
102
|
Number.parseInt(process.env.WB_RECORDING_SCREENCAST_FPS || "", 10) || 5;
|
|
99
|
-
const
|
|
103
|
+
const rawQuality =
|
|
100
104
|
Number.parseInt(process.env.WB_RECORDING_SCREENCAST_QUALITY || "", 10) ||
|
|
101
105
|
60;
|
|
106
|
+
const fps = clamp(rawFps, 1, 30);
|
|
107
|
+
const quality = clamp(rawQuality, 10, 95);
|
|
108
|
+
if (fps !== rawFps) {
|
|
109
|
+
log(`[recording] fps=${rawFps} clamped to ${fps} (valid range 1..30)`);
|
|
110
|
+
}
|
|
111
|
+
if (quality !== rawQuality) {
|
|
112
|
+
log(
|
|
113
|
+
`[recording] quality=${rawQuality} clamped to ${quality} (valid range 10..95)`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
102
116
|
|
|
103
117
|
const rrwebRequested = process.env.WB_RECORDING_RRWEB !== "0";
|
|
104
118
|
const videoRequested = process.env.WB_RECORDING_VIDEO !== "0";
|
|
@@ -131,6 +145,10 @@ function loadRecordingConfig() {
|
|
|
131
145
|
return { enabled: false, reason: "all-kinds-disabled" };
|
|
132
146
|
}
|
|
133
147
|
|
|
148
|
+
const rrwebMaxEvents =
|
|
149
|
+
Number.parseInt(process.env.WB_RECORDING_RRWEB_MAX_EVENTS || "", 10) ||
|
|
150
|
+
50_000;
|
|
151
|
+
|
|
134
152
|
return {
|
|
135
153
|
enabled: true,
|
|
136
154
|
uploadUrl,
|
|
@@ -140,6 +158,7 @@ function loadRecordingConfig() {
|
|
|
140
158
|
quality,
|
|
141
159
|
kinds,
|
|
142
160
|
rrwebSource,
|
|
161
|
+
rrwebMaxEvents,
|
|
143
162
|
};
|
|
144
163
|
}
|
|
145
164
|
|
|
@@ -194,14 +213,18 @@ async function bbCreateSession() {
|
|
|
194
213
|
|
|
195
214
|
log(`[bb] session create advancedStealth=${advancedStealth} proxies=${proxies}`);
|
|
196
215
|
|
|
197
|
-
const res = await
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"
|
|
201
|
-
|
|
216
|
+
const res = await retryableFetch(
|
|
217
|
+
`${BB_BASE}/v1/sessions`,
|
|
218
|
+
{
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: {
|
|
221
|
+
"X-BB-API-Key": apiKey,
|
|
222
|
+
"Content-Type": "application/json",
|
|
223
|
+
},
|
|
224
|
+
body: JSON.stringify(body),
|
|
202
225
|
},
|
|
203
|
-
|
|
204
|
-
|
|
226
|
+
"bb.create",
|
|
227
|
+
);
|
|
205
228
|
if (!res.ok) {
|
|
206
229
|
throw new Error(
|
|
207
230
|
`Browserbase create failed (${res.status}): ${await safeText(res)}`,
|
|
@@ -212,9 +235,11 @@ async function bbCreateSession() {
|
|
|
212
235
|
|
|
213
236
|
async function bbGetLiveUrl(sessionId) {
|
|
214
237
|
const apiKey = process.env.BROWSERBASE_API_KEY;
|
|
215
|
-
const res = await
|
|
216
|
-
|
|
217
|
-
|
|
238
|
+
const res = await retryableFetch(
|
|
239
|
+
`${BB_BASE}/v1/sessions/${sessionId}/debug`,
|
|
240
|
+
{ headers: { "X-BB-API-Key": apiKey } },
|
|
241
|
+
"bb.debug",
|
|
242
|
+
);
|
|
218
243
|
if (!res.ok) {
|
|
219
244
|
throw new Error(
|
|
220
245
|
`Browserbase debug fetch failed (${res.status}): ${await safeText(res)}`,
|
|
@@ -228,11 +253,15 @@ async function bbReleaseSession(sessionId) {
|
|
|
228
253
|
const apiKey = process.env.BROWSERBASE_API_KEY;
|
|
229
254
|
const projectId = process.env.BROWSERBASE_PROJECT_ID;
|
|
230
255
|
try {
|
|
231
|
-
await
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
256
|
+
await retryableFetch(
|
|
257
|
+
`${BB_BASE}/v1/sessions/${sessionId}`,
|
|
258
|
+
{
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "X-BB-API-Key": apiKey, "Content-Type": "application/json" },
|
|
261
|
+
body: JSON.stringify({ projectId, status: "REQUEST_RELEASE" }),
|
|
262
|
+
},
|
|
263
|
+
"bb.release",
|
|
264
|
+
);
|
|
236
265
|
} catch (e) {
|
|
237
266
|
log(`[shutdown] release session ${sessionId} failed: ${e.message}`);
|
|
238
267
|
}
|
|
@@ -246,6 +275,44 @@ async function safeText(res) {
|
|
|
246
275
|
}
|
|
247
276
|
}
|
|
248
277
|
|
|
278
|
+
// Retry transient network + 5xx/429 failures with short exponential backoff.
|
|
279
|
+
// Each attempt gets its own AbortController + timeout; caller-passed signals
|
|
280
|
+
// are not plumbed through since we don't have a cancellation story above this
|
|
281
|
+
// layer. Non-retryable statuses (4xx except 429) are returned immediately for
|
|
282
|
+
// the caller to handle.
|
|
283
|
+
async function retryableFetch(url, opts = {}, label, { timeoutMs = 30_000 } = {}) {
|
|
284
|
+
const delays = [100, 500];
|
|
285
|
+
let lastErr = null;
|
|
286
|
+
let lastRes = null;
|
|
287
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
288
|
+
if (attempt > 0) {
|
|
289
|
+
await new Promise((r) => setTimeout(r, delays[attempt - 1]));
|
|
290
|
+
const prev = lastRes
|
|
291
|
+
? `status=${lastRes.status}`
|
|
292
|
+
: `err=${lastErr?.message || lastErr}`;
|
|
293
|
+
log(`[retry] ${label} attempt ${attempt + 1}/3 (${prev})`);
|
|
294
|
+
}
|
|
295
|
+
const controller = new AbortController();
|
|
296
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
297
|
+
try {
|
|
298
|
+
const res = await fetch(url, { ...opts, signal: controller.signal });
|
|
299
|
+
if (res.ok) return res;
|
|
300
|
+
if (res.status === 429 || (res.status >= 500 && res.status < 600)) {
|
|
301
|
+
lastRes = res;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
return res;
|
|
305
|
+
} catch (e) {
|
|
306
|
+
lastErr = e;
|
|
307
|
+
continue;
|
|
308
|
+
} finally {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (lastRes) return lastRes;
|
|
313
|
+
throw lastErr;
|
|
314
|
+
}
|
|
315
|
+
|
|
249
316
|
// --- Session cache ----------------------------------------------------------
|
|
250
317
|
|
|
251
318
|
const sessions = new Map(); // name -> { sid, browser, context, page, liveUrl, recording }
|
|
@@ -253,32 +320,48 @@ const sessions = new Map(); // name -> { sid, browser, context, page, liveUrl, r
|
|
|
253
320
|
async function ensureSession(name) {
|
|
254
321
|
if (sessions.has(name)) return sessions.get(name);
|
|
255
322
|
|
|
323
|
+
// Browserbase charges for the session the moment it's created; if anything
|
|
324
|
+
// after this point throws (debug URL, CDP connect, newContext, recording
|
|
325
|
+
// setup) we must release it explicitly or quota leaks until BB's idle
|
|
326
|
+
// timeout.
|
|
256
327
|
const created = await bbCreateSession();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
328
|
+
let browser = null;
|
|
329
|
+
try {
|
|
330
|
+
const liveUrl = await bbGetLiveUrl(created.id);
|
|
331
|
+
browser = await chromium.connectOverCDP(created.connectUrl);
|
|
332
|
+
const context = browser.contexts()[0] ?? (await browser.newContext());
|
|
333
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
334
|
+
|
|
335
|
+
const info = {
|
|
336
|
+
sid: created.id,
|
|
337
|
+
browser,
|
|
338
|
+
context,
|
|
339
|
+
page,
|
|
340
|
+
liveUrl,
|
|
341
|
+
recording: null,
|
|
342
|
+
};
|
|
343
|
+
sessions.set(name, info);
|
|
344
|
+
|
|
345
|
+
send({
|
|
346
|
+
type: "slice.session_started",
|
|
347
|
+
session: name,
|
|
348
|
+
session_id: created.id,
|
|
349
|
+
live_url: liveUrl,
|
|
350
|
+
started_at: new Date().toISOString(),
|
|
351
|
+
});
|
|
279
352
|
|
|
280
|
-
|
|
281
|
-
|
|
353
|
+
await startRecording(info, name);
|
|
354
|
+
return info;
|
|
355
|
+
} catch (e) {
|
|
356
|
+
if (browser) {
|
|
357
|
+
try {
|
|
358
|
+
await browser.close();
|
|
359
|
+
} catch {}
|
|
360
|
+
}
|
|
361
|
+
sessions.delete(name);
|
|
362
|
+
await bbReleaseSession(created.id);
|
|
363
|
+
throw e;
|
|
364
|
+
}
|
|
282
365
|
}
|
|
283
366
|
|
|
284
367
|
// --- Recording (rrweb + CDP screencast) ------------------------------------
|
|
@@ -301,6 +384,8 @@ async function startRecording(info, sessionName) {
|
|
|
301
384
|
info.recording = {
|
|
302
385
|
kinds: { ...RECORDING.kinds },
|
|
303
386
|
rrwebEvents: [],
|
|
387
|
+
rrwebDropped: 0,
|
|
388
|
+
rrwebOverflowLogged: false,
|
|
304
389
|
cdp: null,
|
|
305
390
|
ffmpeg: null,
|
|
306
391
|
ffmpegDone: null,
|
|
@@ -308,11 +393,28 @@ async function startRecording(info, sessionName) {
|
|
|
308
393
|
};
|
|
309
394
|
const rec = info.recording;
|
|
310
395
|
|
|
396
|
+
// Drop oldest events once the buffer exceeds the cap — keeps the tail of a
|
|
397
|
+
// long run (usually the interesting bit) rather than failing the upload or
|
|
398
|
+
// OOMing the sidecar. One warning per session so ops can spot it.
|
|
399
|
+
const pushRrweb = (e) => {
|
|
400
|
+
if (rec.rrwebEvents.length >= RECORDING.rrwebMaxEvents) {
|
|
401
|
+
rec.rrwebEvents.shift();
|
|
402
|
+
rec.rrwebDropped++;
|
|
403
|
+
if (!rec.rrwebOverflowLogged) {
|
|
404
|
+
rec.rrwebOverflowLogged = true;
|
|
405
|
+
log(
|
|
406
|
+
`[recording] rrweb buffer hit cap (${RECORDING.rrwebMaxEvents}); dropping oldest events`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
rec.rrwebEvents.push(e);
|
|
411
|
+
};
|
|
412
|
+
|
|
311
413
|
if (rec.kinds.rrweb) {
|
|
312
414
|
try {
|
|
313
415
|
await info.context.exposeBinding("__wbRrwebFlush", (_src, batch) => {
|
|
314
416
|
if (Array.isArray(batch)) {
|
|
315
|
-
for (const e of batch)
|
|
417
|
+
for (const e of batch) pushRrweb(e);
|
|
316
418
|
}
|
|
317
419
|
});
|
|
318
420
|
const bootstrap = `
|
|
@@ -397,10 +499,56 @@ async function startRecording(info, sessionName) {
|
|
|
397
499
|
|
|
398
500
|
const cdp = await info.context.newCDPSession(info.page);
|
|
399
501
|
rec.cdp = cdp;
|
|
502
|
+
// Dedup identical consecutive frames. CDP emits repeats when nothing
|
|
503
|
+
// changed on screen; encoding them as distinct frames bloats the WebM
|
|
504
|
+
// and mis-paces playback. Compare the base64 string directly — it's
|
|
505
|
+
// cheaper than hashing and equivalent for exact equality.
|
|
506
|
+
let lastFrameData = null;
|
|
507
|
+
let dedupCount = 0;
|
|
508
|
+
let dedupLogged = false;
|
|
509
|
+
|
|
400
510
|
cdp.on("Page.screencastFrame", async (frame) => {
|
|
401
511
|
try {
|
|
402
512
|
if (ff.stdin.writable && !ff.killed) {
|
|
403
|
-
|
|
513
|
+
if (frame.data === lastFrameData) {
|
|
514
|
+
dedupCount++;
|
|
515
|
+
if (!dedupLogged && dedupCount >= 100) {
|
|
516
|
+
dedupLogged = true;
|
|
517
|
+
log(
|
|
518
|
+
`[recording] dedup active (${dedupCount} duplicate frames skipped so far)`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
// Still ack — Chrome needs it to keep streaming.
|
|
522
|
+
await cdp.send("Page.screencastFrameAck", {
|
|
523
|
+
sessionId: frame.sessionId,
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
lastFrameData = frame.data;
|
|
528
|
+
const buf = Buffer.from(frame.data, "base64");
|
|
529
|
+
const ok = ff.stdin.write(buf);
|
|
530
|
+
// Backpressure: if ffmpeg's stdin buffer is full, wait for drain
|
|
531
|
+
// before acking so Chrome slows frame production instead of
|
|
532
|
+
// piling JPEG frames in Node heap. 5s fail-open so a wedged
|
|
533
|
+
// ffmpeg can't stall the protocol indefinitely.
|
|
534
|
+
if (!ok) {
|
|
535
|
+
await new Promise((resolve) => {
|
|
536
|
+
let fired = false;
|
|
537
|
+
const done = () => {
|
|
538
|
+
if (fired) return;
|
|
539
|
+
fired = true;
|
|
540
|
+
ff.stdin.off("drain", done);
|
|
541
|
+
ff.stdin.off("close", done);
|
|
542
|
+
ff.stdin.off("error", done);
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
resolve();
|
|
545
|
+
};
|
|
546
|
+
const timer = setTimeout(done, 5000);
|
|
547
|
+
ff.stdin.once("drain", done);
|
|
548
|
+
ff.stdin.once("close", done);
|
|
549
|
+
ff.stdin.once("error", done);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
404
552
|
}
|
|
405
553
|
// Must ack each frame or Chrome stops streaming.
|
|
406
554
|
await cdp.send("Page.screencastFrameAck", {
|
|
@@ -453,7 +601,13 @@ async function flushRecording(info, sessionName) {
|
|
|
453
601
|
return out;
|
|
454
602
|
});
|
|
455
603
|
if (Array.isArray(tail)) {
|
|
456
|
-
for (const e of tail)
|
|
604
|
+
for (const e of tail) {
|
|
605
|
+
if (rec.rrwebEvents.length >= RECORDING.rrwebMaxEvents) {
|
|
606
|
+
rec.rrwebEvents.shift();
|
|
607
|
+
rec.rrwebDropped++;
|
|
608
|
+
}
|
|
609
|
+
rec.rrwebEvents.push(e);
|
|
610
|
+
}
|
|
457
611
|
}
|
|
458
612
|
} catch (e) {
|
|
459
613
|
log(`[recording] rrweb final drain failed: ${e.message}`);
|
|
@@ -464,6 +618,7 @@ async function flushRecording(info, sessionName) {
|
|
|
464
618
|
run_id: RECORDING.runId,
|
|
465
619
|
session: sessionName,
|
|
466
620
|
event_count: rec.rrwebEvents.length,
|
|
621
|
+
dropped: rec.rrwebDropped,
|
|
467
622
|
events: rec.rrwebEvents,
|
|
468
623
|
});
|
|
469
624
|
rrwebBody = await gzip(Buffer.from(json, "utf8"));
|
|
@@ -474,31 +629,46 @@ async function flushRecording(info, sessionName) {
|
|
|
474
629
|
}
|
|
475
630
|
|
|
476
631
|
let videoBody = null;
|
|
632
|
+
let videoFailure = null;
|
|
477
633
|
if (rec.kinds.video && rec.cdp && rec.ffmpeg) {
|
|
478
634
|
try {
|
|
479
635
|
await rec.cdp.send("Page.stopScreencast");
|
|
480
636
|
} catch {
|
|
481
637
|
// Browser may already be tearing down.
|
|
482
638
|
}
|
|
639
|
+
const timeoutMs =
|
|
640
|
+
Number.parseInt(process.env.WB_RECORDING_FFMPEG_TIMEOUT_MS || "", 10) ||
|
|
641
|
+
30_000;
|
|
483
642
|
try {
|
|
484
643
|
rec.ffmpeg.stdin.end();
|
|
485
644
|
const settled = await Promise.race([
|
|
486
645
|
rec.ffmpegDone,
|
|
487
|
-
new Promise((r) =>
|
|
646
|
+
new Promise((r) =>
|
|
647
|
+
setTimeout(() => r({ __timeout: true }), timeoutMs),
|
|
648
|
+
),
|
|
488
649
|
]);
|
|
489
|
-
if (settled === "
|
|
490
|
-
log(
|
|
650
|
+
if (settled && typeof settled === "object" && settled.__timeout) {
|
|
651
|
+
log(`[recording] ffmpeg did not exit within ${timeoutMs}ms; killing`);
|
|
491
652
|
try {
|
|
492
653
|
rec.ffmpeg.kill("SIGKILL");
|
|
493
654
|
} catch {}
|
|
655
|
+
videoFailure = `ffmpeg_timeout_${timeoutMs}ms`;
|
|
656
|
+
} else if (typeof settled === "number" && settled !== 0) {
|
|
657
|
+
// ff.on('close') resolves with the exit code — non-zero means ffmpeg
|
|
658
|
+
// produced a corrupt/partial webm that we should not upload.
|
|
659
|
+
videoFailure = `ffmpeg_exit_code_${settled}`;
|
|
660
|
+
log(`[recording] ffmpeg exited with code ${settled}`);
|
|
494
661
|
}
|
|
495
|
-
if (rec.videoPath && existsSync(rec.videoPath)) {
|
|
662
|
+
if (!videoFailure && rec.videoPath && existsSync(rec.videoPath)) {
|
|
496
663
|
videoBody = await fsPromises.readFile(rec.videoPath);
|
|
664
|
+
}
|
|
665
|
+
if (rec.videoPath && existsSync(rec.videoPath)) {
|
|
497
666
|
try {
|
|
498
667
|
await fsPromises.unlink(rec.videoPath);
|
|
499
668
|
} catch {}
|
|
500
669
|
}
|
|
501
670
|
} catch (e) {
|
|
671
|
+
videoFailure = `finalize_error: ${e.message}`;
|
|
502
672
|
log(`[recording] video finalize failed: ${e.message}`);
|
|
503
673
|
}
|
|
504
674
|
}
|
|
@@ -521,6 +691,16 @@ async function flushRecording(info, sessionName) {
|
|
|
521
691
|
fps: RECORDING.fps,
|
|
522
692
|
}),
|
|
523
693
|
);
|
|
694
|
+
} else if (videoFailure) {
|
|
695
|
+
// Surface a terminal recording failure to the callback stream so the
|
|
696
|
+
// consumer knows the video was lost rather than silently missing.
|
|
697
|
+
send({
|
|
698
|
+
type: "slice.recording.failed",
|
|
699
|
+
session: sessionName,
|
|
700
|
+
run_id: RECORDING.runId,
|
|
701
|
+
kind: "video",
|
|
702
|
+
reason: videoFailure,
|
|
703
|
+
});
|
|
524
704
|
}
|
|
525
705
|
await Promise.allSettled(uploads);
|
|
526
706
|
}
|
|
@@ -529,21 +709,23 @@ async function uploadArtifact(kind, body, contentType, sessionName, extra) {
|
|
|
529
709
|
const url = RECORDING.uploadUrl
|
|
530
710
|
.replace("{run_id}", encodeURIComponent(RECORDING.runId))
|
|
531
711
|
.replace("{kind}", encodeURIComponent(kind));
|
|
532
|
-
const controller = new AbortController();
|
|
533
|
-
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
534
712
|
try {
|
|
535
|
-
const res = await
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
713
|
+
const res = await retryableFetch(
|
|
714
|
+
url,
|
|
715
|
+
{
|
|
716
|
+
method: "POST",
|
|
717
|
+
headers: {
|
|
718
|
+
Authorization: `Bearer ${RECORDING.secret}`,
|
|
719
|
+
"Content-Type": contentType,
|
|
720
|
+
"X-WB-Run-Id": RECORDING.runId,
|
|
721
|
+
"X-WB-Recording-Kind": kind,
|
|
722
|
+
"X-WB-Session": sessionName,
|
|
723
|
+
},
|
|
724
|
+
body,
|
|
543
725
|
},
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
726
|
+
`upload.${kind}`,
|
|
727
|
+
{ timeoutMs: 30_000 },
|
|
728
|
+
);
|
|
547
729
|
if (!res.ok) {
|
|
548
730
|
send({
|
|
549
731
|
type: "slice.recording.failed",
|
|
@@ -571,8 +753,6 @@ async function uploadArtifact(kind, body, contentType, sessionName, extra) {
|
|
|
571
753
|
kind,
|
|
572
754
|
reason: e.name === "AbortError" ? "timeout" : e.message,
|
|
573
755
|
});
|
|
574
|
-
} finally {
|
|
575
|
-
clearTimeout(timer);
|
|
576
756
|
}
|
|
577
757
|
}
|
|
578
758
|
|
|
@@ -583,51 +763,114 @@ function sanitize(s) {
|
|
|
583
763
|
// --- {{ env.X }} / {{ artifacts.X }} substitution --------------------------
|
|
584
764
|
|
|
585
765
|
const ENV_RE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
586
|
-
|
|
766
|
+
// Artifact names are bare identifiers — no dots, no slashes. Anything more
|
|
767
|
+
// exotic would invite path traversal once composed with WB_ARTIFACTS_DIR.
|
|
768
|
+
const ARTIFACT_RE = /\{\{\s*artifacts\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g;
|
|
769
|
+
|
|
770
|
+
function resolveInside(dir, candidate) {
|
|
771
|
+
const resolvedDir = path.resolve(dir);
|
|
772
|
+
const resolved = path.resolve(resolvedDir, candidate);
|
|
773
|
+
const rel = path.relative(resolvedDir, resolved);
|
|
774
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
775
|
+
return resolved;
|
|
776
|
+
}
|
|
587
777
|
|
|
588
|
-
|
|
778
|
+
// Resolved once at module load. `warn` matches historical behavior
|
|
779
|
+
// (log + empty string, runbook continues). `error` throws so a missing OTP
|
|
780
|
+
// or env var fails the slice instead of silently sending an empty value
|
|
781
|
+
// into a Playwright action. `empty` is the silent variant.
|
|
782
|
+
const ON_MISSING = (() => {
|
|
783
|
+
const raw = (process.env.WB_SUBSTITUTION_ON_MISSING || "warn")
|
|
784
|
+
.trim()
|
|
785
|
+
.toLowerCase();
|
|
786
|
+
if (raw === "error" || raw === "empty" || raw === "warn") return raw;
|
|
787
|
+
log(
|
|
788
|
+
`[warn] WB_SUBSTITUTION_ON_MISSING=${raw} is not valid (warn|error|empty); defaulting to warn`,
|
|
789
|
+
);
|
|
790
|
+
return "warn";
|
|
791
|
+
})();
|
|
792
|
+
|
|
793
|
+
function handleMissingSubstitution(kind, name) {
|
|
794
|
+
const msg = `${kind}.${name} is not set`;
|
|
795
|
+
if (ON_MISSING === "error") {
|
|
796
|
+
throw new Error(`substitution: ${msg}`);
|
|
797
|
+
}
|
|
798
|
+
if (ON_MISSING === "warn") {
|
|
799
|
+
log(`[warn] ${msg}; substituting empty string`);
|
|
800
|
+
}
|
|
801
|
+
return "";
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function readArtifactRaw(name) {
|
|
589
805
|
const dir = (process.env.WB_ARTIFACTS_DIR || "").trim();
|
|
590
806
|
if (!dir) {
|
|
591
807
|
log(`[warn] artifacts.${name} referenced but WB_ARTIFACTS_DIR is not set`);
|
|
592
|
-
return
|
|
808
|
+
return null;
|
|
593
809
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
810
|
+
for (const candidate of [`${name}.txt`, name]) {
|
|
811
|
+
const full = resolveInside(dir, candidate);
|
|
812
|
+
if (!full) continue;
|
|
597
813
|
try {
|
|
598
|
-
return readFileSync(
|
|
814
|
+
return readFileSync(full, "utf8").trimEnd();
|
|
599
815
|
} catch {
|
|
600
816
|
// try next candidate
|
|
601
817
|
}
|
|
602
818
|
}
|
|
603
|
-
|
|
604
|
-
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function readArtifact(name, cache) {
|
|
823
|
+
if (cache && cache.has(name)) {
|
|
824
|
+
const hit = cache.get(name);
|
|
825
|
+
if (hit === null) return handleMissingSubstitution("artifacts", name);
|
|
826
|
+
return hit;
|
|
827
|
+
}
|
|
828
|
+
const v = readArtifactRaw(name);
|
|
829
|
+
if (cache) cache.set(name, v);
|
|
830
|
+
if (v === null) return handleMissingSubstitution("artifacts", name);
|
|
831
|
+
return v;
|
|
605
832
|
}
|
|
606
833
|
|
|
607
|
-
function expand(value) {
|
|
834
|
+
function expand(value, collected, artifactCache) {
|
|
608
835
|
if (typeof value === "string") {
|
|
609
836
|
return value
|
|
610
837
|
.replace(ENV_RE, (_, name) => {
|
|
611
838
|
const v = process.env[name];
|
|
612
|
-
if (v === undefined)
|
|
613
|
-
|
|
614
|
-
// summaries instead of silently turning into empty strings.
|
|
615
|
-
log(`[warn] env var ${name} is not set; leaving placeholder`);
|
|
616
|
-
return "";
|
|
617
|
-
}
|
|
839
|
+
if (v === undefined) return handleMissingSubstitution("env", name);
|
|
840
|
+
if (collected && v.length >= 3) collected.add(v);
|
|
618
841
|
return v;
|
|
619
842
|
})
|
|
620
|
-
.replace(ARTIFACT_RE, (_, name) =>
|
|
843
|
+
.replace(ARTIFACT_RE, (_, name) => {
|
|
844
|
+
const v = readArtifact(name, artifactCache);
|
|
845
|
+
if (collected && v && v.length >= 3) collected.add(v);
|
|
846
|
+
return v;
|
|
847
|
+
});
|
|
621
848
|
}
|
|
622
|
-
if (Array.isArray(value))
|
|
849
|
+
if (Array.isArray(value))
|
|
850
|
+
return value.map((v) => expand(v, collected, artifactCache));
|
|
623
851
|
if (value && typeof value === "object") {
|
|
624
852
|
const out = {};
|
|
625
|
-
for (const [k, v] of Object.entries(value))
|
|
853
|
+
for (const [k, v] of Object.entries(value))
|
|
854
|
+
out[k] = expand(v, collected, artifactCache);
|
|
626
855
|
return out;
|
|
627
856
|
}
|
|
628
857
|
return value;
|
|
629
858
|
}
|
|
630
859
|
|
|
860
|
+
// Scrub any values that came from {{ env.X }} / {{ artifacts.X }} expansion
|
|
861
|
+
// out of error messages before they cross the stdio boundary — Playwright and
|
|
862
|
+
// fetch errors sometimes echo their inputs (URLs, script bodies, assertion
|
|
863
|
+
// text) and those inputs may contain credentials.
|
|
864
|
+
function scrubSecrets(msg, secrets) {
|
|
865
|
+
let out = String(msg == null ? "" : msg);
|
|
866
|
+
if (!secrets) return out;
|
|
867
|
+
for (const s of secrets) {
|
|
868
|
+
if (!s) continue;
|
|
869
|
+
out = out.split(s).join("«***»");
|
|
870
|
+
}
|
|
871
|
+
return out;
|
|
872
|
+
}
|
|
873
|
+
|
|
631
874
|
// --- Verb dispatch ----------------------------------------------------------
|
|
632
875
|
|
|
633
876
|
function verbName(verb) {
|
|
@@ -647,7 +890,11 @@ function arg(value, primaryKey) {
|
|
|
647
890
|
async function runVerb(page, verb, index, ctx) {
|
|
648
891
|
const name = verbName(verb);
|
|
649
892
|
const raw = verb[name];
|
|
650
|
-
const a = expand(
|
|
893
|
+
const a = expand(
|
|
894
|
+
arg(raw, defaultKey(name)),
|
|
895
|
+
ctx?.secrets,
|
|
896
|
+
ctx?.artifactCache,
|
|
897
|
+
);
|
|
651
898
|
|
|
652
899
|
switch (name) {
|
|
653
900
|
case "goto": {
|
|
@@ -682,15 +929,42 @@ async function runVerb(page, verb, index, ctx) {
|
|
|
682
929
|
return `${selector} (${state})`;
|
|
683
930
|
}
|
|
684
931
|
case "screenshot": {
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
932
|
+
// Always resolve inside $WB_ARTIFACTS_DIR (or cwd when unset). Absolute
|
|
933
|
+
// paths and traversals are rejected — screenshots are controlled by
|
|
934
|
+
// runbook authors whose content we don't want to grant arbitrary-write.
|
|
688
935
|
const requested = a.path ?? `screenshot-${Date.now()}.png`;
|
|
689
|
-
const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim();
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
936
|
+
const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim() || ".";
|
|
937
|
+
if (path.isAbsolute(requested)) {
|
|
938
|
+
throw new Error(
|
|
939
|
+
`screenshot: absolute paths are not allowed (got ${requested})`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
const full = resolveInside(artifactsDir, requested);
|
|
943
|
+
if (!full) {
|
|
944
|
+
throw new Error(
|
|
945
|
+
`screenshot: path escapes artifacts dir (got ${requested})`,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
await fsPromises.mkdir(path.dirname(full), { recursive: true });
|
|
949
|
+
// Atomic write via tmp + rename so a crash mid-capture can't leave a
|
|
950
|
+
// truncated PNG that's already been announced via slice.artifact_saved
|
|
951
|
+
// and uploaded to R2. We capture to a Buffer (with `type` derived from
|
|
952
|
+
// the requested extension) and write it ourselves — passing a `.tmp`
|
|
953
|
+
// path directly to Playwright fails because it infers format from the
|
|
954
|
+
// file extension and rejects unknown ones.
|
|
955
|
+
const ext = path.extname(full).toLowerCase();
|
|
956
|
+
const type = ext === ".jpg" || ext === ".jpeg" ? "jpeg" : "png";
|
|
957
|
+
const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
958
|
+
try {
|
|
959
|
+
const buf = await page.screenshot({ type, fullPage: !!a.full_page });
|
|
960
|
+
await fsPromises.writeFile(tmp, buf);
|
|
961
|
+
await fsPromises.rename(tmp, full);
|
|
962
|
+
} catch (e) {
|
|
963
|
+
try {
|
|
964
|
+
await fsPromises.unlink(tmp);
|
|
965
|
+
} catch {}
|
|
966
|
+
throw e;
|
|
967
|
+
}
|
|
694
968
|
return `→ ${requested}`;
|
|
695
969
|
}
|
|
696
970
|
case "extract": {
|
|
@@ -777,16 +1051,24 @@ async function runVerb(page, verb, index, ctx) {
|
|
|
777
1051
|
const filename = name.endsWith(".json") ? name : `${name}.json`;
|
|
778
1052
|
const full = path.join(artifactsDir, filename);
|
|
779
1053
|
await fsPromises.mkdir(artifactsDir, { recursive: true });
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1054
|
+
// Atomic write: serialize to .tmp, then rename. Announce the artifact
|
|
1055
|
+
// AFTER rename so a partial write can never be seen by wb's uploader.
|
|
1056
|
+
const serialized = JSON.stringify(payload, null, 2);
|
|
1057
|
+
const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
1058
|
+
try {
|
|
1059
|
+
await fsPromises.writeFile(tmp, serialized, "utf8");
|
|
1060
|
+
await fsPromises.rename(tmp, full);
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
try {
|
|
1063
|
+
await fsPromises.unlink(tmp);
|
|
1064
|
+
} catch {}
|
|
1065
|
+
throw e;
|
|
1066
|
+
}
|
|
785
1067
|
send({
|
|
786
1068
|
type: "slice.artifact_saved",
|
|
787
1069
|
filename,
|
|
788
1070
|
path: full,
|
|
789
|
-
bytes: Buffer.byteLength(
|
|
1071
|
+
bytes: Buffer.byteLength(serialized),
|
|
790
1072
|
});
|
|
791
1073
|
return `→ ${filename}`;
|
|
792
1074
|
}
|
|
@@ -837,57 +1119,79 @@ function redact(value) {
|
|
|
837
1119
|
// --- Slice handler ----------------------------------------------------------
|
|
838
1120
|
|
|
839
1121
|
async function handleSlice(msg) {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1122
|
+
// Declared outside the inner try so the outer catch can scrub error
|
|
1123
|
+
// messages using whatever secrets were collected before the throw.
|
|
1124
|
+
const sliceCtx = {
|
|
1125
|
+
lastResult: undefined,
|
|
1126
|
+
blockIndex:
|
|
1127
|
+
typeof msg?.block_index === "number" ? msg.block_index : null,
|
|
1128
|
+
secrets: new Set(),
|
|
1129
|
+
// Per-slice cache so `{{ artifacts.otp }}` referenced from 5 verbs
|
|
1130
|
+
// hits disk once instead of 5× and doesn't block the event loop
|
|
1131
|
+
// per-verb. Freshness across slices is preserved because the cache is
|
|
1132
|
+
// scoped to one slice — a bash cell that rewrites the file between
|
|
1133
|
+
// slices is seen on the next slice's first read.
|
|
1134
|
+
artifactCache: new Map(),
|
|
1135
|
+
};
|
|
1136
|
+
// Top-level guard: any unhandled error must emit slice.failed so the Rust
|
|
1137
|
+
// side sees a terminal frame instead of waiting out SLICE_EVENT_TIMEOUT.
|
|
847
1138
|
try {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
type: "slice.failed",
|
|
852
|
-
error: `session start failed: ${e.message}`,
|
|
853
|
-
});
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
1139
|
+
const verbs = Array.isArray(msg.verbs) ? msg.verbs : [];
|
|
1140
|
+
const sessionName = msg.session || "default";
|
|
1141
|
+
const restore = msg.restore || null;
|
|
856
1142
|
|
|
857
|
-
|
|
858
|
-
// The sidecar protocol leaves room for it; when wait_for_mfa lands, this
|
|
859
|
-
// is where we'd jump to verbs[restore.state.verb_index].
|
|
860
|
-
const startAt = restore?.state?.verb_index ?? 0;
|
|
861
|
-
|
|
862
|
-
// Per-slice scratch so `save:` can capture the prior verb's JSON output.
|
|
863
|
-
const sliceCtx = { lastResult: undefined, blockIndex };
|
|
864
|
-
|
|
865
|
-
for (let i = startAt; i < verbs.length; i++) {
|
|
866
|
-
const v = verbs[i];
|
|
867
|
-
const name = verbName(v);
|
|
1143
|
+
let session;
|
|
868
1144
|
try {
|
|
869
|
-
|
|
870
|
-
send({
|
|
871
|
-
type: "verb.complete",
|
|
872
|
-
verb: name,
|
|
873
|
-
verb_index: i,
|
|
874
|
-
summary,
|
|
875
|
-
});
|
|
1145
|
+
session = await ensureSession(sessionName);
|
|
876
1146
|
} catch (e) {
|
|
877
|
-
send({
|
|
878
|
-
type: "verb.failed",
|
|
879
|
-
verb: name,
|
|
880
|
-
verb_index: i,
|
|
881
|
-
error: e.message,
|
|
882
|
-
});
|
|
883
1147
|
send({
|
|
884
1148
|
type: "slice.failed",
|
|
885
|
-
error: `
|
|
1149
|
+
error: `session start failed: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
|
|
886
1150
|
});
|
|
887
1151
|
return;
|
|
888
1152
|
}
|
|
1153
|
+
|
|
1154
|
+
// Restore-from-pause is not implemented yet (no pause verb wired here).
|
|
1155
|
+
// The sidecar protocol leaves room for it; when wait_for_mfa lands, this
|
|
1156
|
+
// is where we'd jump to verbs[restore.state.verb_index].
|
|
1157
|
+
const startAt = restore?.state?.verb_index ?? 0;
|
|
1158
|
+
|
|
1159
|
+
for (let i = startAt; i < verbs.length; i++) {
|
|
1160
|
+
const v = verbs[i];
|
|
1161
|
+
const name = verbName(v);
|
|
1162
|
+
try {
|
|
1163
|
+
const summary = await runVerb(session.page, v, i, sliceCtx);
|
|
1164
|
+
send({
|
|
1165
|
+
type: "verb.complete",
|
|
1166
|
+
verb: name,
|
|
1167
|
+
verb_index: i,
|
|
1168
|
+
summary,
|
|
1169
|
+
});
|
|
1170
|
+
} catch (e) {
|
|
1171
|
+
const clean = scrubSecrets(e.message, sliceCtx.secrets);
|
|
1172
|
+
send({
|
|
1173
|
+
type: "verb.failed",
|
|
1174
|
+
verb: name,
|
|
1175
|
+
verb_index: i,
|
|
1176
|
+
error: clean,
|
|
1177
|
+
});
|
|
1178
|
+
send({
|
|
1179
|
+
type: "slice.failed",
|
|
1180
|
+
error: `verb ${name} (index ${i}): ${clean}`,
|
|
1181
|
+
});
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
send({ type: "slice.complete" });
|
|
1186
|
+
} catch (e) {
|
|
1187
|
+
log(`[slice] unhandled: ${e.stack || e.message}`);
|
|
1188
|
+
try {
|
|
1189
|
+
send({
|
|
1190
|
+
type: "slice.failed",
|
|
1191
|
+
error: `sidecar error: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
|
|
1192
|
+
});
|
|
1193
|
+
} catch {}
|
|
889
1194
|
}
|
|
890
|
-
send({ type: "slice.complete" });
|
|
891
1195
|
}
|
|
892
1196
|
|
|
893
1197
|
// --- Shutdown ---------------------------------------------------------------
|
|
@@ -927,8 +1231,17 @@ const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
|
927
1231
|
// Serialize incoming messages — Playwright operations are async and we don't
|
|
928
1232
|
// want concurrent slice handlers stomping on the shared page.
|
|
929
1233
|
let chain = Promise.resolve();
|
|
930
|
-
function enqueue(fn) {
|
|
931
|
-
chain = chain.then(fn).catch((e) =>
|
|
1234
|
+
function enqueue(fn, kind) {
|
|
1235
|
+
chain = chain.then(fn).catch((e) => {
|
|
1236
|
+
log(`[loop] ${e.stack || e.message}`);
|
|
1237
|
+
// Last-resort terminal frame so a bug in the handler can never strand
|
|
1238
|
+
// the Rust parent waiting for a slice to finish.
|
|
1239
|
+
if (kind === "slice") {
|
|
1240
|
+
try {
|
|
1241
|
+
send({ type: "slice.failed", error: `sidecar loop error: ${e.message}` });
|
|
1242
|
+
} catch {}
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
932
1245
|
return chain;
|
|
933
1246
|
}
|
|
934
1247
|
|
|
@@ -954,7 +1267,7 @@ rl.on("line", (line) => {
|
|
|
954
1267
|
});
|
|
955
1268
|
break;
|
|
956
1269
|
case "slice":
|
|
957
|
-
enqueue(() => handleSlice(msg));
|
|
1270
|
+
enqueue(() => handleSlice(msg), "slice");
|
|
958
1271
|
break;
|
|
959
1272
|
case "shutdown":
|
|
960
1273
|
enqueue(shutdown);
|
|
@@ -968,3 +1281,20 @@ rl.on("close", () => {
|
|
|
968
1281
|
// stdin closed — drain pending work then exit.
|
|
969
1282
|
enqueue(shutdown);
|
|
970
1283
|
});
|
|
1284
|
+
|
|
1285
|
+
// If the Rust parent SIGTERMs us (timeout, abort, crash), Node's default is
|
|
1286
|
+
// to exit without running shutdown() — which leaves ffmpeg processes and
|
|
1287
|
+
// Browserbase sessions orphaned. Route signals through the same drain path.
|
|
1288
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
1289
|
+
process.on(sig, () => {
|
|
1290
|
+
log(`[shutdown] received ${sig}`);
|
|
1291
|
+
enqueue(shutdown);
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Log unhandled rejections so a dropped promise doesn't exit the process
|
|
1296
|
+
// silently between slices. The top-level guards in handleSlice / enqueue
|
|
1297
|
+
// cover the hot paths; this catches background work (recording uploads, etc).
|
|
1298
|
+
process.on("unhandledRejection", (reason) => {
|
|
1299
|
+
log(`[unhandledRejection] ${reason?.stack || reason}`);
|
|
1300
|
+
});
|
package/package.json
CHANGED