wb-browser-runtime 0.6.1 → 0.7.0
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 +58 -5
- package/bin/wb-browser-runtime.js +147 -999
- package/lib/http.js +63 -0
- package/lib/io.js +56 -0
- package/lib/providers/browser-use.js +133 -0
- package/lib/providers/browserbase.js +120 -0
- package/lib/providers/index.js +43 -0
- package/lib/recording-manager.js +620 -0
- package/lib/session-manager.js +101 -0
- package/lib/stub-page.js +112 -0
- package/lib/util.js +33 -0
- package/package.json +8 -3
- package/verbs/assert.js +23 -0
- package/verbs/click.js +8 -0
- package/verbs/eval.js +20 -0
- package/verbs/extract.js +38 -0
- package/verbs/fill.js +13 -0
- package/verbs/goto.js +10 -0
- package/verbs/index.js +70 -0
- package/verbs/press.js +9 -0
- package/verbs/save.js +55 -0
- package/verbs/screenshot.js +48 -0
- package/verbs/wait_for.js +13 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
// Recording lifecycle — rrweb DOM capture + CDP screencast video, both
|
|
2
|
+
// uploaded to a consumer endpoint at session close. Feature is off unless
|
|
3
|
+
// `WB_RECORDING_UPLOAD_URL` is set (validated in `loadRecordingConfig`).
|
|
4
|
+
//
|
|
5
|
+
// The manager has two public methods:
|
|
6
|
+
//
|
|
7
|
+
// start(info, sessionName) — installs rrweb via context.addInitScript +
|
|
8
|
+
// exposeBinding, and spawns `ffmpeg` piped
|
|
9
|
+
// from a per-page CDP screencast. Mutates
|
|
10
|
+
// `info.recording` with the per-session state
|
|
11
|
+
// (events buffer, ffmpeg handle, video path).
|
|
12
|
+
//
|
|
13
|
+
// flush(info, sessionName) — final rrweb drain + gzip, stop screencast,
|
|
14
|
+
// wait for ffmpeg to exit, upload both
|
|
15
|
+
// artifacts, then clean up the .webm on disk.
|
|
16
|
+
// Safe to call regardless of whether start()
|
|
17
|
+
// succeeded — returns immediately if there's
|
|
18
|
+
// no `info.recording`.
|
|
19
|
+
//
|
|
20
|
+
// Per-session state lives on `info.recording` (opaque to the main file) so
|
|
21
|
+
// SessionManager's cache stays a plain name -> SessionInfo map. The config
|
|
22
|
+
// (runId, kinds, fps, etc.) is constructor-scoped and shared across
|
|
23
|
+
// sessions — that's intentional, since a single wb process = a single
|
|
24
|
+
// recording stream.
|
|
25
|
+
|
|
26
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
27
|
+
import {
|
|
28
|
+
createReadStream,
|
|
29
|
+
existsSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
promises as fsPromises,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import os from "node:os";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import zlib from "node:zlib";
|
|
38
|
+
import { promisify } from "node:util";
|
|
39
|
+
import { send, log } from "./io.js";
|
|
40
|
+
import { retryableFetch, safeText } from "./http.js";
|
|
41
|
+
|
|
42
|
+
const gzip = promisify(zlib.gzip);
|
|
43
|
+
|
|
44
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const RRWEB_VENDOR_PATH = path.join(
|
|
46
|
+
__dirname,
|
|
47
|
+
"..",
|
|
48
|
+
"vendor",
|
|
49
|
+
"rrweb-record.min.js",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
function checkFfmpeg() {
|
|
53
|
+
try {
|
|
54
|
+
const res = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
|
55
|
+
return res.status === 0;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sanitize(s) {
|
|
62
|
+
return String(s || "default").replace(/[^A-Za-z0-9_-]+/g, "_");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve recording config from environment at boot. Returns either
|
|
66
|
+
// `{ enabled: false, reason }` or a fully populated enabled config. Split
|
|
67
|
+
// out of the class so tests can inspect config without constructing the
|
|
68
|
+
// manager, and so main.js can log the boot banner on enabled without
|
|
69
|
+
// poking the manager's internals.
|
|
70
|
+
export function loadRecordingConfig() {
|
|
71
|
+
const uploadUrl = (process.env.WB_RECORDING_UPLOAD_URL || "").trim();
|
|
72
|
+
if (!uploadUrl) return { enabled: false, reason: "no-upload-url" };
|
|
73
|
+
const secret = (process.env.WB_RECORDING_UPLOAD_SECRET || "").trim();
|
|
74
|
+
if (!secret) {
|
|
75
|
+
log(
|
|
76
|
+
"[recording] WB_RECORDING_UPLOAD_URL is set but WB_RECORDING_UPLOAD_SECRET is empty — refusing to upload unauthenticated. Recording disabled.",
|
|
77
|
+
);
|
|
78
|
+
return { enabled: false, reason: "no-secret" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const runId =
|
|
82
|
+
(process.env.WB_RECORDING_RUN_ID || "").trim() ||
|
|
83
|
+
(process.env.TRIGGER_RUN_ID || "").trim() ||
|
|
84
|
+
`wb-${randomUUID()}`;
|
|
85
|
+
|
|
86
|
+
// Clamp to ranges ffmpeg/libvpx-vp9 actually handles. Requesting fps=120
|
|
87
|
+
// silently blew up memory; quality=0 produced unwatchable garbage. Clamp
|
|
88
|
+
// + log so operators see the effective value.
|
|
89
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
90
|
+
const rawFps =
|
|
91
|
+
Number.parseInt(process.env.WB_RECORDING_SCREENCAST_FPS || "", 10) || 5;
|
|
92
|
+
const rawQuality =
|
|
93
|
+
Number.parseInt(process.env.WB_RECORDING_SCREENCAST_QUALITY || "", 10) ||
|
|
94
|
+
60;
|
|
95
|
+
const fps = clamp(rawFps, 1, 30);
|
|
96
|
+
const quality = clamp(rawQuality, 10, 95);
|
|
97
|
+
if (fps !== rawFps) {
|
|
98
|
+
log(`[recording] fps=${rawFps} clamped to ${fps} (valid range 1..30)`);
|
|
99
|
+
}
|
|
100
|
+
if (quality !== rawQuality) {
|
|
101
|
+
log(
|
|
102
|
+
`[recording] quality=${rawQuality} clamped to ${quality} (valid range 10..95)`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rrwebRequested = process.env.WB_RECORDING_RRWEB !== "0";
|
|
107
|
+
const videoRequested = process.env.WB_RECORDING_VIDEO !== "0";
|
|
108
|
+
|
|
109
|
+
let rrwebSource = null;
|
|
110
|
+
if (rrwebRequested) {
|
|
111
|
+
if (!existsSync(RRWEB_VENDOR_PATH)) {
|
|
112
|
+
log(
|
|
113
|
+
`[recording] rrweb vendor file missing at ${RRWEB_VENDOR_PATH} — disabling rrweb capture`,
|
|
114
|
+
);
|
|
115
|
+
} else {
|
|
116
|
+
rrwebSource = readFileSync(RRWEB_VENDOR_PATH, "utf8");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const hasFfmpeg = videoRequested ? checkFfmpeg() : false;
|
|
121
|
+
if (videoRequested && !hasFfmpeg) {
|
|
122
|
+
log(
|
|
123
|
+
"[recording] ffmpeg not found on $PATH — disabling video capture (rrweb will continue if enabled)",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const kinds = {
|
|
128
|
+
rrweb: rrwebRequested && !!rrwebSource,
|
|
129
|
+
video: videoRequested && hasFfmpeg,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (!kinds.rrweb && !kinds.video) {
|
|
133
|
+
log("[recording] no usable kinds — recording disabled");
|
|
134
|
+
return { enabled: false, reason: "all-kinds-disabled" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rrwebMaxEvents =
|
|
138
|
+
Number.parseInt(process.env.WB_RECORDING_RRWEB_MAX_EVENTS || "", 10) ||
|
|
139
|
+
50_000;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
enabled: true,
|
|
143
|
+
uploadUrl,
|
|
144
|
+
secret,
|
|
145
|
+
runId,
|
|
146
|
+
fps,
|
|
147
|
+
quality,
|
|
148
|
+
kinds,
|
|
149
|
+
rrwebSource,
|
|
150
|
+
rrwebMaxEvents,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class RecordingManager {
|
|
155
|
+
constructor(config) {
|
|
156
|
+
this._config = config;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
get enabled() {
|
|
160
|
+
return !!this._config.enabled;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get runId() {
|
|
164
|
+
return this._config.runId ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get activeKinds() {
|
|
168
|
+
if (!this.enabled) return [];
|
|
169
|
+
return Object.entries(this._config.kinds)
|
|
170
|
+
.filter(([, v]) => v)
|
|
171
|
+
.map(([k]) => k);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get fps() {
|
|
175
|
+
return this._config.fps ?? null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get quality() {
|
|
179
|
+
return this._config.quality ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Per-page setup: inject rrweb, spawn ffmpeg, wire the CDP screencast.
|
|
183
|
+
// No-op when disabled.
|
|
184
|
+
async start(info, sessionName) {
|
|
185
|
+
if (!this.enabled) return;
|
|
186
|
+
const cfg = this._config;
|
|
187
|
+
info.recording = {
|
|
188
|
+
kinds: { ...cfg.kinds },
|
|
189
|
+
rrwebEvents: [],
|
|
190
|
+
rrwebDropped: 0,
|
|
191
|
+
rrwebOverflowLogged: false,
|
|
192
|
+
cdp: null,
|
|
193
|
+
ffmpeg: null,
|
|
194
|
+
ffmpegDone: null,
|
|
195
|
+
videoPath: null,
|
|
196
|
+
};
|
|
197
|
+
const rec = info.recording;
|
|
198
|
+
|
|
199
|
+
// Drop oldest events once the buffer exceeds the cap — keeps the tail
|
|
200
|
+
// of a long run (usually the interesting bit) rather than failing the
|
|
201
|
+
// upload or OOMing the sidecar. One warning per session so ops can
|
|
202
|
+
// spot it.
|
|
203
|
+
const pushRrweb = (e) => {
|
|
204
|
+
if (rec.rrwebEvents.length >= cfg.rrwebMaxEvents) {
|
|
205
|
+
rec.rrwebEvents.shift();
|
|
206
|
+
rec.rrwebDropped++;
|
|
207
|
+
if (!rec.rrwebOverflowLogged) {
|
|
208
|
+
rec.rrwebOverflowLogged = true;
|
|
209
|
+
log(
|
|
210
|
+
`[recording] rrweb buffer hit cap (${cfg.rrwebMaxEvents}); dropping oldest events`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
rec.rrwebEvents.push(e);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (rec.kinds.rrweb) {
|
|
218
|
+
try {
|
|
219
|
+
await info.context.exposeBinding("__wbRrwebFlush", (_src, batch) => {
|
|
220
|
+
if (Array.isArray(batch)) {
|
|
221
|
+
for (const e of batch) pushRrweb(e);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
const bootstrap = `
|
|
225
|
+
;(function(){
|
|
226
|
+
if (window.__wbRrwebActive) return;
|
|
227
|
+
window.__wbRrwebActive = true;
|
|
228
|
+
window.__wbRrwebBuffer = [];
|
|
229
|
+
try {
|
|
230
|
+
rrwebRecord({
|
|
231
|
+
emit: function(event){ window.__wbRrwebBuffer.push(event); },
|
|
232
|
+
sampling: { scroll: 150, media: 800, input: 'last' },
|
|
233
|
+
maskAllInputs: true
|
|
234
|
+
});
|
|
235
|
+
} catch (e) { /* rrweb unavailable on this page (e.g. chrome://) */ }
|
|
236
|
+
var flush = function(){
|
|
237
|
+
var buf = window.__wbRrwebBuffer;
|
|
238
|
+
if (buf && buf.length && typeof window.__wbRrwebFlush === 'function') {
|
|
239
|
+
window.__wbRrwebBuffer = [];
|
|
240
|
+
try { window.__wbRrwebFlush(buf); } catch (e) {}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
setInterval(flush, 500);
|
|
244
|
+
window.addEventListener('beforeunload', flush);
|
|
245
|
+
})();
|
|
246
|
+
`;
|
|
247
|
+
await info.context.addInitScript({
|
|
248
|
+
content: cfg.rrwebSource + "\n" + bootstrap,
|
|
249
|
+
});
|
|
250
|
+
} catch (e) {
|
|
251
|
+
log(`[recording] rrweb setup failed: ${e.message}`);
|
|
252
|
+
rec.kinds.rrweb = false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (rec.kinds.video) {
|
|
257
|
+
try {
|
|
258
|
+
const outPath = path.join(
|
|
259
|
+
os.tmpdir(),
|
|
260
|
+
`wb-video-${sanitize(sessionName)}-${Date.now()}-${process.pid}.webm`,
|
|
261
|
+
);
|
|
262
|
+
rec.videoPath = outPath;
|
|
263
|
+
const ff = spawn(
|
|
264
|
+
"ffmpeg",
|
|
265
|
+
[
|
|
266
|
+
"-hide_banner",
|
|
267
|
+
"-loglevel",
|
|
268
|
+
"warning",
|
|
269
|
+
"-y",
|
|
270
|
+
"-f",
|
|
271
|
+
"image2pipe",
|
|
272
|
+
"-vcodec",
|
|
273
|
+
"mjpeg",
|
|
274
|
+
"-framerate",
|
|
275
|
+
String(cfg.fps),
|
|
276
|
+
"-i",
|
|
277
|
+
"pipe:0",
|
|
278
|
+
"-c:v",
|
|
279
|
+
"libvpx-vp9",
|
|
280
|
+
"-b:v",
|
|
281
|
+
"1M",
|
|
282
|
+
"-deadline",
|
|
283
|
+
"realtime",
|
|
284
|
+
"-pix_fmt",
|
|
285
|
+
"yuv420p",
|
|
286
|
+
outPath,
|
|
287
|
+
],
|
|
288
|
+
{ stdio: ["pipe", "ignore", "pipe"] },
|
|
289
|
+
);
|
|
290
|
+
ff.stderr.on("data", (d) => {
|
|
291
|
+
const s = d.toString().trim();
|
|
292
|
+
if (s) log(`[ffmpeg] ${s.slice(0, 240)}`);
|
|
293
|
+
});
|
|
294
|
+
// Broken pipe on shutdown is normal — swallow it so it doesn't
|
|
295
|
+
// crash the node process via the default 'error' handler.
|
|
296
|
+
ff.stdin.on("error", (e) => {
|
|
297
|
+
if (e.code !== "EPIPE") log(`[ffmpeg stdin] ${e.message}`);
|
|
298
|
+
});
|
|
299
|
+
rec.ffmpeg = ff;
|
|
300
|
+
rec.ffmpegDone = new Promise((resolve) => {
|
|
301
|
+
ff.on("close", (code) => resolve(code));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const cdp = await info.context.newCDPSession(info.page);
|
|
305
|
+
rec.cdp = cdp;
|
|
306
|
+
// Dedup identical consecutive frames. CDP emits repeats when
|
|
307
|
+
// nothing changed on screen; encoding them as distinct frames
|
|
308
|
+
// bloats the WebM and mis-paces playback. Compare the base64
|
|
309
|
+
// string directly — it's cheaper than hashing and equivalent for
|
|
310
|
+
// exact equality.
|
|
311
|
+
let lastFrameData = null;
|
|
312
|
+
let dedupCount = 0;
|
|
313
|
+
let dedupLogged = false;
|
|
314
|
+
|
|
315
|
+
cdp.on("Page.screencastFrame", async (frame) => {
|
|
316
|
+
try {
|
|
317
|
+
if (ff.stdin.writable && !ff.killed) {
|
|
318
|
+
if (frame.data === lastFrameData) {
|
|
319
|
+
dedupCount++;
|
|
320
|
+
if (!dedupLogged && dedupCount >= 100) {
|
|
321
|
+
dedupLogged = true;
|
|
322
|
+
log(
|
|
323
|
+
`[recording] dedup active (${dedupCount} duplicate frames skipped so far)`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
// Still ack — Chrome needs it to keep streaming.
|
|
327
|
+
await cdp.send("Page.screencastFrameAck", {
|
|
328
|
+
sessionId: frame.sessionId,
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
lastFrameData = frame.data;
|
|
333
|
+
const buf = Buffer.from(frame.data, "base64");
|
|
334
|
+
const ok = ff.stdin.write(buf);
|
|
335
|
+
// Backpressure: if ffmpeg's stdin buffer is full, wait for
|
|
336
|
+
// drain before acking so Chrome slows frame production
|
|
337
|
+
// instead of piling JPEG frames in Node heap. 5s fail-open
|
|
338
|
+
// so a wedged ffmpeg can't stall the protocol indefinitely.
|
|
339
|
+
if (!ok) {
|
|
340
|
+
await new Promise((resolve) => {
|
|
341
|
+
let fired = false;
|
|
342
|
+
const done = () => {
|
|
343
|
+
if (fired) return;
|
|
344
|
+
fired = true;
|
|
345
|
+
ff.stdin.off("drain", done);
|
|
346
|
+
ff.stdin.off("close", done);
|
|
347
|
+
ff.stdin.off("error", done);
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
resolve();
|
|
350
|
+
};
|
|
351
|
+
const timer = setTimeout(done, 5000);
|
|
352
|
+
ff.stdin.once("drain", done);
|
|
353
|
+
ff.stdin.once("close", done);
|
|
354
|
+
ff.stdin.once("error", done);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Must ack each frame or Chrome stops streaming.
|
|
359
|
+
await cdp.send("Page.screencastFrameAck", {
|
|
360
|
+
sessionId: frame.sessionId,
|
|
361
|
+
});
|
|
362
|
+
} catch {
|
|
363
|
+
// Session tearing down — safe to ignore.
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
await cdp.send("Page.startScreencast", {
|
|
367
|
+
format: "jpeg",
|
|
368
|
+
quality: cfg.quality,
|
|
369
|
+
everyNthFrame: 1,
|
|
370
|
+
});
|
|
371
|
+
} catch (e) {
|
|
372
|
+
log(`[recording] video setup failed: ${e.message}`);
|
|
373
|
+
rec.kinds.video = false;
|
|
374
|
+
if (rec.ffmpeg) {
|
|
375
|
+
try {
|
|
376
|
+
rec.ffmpeg.kill();
|
|
377
|
+
} catch {}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const active = Object.entries(rec.kinds)
|
|
383
|
+
.filter(([, v]) => v)
|
|
384
|
+
.map(([k]) => k);
|
|
385
|
+
if (active.length) {
|
|
386
|
+
send({
|
|
387
|
+
type: "slice.recording.started",
|
|
388
|
+
session: sessionName,
|
|
389
|
+
run_id: cfg.runId,
|
|
390
|
+
kinds: active,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async flush(info, sessionName) {
|
|
396
|
+
if (!info.recording) return;
|
|
397
|
+
const cfg = this._config;
|
|
398
|
+
const rec = info.recording;
|
|
399
|
+
|
|
400
|
+
let rrwebBody = null;
|
|
401
|
+
let rrwebFailure = null;
|
|
402
|
+
if (rec.kinds.rrweb) {
|
|
403
|
+
try {
|
|
404
|
+
const tail = await info.page.evaluate(() => {
|
|
405
|
+
if (!Array.isArray(window.__wbRrwebBuffer)) return [];
|
|
406
|
+
const out = window.__wbRrwebBuffer;
|
|
407
|
+
window.__wbRrwebBuffer = [];
|
|
408
|
+
return out;
|
|
409
|
+
});
|
|
410
|
+
if (Array.isArray(tail)) {
|
|
411
|
+
for (const e of tail) {
|
|
412
|
+
if (rec.rrwebEvents.length >= cfg.rrwebMaxEvents) {
|
|
413
|
+
rec.rrwebEvents.shift();
|
|
414
|
+
rec.rrwebDropped++;
|
|
415
|
+
}
|
|
416
|
+
rec.rrwebEvents.push(e);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (e) {
|
|
420
|
+
log(`[recording] rrweb final drain failed: ${e.message}`);
|
|
421
|
+
rrwebFailure = `final_drain_error: ${e.message}`;
|
|
422
|
+
}
|
|
423
|
+
if (rec.rrwebEvents.length > 0) {
|
|
424
|
+
try {
|
|
425
|
+
const json = JSON.stringify({
|
|
426
|
+
run_id: cfg.runId,
|
|
427
|
+
session: sessionName,
|
|
428
|
+
event_count: rec.rrwebEvents.length,
|
|
429
|
+
dropped: rec.rrwebDropped,
|
|
430
|
+
events: rec.rrwebEvents,
|
|
431
|
+
});
|
|
432
|
+
rrwebBody = await gzip(Buffer.from(json, "utf8"));
|
|
433
|
+
} catch (e) {
|
|
434
|
+
log(`[recording] rrweb gzip failed: ${e.message}`);
|
|
435
|
+
rrwebFailure = `gzip_error: ${e.message}`;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let videoBody = null;
|
|
441
|
+
let videoFailure = null;
|
|
442
|
+
if (rec.kinds.video && rec.cdp && rec.ffmpeg) {
|
|
443
|
+
try {
|
|
444
|
+
await rec.cdp.send("Page.stopScreencast");
|
|
445
|
+
} catch {
|
|
446
|
+
// Browser may already be tearing down.
|
|
447
|
+
}
|
|
448
|
+
const timeoutMs =
|
|
449
|
+
Number.parseInt(
|
|
450
|
+
process.env.WB_RECORDING_FFMPEG_TIMEOUT_MS || "",
|
|
451
|
+
10,
|
|
452
|
+
) || 30_000;
|
|
453
|
+
try {
|
|
454
|
+
rec.ffmpeg.stdin.end();
|
|
455
|
+
const settled = await Promise.race([
|
|
456
|
+
rec.ffmpegDone,
|
|
457
|
+
new Promise((r) =>
|
|
458
|
+
setTimeout(() => r({ __timeout: true }), timeoutMs),
|
|
459
|
+
),
|
|
460
|
+
]);
|
|
461
|
+
if (settled && typeof settled === "object" && settled.__timeout) {
|
|
462
|
+
log(
|
|
463
|
+
`[recording] ffmpeg did not exit within ${timeoutMs}ms; killing`,
|
|
464
|
+
);
|
|
465
|
+
try {
|
|
466
|
+
rec.ffmpeg.kill("SIGKILL");
|
|
467
|
+
} catch {}
|
|
468
|
+
videoFailure = `ffmpeg_timeout_${timeoutMs}ms`;
|
|
469
|
+
} else if (typeof settled === "number" && settled !== 0) {
|
|
470
|
+
// ff.on('close') resolves with the exit code — non-zero means
|
|
471
|
+
// ffmpeg produced a corrupt/partial webm that we should not
|
|
472
|
+
// upload.
|
|
473
|
+
videoFailure = `ffmpeg_exit_code_${settled}`;
|
|
474
|
+
log(`[recording] ffmpeg exited with code ${settled}`);
|
|
475
|
+
}
|
|
476
|
+
if (!videoFailure && rec.videoPath && existsSync(rec.videoPath)) {
|
|
477
|
+
// Stream the WebM off disk instead of buffering — a long slice
|
|
478
|
+
// can produce hundreds of MB and slurping it into RAM just to
|
|
479
|
+
// fetch() is the largest memory hit in this process. The
|
|
480
|
+
// factory produces a fresh ReadStream per retry attempt.
|
|
481
|
+
// cleanup() is deferred to _uploadArtifact's finally so the file
|
|
482
|
+
// survives until upload settles.
|
|
483
|
+
const stat = await fsPromises.stat(rec.videoPath);
|
|
484
|
+
const videoPath = rec.videoPath;
|
|
485
|
+
videoBody = {
|
|
486
|
+
factory: () => createReadStream(videoPath),
|
|
487
|
+
bytes: stat.size,
|
|
488
|
+
cleanup: () => fsPromises.unlink(videoPath).catch(() => {}),
|
|
489
|
+
};
|
|
490
|
+
} else if (rec.videoPath && existsSync(rec.videoPath)) {
|
|
491
|
+
// No upload path (failure or skip) — clean up the file now so
|
|
492
|
+
// we don't leak disk. Upload path cleans up in _uploadArtifact's
|
|
493
|
+
// finally.
|
|
494
|
+
try {
|
|
495
|
+
await fsPromises.unlink(rec.videoPath);
|
|
496
|
+
} catch {}
|
|
497
|
+
}
|
|
498
|
+
} catch (e) {
|
|
499
|
+
videoFailure = `finalize_error: ${e.message}`;
|
|
500
|
+
log(`[recording] video finalize failed: ${e.message}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const uploads = [];
|
|
505
|
+
if (rrwebBody) {
|
|
506
|
+
uploads.push(
|
|
507
|
+
this._uploadArtifact(
|
|
508
|
+
"rrweb",
|
|
509
|
+
rrwebBody,
|
|
510
|
+
"application/json+gzip",
|
|
511
|
+
sessionName,
|
|
512
|
+
{ event_count: rec.rrwebEvents.length },
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
} else if (rrwebFailure) {
|
|
516
|
+
// Surface pre-upload failures so consumers can distinguish "rrweb
|
|
517
|
+
// recorded nothing" from "rrweb recorded and we lost it".
|
|
518
|
+
send({
|
|
519
|
+
type: "slice.recording.failed",
|
|
520
|
+
session: sessionName,
|
|
521
|
+
run_id: cfg.runId,
|
|
522
|
+
kind: "rrweb",
|
|
523
|
+
reason: rrwebFailure,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (videoBody) {
|
|
527
|
+
uploads.push(
|
|
528
|
+
this._uploadArtifact("video", videoBody, "video/webm", sessionName, {
|
|
529
|
+
fps: cfg.fps,
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
} else if (videoFailure) {
|
|
533
|
+
// Surface a terminal recording failure to the callback stream so
|
|
534
|
+
// the consumer knows the video was lost rather than silently
|
|
535
|
+
// missing.
|
|
536
|
+
send({
|
|
537
|
+
type: "slice.recording.failed",
|
|
538
|
+
session: sessionName,
|
|
539
|
+
run_id: cfg.runId,
|
|
540
|
+
kind: "video",
|
|
541
|
+
reason: videoFailure,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
await Promise.allSettled(uploads);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// `body` is either:
|
|
548
|
+
// Buffer — legacy; reused across retries
|
|
549
|
+
// { factory, bytes, cleanup? } — streaming; factory() returns a
|
|
550
|
+
// fresh Readable per attempt so
|
|
551
|
+
// retries get a new stream instead
|
|
552
|
+
// of a drained one. cleanup() (if
|
|
553
|
+
// provided) runs after the upload
|
|
554
|
+
// settles, success or failure.
|
|
555
|
+
async _uploadArtifact(kind, body, contentType, sessionName, extra) {
|
|
556
|
+
const cfg = this._config;
|
|
557
|
+
const isStream =
|
|
558
|
+
!Buffer.isBuffer(body) && typeof body?.factory === "function";
|
|
559
|
+
const bytes = isStream ? body.bytes : body.length;
|
|
560
|
+
const cleanup = isStream ? body.cleanup : null;
|
|
561
|
+
const url = cfg.uploadUrl
|
|
562
|
+
.replace("{run_id}", encodeURIComponent(cfg.runId))
|
|
563
|
+
.replace("{kind}", encodeURIComponent(kind));
|
|
564
|
+
try {
|
|
565
|
+
const res = await retryableFetch(
|
|
566
|
+
url,
|
|
567
|
+
{
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: {
|
|
570
|
+
Authorization: `Bearer ${cfg.secret}`,
|
|
571
|
+
"Content-Type": contentType,
|
|
572
|
+
"X-WB-Run-Id": cfg.runId,
|
|
573
|
+
"X-WB-Recording-Kind": kind,
|
|
574
|
+
"X-WB-Session": sessionName,
|
|
575
|
+
...(isStream ? { "Content-Length": String(bytes) } : {}),
|
|
576
|
+
},
|
|
577
|
+
body: isStream ? undefined : body,
|
|
578
|
+
},
|
|
579
|
+
`upload.${kind}`,
|
|
580
|
+
{
|
|
581
|
+
timeoutMs: 30_000,
|
|
582
|
+
bodyFactory: isStream ? body.factory : null,
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
if (!res.ok) {
|
|
586
|
+
send({
|
|
587
|
+
type: "slice.recording.failed",
|
|
588
|
+
session: sessionName,
|
|
589
|
+
run_id: cfg.runId,
|
|
590
|
+
kind,
|
|
591
|
+
status: res.status,
|
|
592
|
+
reason: (await safeText(res)) || res.statusText || "upload rejected",
|
|
593
|
+
});
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
send({
|
|
597
|
+
type: "slice.recording.uploaded",
|
|
598
|
+
session: sessionName,
|
|
599
|
+
run_id: cfg.runId,
|
|
600
|
+
kind,
|
|
601
|
+
bytes,
|
|
602
|
+
...(extra || {}),
|
|
603
|
+
});
|
|
604
|
+
} catch (e) {
|
|
605
|
+
send({
|
|
606
|
+
type: "slice.recording.failed",
|
|
607
|
+
session: sessionName,
|
|
608
|
+
run_id: cfg.runId,
|
|
609
|
+
kind,
|
|
610
|
+
reason: e.name === "AbortError" ? "timeout" : e.message,
|
|
611
|
+
});
|
|
612
|
+
} finally {
|
|
613
|
+
if (cleanup) {
|
|
614
|
+
try {
|
|
615
|
+
await cleanup();
|
|
616
|
+
} catch {}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|