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.
@@ -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
+ }