@vitrinka/web 0.1.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +93 -0
  3. package/README.md +177 -0
  4. package/build/index.d.ts +11 -0
  5. package/build/index.js +1 -0
  6. package/build/next.d.ts +23 -0
  7. package/build/next.js +27 -0
  8. package/build/protocol/index.d.ts +74 -0
  9. package/build/protocol/index.js +22 -0
  10. package/build/recorder/RecorderProvider.d.ts +17 -0
  11. package/build/recorder/RecorderProvider.js +126 -0
  12. package/build/recorder/api-status.d.ts +6 -0
  13. package/build/recorder/api-status.js +6 -0
  14. package/build/recorder/api.d.ts +40 -0
  15. package/build/recorder/api.js +84 -0
  16. package/build/recorder/capture/click.d.ts +17 -0
  17. package/build/recorder/capture/click.js +77 -0
  18. package/build/recorder/capture/console.d.ts +3 -0
  19. package/build/recorder/capture/console.js +89 -0
  20. package/build/recorder/capture/nav.d.ts +8 -0
  21. package/build/recorder/capture/nav.js +54 -0
  22. package/build/recorder/capture/net.d.ts +22 -0
  23. package/build/recorder/capture/net.js +506 -0
  24. package/build/recorder/capture/redact.d.ts +38 -0
  25. package/build/recorder/capture/redact.js +54 -0
  26. package/build/recorder/capture/rrweb.d.ts +10 -0
  27. package/build/recorder/capture/rrweb.js +76 -0
  28. package/build/recorder/config.d.ts +50 -0
  29. package/build/recorder/config.js +100 -0
  30. package/build/recorder/control.d.ts +29 -0
  31. package/build/recorder/control.js +63 -0
  32. package/build/recorder/hud/AnnotateOverlay.d.ts +25 -0
  33. package/build/recorder/hud/AnnotateOverlay.js +122 -0
  34. package/build/recorder/hud/Hud.d.ts +12 -0
  35. package/build/recorder/hud/Hud.js +190 -0
  36. package/build/recorder/hud/LinkSheet.d.ts +26 -0
  37. package/build/recorder/hud/LinkSheet.js +15 -0
  38. package/build/recorder/hud/RecorderPill.d.ts +36 -0
  39. package/build/recorder/hud/RecorderPill.js +73 -0
  40. package/build/recorder/hud/Sheet.d.ts +20 -0
  41. package/build/recorder/hud/Sheet.js +36 -0
  42. package/build/recorder/hud/host.d.ts +27 -0
  43. package/build/recorder/hud/host.js +170 -0
  44. package/build/recorder/hud/icons.d.ts +15 -0
  45. package/build/recorder/hud/icons.js +40 -0
  46. package/build/recorder/hud/styles.d.ts +13 -0
  47. package/build/recorder/hud/styles.js +111 -0
  48. package/build/recorder/index.d.ts +46 -0
  49. package/build/recorder/index.js +61 -0
  50. package/build/recorder/link.d.ts +18 -0
  51. package/build/recorder/link.js +37 -0
  52. package/build/recorder/queue.d.ts +163 -0
  53. package/build/recorder/queue.js +642 -0
  54. package/build/recorder/session.d.ts +73 -0
  55. package/build/recorder/session.js +246 -0
  56. package/build/recorder/state.d.ts +26 -0
  57. package/build/recorder/state.js +42 -0
  58. package/build/recorder/storage/index.d.ts +35 -0
  59. package/build/recorder/storage/index.js +69 -0
  60. package/build/recorder/storage/memory.d.ts +2 -0
  61. package/build/recorder/storage/memory.js +2 -0
  62. package/package.json +77 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Network capture — JS-level fetch + XHR monkey-patch (design decision).
3
+ *
4
+ * A consuming app's API client may ride axios, which uses XHR — that patch
5
+ * carries the API waterfall. The fetch patch covers manual fetches. Bodies
6
+ * are captured whole up to BODY_CAP (D7 capture-everything: recording is
7
+ * opt-in per session and the tester chooses what to record; nothing leaves the
8
+ * device except to the configured vitrinka host); native-level requests (image
9
+ * loads, native SDKs) are out of scope.
10
+ *
11
+ * Recorder's own vitrinka traffic is excluded to avoid feedback loops.
12
+ * Patches observe only — errors in capture code must never break the app's
13
+ * request, and responses are never consumed (XHR reads responseText after
14
+ * completion; fetch clones).
15
+ */
16
+ import { isVitrinkaUrl } from '../config';
17
+ import { getState, pushEvent, trackCapture } from '../queue';
18
+ import { currentRoute } from '../state';
19
+ import { redactAndCap, redactHeaders, redactUrl } from './redact';
20
+ const BODY_CAP = 64 * 1024;
21
+ /**
22
+ * Responses larger than this are recorded WITHOUT their body. `capBody` can
23
+ * only slice a string that was already materialized, so the cap alone does not
24
+ * bound decode/allocation work — the length check does.
25
+ */
26
+ const BODY_READ_LIMIT = 512 * 1024;
27
+ /**
28
+ * Wall-clock bound on a single body read. Keeps a streaming/stalled response
29
+ * from pinning a capture (and therefore Stop) open indefinitely.
30
+ */
31
+ let BODY_READ_DEADLINE_MS = 3000;
32
+ /** Test-only: shorten the read deadline so suites don't burn real seconds. */
33
+ export function __setBodyReadDeadlineForTests(ms) {
34
+ const prev = BODY_READ_DEADLINE_MS;
35
+ BODY_READ_DEADLINE_MS = ms;
36
+ return () => {
37
+ BODY_READ_DEADLINE_MS = prev;
38
+ };
39
+ }
40
+ /** Is a session actively capturing right now? Gates all body work. */
41
+ function capturing() {
42
+ return activeSessionId() !== null;
43
+ }
44
+ /** Id of the capturing session, else null (paused AND dead count as null). */
45
+ function activeSessionId() {
46
+ const rec = getState();
47
+ return rec !== null && !rec.paused && !rec.dead ? rec.sessionId : null;
48
+ }
49
+ /**
50
+ * Is `id` STILL the capturing session? In-flight requests must be bound to the
51
+ * session they started in: a request begun in session A that completes after A
52
+ * stopped and B started would otherwise be appended to B, with B's route
53
+ * attached.
54
+ */
55
+ function stillCapturing(id) {
56
+ return id !== null && activeSessionId() === id;
57
+ }
58
+ /**
59
+ * Redact + cap in the shape-appropriate ORDER — see `redactAndCap`
60
+ *.
61
+ */
62
+ function capBody(body, contentType) {
63
+ if (typeof body === 'string')
64
+ return redactAndCap(body, BODY_CAP, contentType);
65
+ // URLSearchParams is a form body by construction — serialize it and let the
66
+ // engine's form transform scrub it like any other string.
67
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
68
+ return redactAndCap(body.toString(), BODY_CAP, 'application/x-www-form-urlencoded');
69
+ }
70
+ return describeOpaqueBody(body);
71
+ }
72
+ /**
73
+ * Non-string bodies are recorded as a TYPED PLACEHOLDER, never their bytes:
74
+ * a FormData routinely carries files and credentials and cannot be scrubbed
75
+ * without parsing it; a Blob/ArrayBuffer is binary. The placeholder keeps
76
+ * the timeline honest (a body WAS sent, this shape, this size) without the
77
+ * content.
78
+ */
79
+ export function describeOpaqueBody(body) {
80
+ if (body == null)
81
+ return undefined;
82
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
83
+ let n = 0;
84
+ try {
85
+ body.forEach(() => n++);
86
+ }
87
+ catch {
88
+ // a stubbed FormData without forEach — count unknown
89
+ }
90
+ return `[formdata body omitted: ${n} field(s)]`;
91
+ }
92
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
93
+ return `[blob body omitted: ${body.size} bytes${body.type ? `, ${body.type}` : ''}]`;
94
+ }
95
+ if (body instanceof ArrayBuffer)
96
+ return `[buffer body omitted: ${body.byteLength} bytes]`;
97
+ if (ArrayBuffer.isView(body))
98
+ return `[buffer body omitted: ${body.byteLength} bytes]`;
99
+ if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
100
+ return '[stream body omitted]';
101
+ }
102
+ return `[${typeof body} body omitted]`;
103
+ }
104
+ /** Content-type from a raw (pre-redaction) header object, '' when absent. */
105
+ function ctOf(h) {
106
+ for (const [k, v] of Object.entries(h ?? {})) {
107
+ if (k.toLowerCase().replace(/[-_]/g, '') === 'contenttype')
108
+ return v;
109
+ }
110
+ return '';
111
+ }
112
+ /** Narrow a RequestInfo to its object form for property access. */
113
+ function init0(input) {
114
+ return typeof input === 'string' || input instanceof URL ? {} : input;
115
+ }
116
+ function recordNet(payload) {
117
+ // Query strings carry secrets too (?token=…, ?otp=…) — redact the URL
118
+ // centrally, with the engine's dedicated URL scrub (query AND fragment,
119
+ // split on both `&` and `;`).
120
+ const url = typeof payload.url === 'string' ? redactUrl(payload.url) : payload.url;
121
+ pushEvent('net', { ...payload, url }, { tabId: currentRoute.tabId, tabHost: currentRoute.tabHost });
122
+ }
123
+ /**
124
+ * Normalize any HeadersInit shape (Headers, pair array, plain record) into a
125
+ * plain object for the redaction engine. Returns undefined when there is
126
+ * nothing to record; never throws — header capture must not break a request.
127
+ */
128
+ function headersToObject(h) {
129
+ if (!h)
130
+ return undefined;
131
+ try {
132
+ const out = {};
133
+ const headers = h;
134
+ if (typeof headers.forEach === 'function') {
135
+ // Headers instance (also covers Maps and arrays via their forEach shapes
136
+ // differing — arrays are handled below instead).
137
+ if (Array.isArray(h)) {
138
+ for (const pair of h) {
139
+ if (Array.isArray(pair) && pair.length >= 2)
140
+ out[String(pair[0])] = String(pair[1]);
141
+ }
142
+ }
143
+ else {
144
+ headers.forEach((value, key) => {
145
+ out[key] = String(value);
146
+ });
147
+ }
148
+ }
149
+ else if (typeof h === 'object') {
150
+ for (const [k, v] of Object.entries(h)) {
151
+ out[k] = String(v);
152
+ }
153
+ }
154
+ return Object.keys(out).length > 0 ? out : undefined;
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ }
160
+ /** RAW request headers for a fetch call (init wins over Request) — redact before recording. */
161
+ function rawFetchReqHeaders(input, init) {
162
+ const fromInit = headersToObject(init?.headers);
163
+ if (fromInit)
164
+ return fromInit;
165
+ const obj = init0(input);
166
+ return headersToObject(obj.headers);
167
+ }
168
+ /** Parse XHR's getAllResponseHeaders() CRLF block into a plain object. */
169
+ function parseRawHeaders(raw) {
170
+ if (!raw)
171
+ return undefined;
172
+ const out = {};
173
+ for (const line of raw.split(/\r?\n/)) {
174
+ const i = line.indexOf(':');
175
+ if (i > 0)
176
+ out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
177
+ }
178
+ return Object.keys(out).length > 0 ? out : undefined;
179
+ }
180
+ /**
181
+ * Release a clone we are not going to read. Every early return MUST call this:
182
+ * an abandoned clone keeps its tee branch buffering the entire response
183
+ *.
184
+ */
185
+ function discard(clone) {
186
+ try {
187
+ const body = clone.body;
188
+ void body?.cancel?.().catch(() => undefined);
189
+ }
190
+ catch {
191
+ // nothing to release
192
+ }
193
+ }
194
+ /** Declared body size when the server sent one, else null. */
195
+ function declaredLength(headers) {
196
+ const raw = headers.get('content-length');
197
+ if (!raw)
198
+ return null;
199
+ const n = Number(raw);
200
+ return Number.isFinite(n) ? n : null;
201
+ }
202
+ /**
203
+ * Read a response body with a HARD byte bound in every path.
204
+ *
205
+ * - declared length over the limit → never read at all;
206
+ * - streamable body → read chunk-by-chunk and stop at the limit, so a chunked
207
+ * or compressed (length-less) response can no longer buffer without bound;
208
+ * - no stream support and no declared length → omit the body rather than risk
209
+ * an unbounded `text()`.
210
+ *
211
+ * Always reads from a CLONE, so the app's own consumption of `res` is untouched.
212
+ */
213
+ async function readBoundedBody(clone, headers) {
214
+ const raw = await readBoundedText(clone, headers);
215
+ if (raw === undefined)
216
+ return undefined;
217
+ let ct = '';
218
+ try {
219
+ ct = headers.get('content-type') ?? '';
220
+ }
221
+ catch {
222
+ // stubbed Headers without get() — the engine falls back to shape sniffing
223
+ }
224
+ return capBody(raw, ct);
225
+ }
226
+ /**
227
+ * The BOUNDED read itself, returning raw text. Split from the capping wrapper so
228
+ * the bound can be asserted directly: every observable path in `readBoundedBody`
229
+ * runs through `capBody`, which caps to 64 KiB regardless of how much was read —
230
+ * a test on the recorded body cannot tell a bounded read from an unbounded one
231
+ *.
232
+ */
233
+ async function readBoundedText(clone, headers) {
234
+ const size = declaredLength(headers);
235
+ if (size !== null && size > BODY_READ_LIMIT) {
236
+ // Abandoning the clone unread leaves its tee branch buffering the whole
237
+ // body in memory — cancel it.
238
+ discard(clone);
239
+ return `[body omitted: ${size} bytes]`;
240
+ }
241
+ const stream = clone.body;
242
+ if (stream?.getReader) {
243
+ const reader = stream.getReader();
244
+ const chunks = [];
245
+ let total = 0;
246
+ let truncated = false;
247
+ let stalled = false;
248
+ const readDeadline = Date.now() + BODY_READ_DEADLINE_MS;
249
+ try {
250
+ while (total < BODY_READ_LIMIT) {
251
+ // A long-lived stream (SSE/NDJSON) or a stalled connection reaches
252
+ // neither EOF nor the byte bound, which would keep this capture — and
253
+ // therefore Stop — waiting indefinitely.
254
+ const left = readDeadline - Date.now();
255
+ if (left <= 0) {
256
+ stalled = true;
257
+ break;
258
+ }
259
+ // The deadline timer MUST be cleared when the read wins, or every loop
260
+ // iteration leaves a live timer behind — a length-less response yielding
261
+ // tiny chunks could strand hundreds of thousands of them
262
+ //.
263
+ let timer;
264
+ const next = await Promise.race([
265
+ reader.read(),
266
+ new Promise((res) => {
267
+ timer = setTimeout(() => res('timeout'), left);
268
+ }),
269
+ ]).finally(() => {
270
+ if (timer !== undefined)
271
+ clearTimeout(timer);
272
+ });
273
+ if (next === 'timeout') {
274
+ stalled = true;
275
+ break;
276
+ }
277
+ const { done, value } = next;
278
+ if (done)
279
+ break;
280
+ if (value) {
281
+ // Slice to the REMAINING allowance: a single multi-megabyte chunk
282
+ // would otherwise push total (and the joined allocation) arbitrarily
283
+ // past the advertised bound.
284
+ const room = BODY_READ_LIMIT - total;
285
+ const part = value.byteLength > room ? value.subarray(0, room) : value;
286
+ chunks.push(part);
287
+ total += part.byteLength;
288
+ }
289
+ }
290
+ // Stop pulling once the bound is hit; the rest of the body is discarded.
291
+ truncated = total >= BODY_READ_LIMIT;
292
+ void reader.cancel().catch(() => undefined);
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ const joined = new Uint8Array(total);
298
+ let at = 0;
299
+ for (const c of chunks) {
300
+ joined.set(c, at);
301
+ at += c.byteLength;
302
+ }
303
+ let text;
304
+ try {
305
+ text = new TextDecoder().decode(joined);
306
+ }
307
+ catch {
308
+ return `[body omitted: ${total} bytes, undecodable]`;
309
+ }
310
+ if (stalled)
311
+ return `${text}…[body read timed out after ${BODY_READ_DEADLINE_MS}ms]`;
312
+ return truncated ? `${text}…[truncated at ${BODY_READ_LIMIT} bytes]` : text;
313
+ }
314
+ // No stream API (RN's fetch polyfill): only safe when the length is known.
315
+ if (size === null) {
316
+ discard(clone);
317
+ return '[body omitted: length-less response, no stream API]';
318
+ }
319
+ try {
320
+ return await clone.text();
321
+ }
322
+ catch {
323
+ return undefined;
324
+ }
325
+ }
326
+ /** Test-only: the RAW bounded read, before capping — see `readBoundedText`. */
327
+ export function __readBoundedTextForTests(clone, headers) {
328
+ return readBoundedText(clone, headers);
329
+ }
330
+ /**
331
+ * The patched flag lives on globalThis, NOT in module scope: Fast Refresh can
332
+ * re-evaluate this module, which would reset a module-level boolean and stack a
333
+ * second patch on top of the first — double-recording every request and
334
+ * unbounded wrapper nesting across reloads. Keyed by a
335
+ * string so a re-evaluated module sees the previous evaluation's mark.
336
+ */
337
+ const PATCH_MARK = '__vitrinkaRecorderNetPatched';
338
+ /** The uninstaller of the live patch, kept on globalThis for the same reason as the mark. */
339
+ const UNPATCH_MARK = '__vitrinkaRecorderNetUnpatch';
340
+ /**
341
+ * Remove the fetch/XHR wrappers installed by `patchNetwork` — the provider's
342
+ * unmount calls it so the recorder never outlives its tree. Each global is
343
+ * restored only while it is STILL our wrapper: a later patch by someone else
344
+ * (a devtools, an APM agent) stacked on top must not be torn out from under
345
+ * them, so such a global is left in place and only our capture goes quiet
346
+ * (no session ⇒ the wrapper passes straight through).
347
+ */
348
+ export function unpatchNetwork() {
349
+ const g = globalThis;
350
+ g[UNPATCH_MARK]?.();
351
+ }
352
+ export function patchNetwork() {
353
+ const g = globalThis;
354
+ if (g[PATCH_MARK])
355
+ return;
356
+ g[PATCH_MARK] = true;
357
+ const restores = [];
358
+ g[UNPATCH_MARK] = () => {
359
+ for (const r of restores.splice(0))
360
+ r();
361
+ delete g[PATCH_MARK];
362
+ delete g[UNPATCH_MARK];
363
+ };
364
+ const origFetch = globalThis.fetch;
365
+ const wrappedFetch = async (input, init) => {
366
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
367
+ // Not recording (or recorder's own traffic) → pass straight through: no
368
+ // clone, no body read, zero added cost.
369
+ const origin = activeSessionId();
370
+ if (isVitrinkaUrl(url) || origin === null)
371
+ return origFetch(input, init);
372
+ const method = init?.method ??
373
+ ('method' in init0(input) ? init0(input).method : 'GET');
374
+ const started = Date.now();
375
+ try {
376
+ const res = await origFetch(input, init);
377
+ const ms = Date.now() - started;
378
+ // Clone SYNCHRONOUSLY (before the caller can consume the body), then read
379
+ // and record OFF the caller's critical path: awaiting the body read here
380
+ // delayed the app's own `await fetch(...)` by however long the recorder
381
+ // took to pull up to 512 KiB.
382
+ let clone;
383
+ try {
384
+ clone = res.clone();
385
+ }
386
+ catch {
387
+ clone = undefined; // opaque/consumed body — never disturb the original
388
+ }
389
+ const headers = res.headers;
390
+ const status = res.status;
391
+ trackCapture((async () => {
392
+ // Re-check AFTER the await, and require the SAME session — the run may
393
+ // have been paused/stopped meanwhile.
394
+ if (!stillCapturing(origin))
395
+ return;
396
+ const resBody = clone ? await readBoundedBody(clone, headers) : undefined;
397
+ if (!stillCapturing(origin))
398
+ return;
399
+ const rawReqHeaders = rawFetchReqHeaders(input, init);
400
+ recordNet({
401
+ method,
402
+ url,
403
+ status,
404
+ ms,
405
+ reqHeaders: redactHeaders(rawReqHeaders),
406
+ resHeaders: redactHeaders(headersToObject(headers)),
407
+ reqBody: capBody(init?.body, ctOf(rawReqHeaders)),
408
+ resBody,
409
+ via: 'fetch',
410
+ });
411
+ })());
412
+ return res;
413
+ }
414
+ catch (e) {
415
+ if (stillCapturing(origin)) {
416
+ recordNet({ method, url, error: String(e), ms: Date.now() - started, via: 'fetch' });
417
+ }
418
+ throw e;
419
+ }
420
+ };
421
+ // Bun/undici type `fetch` with static extras (preconnect) the wrapper does
422
+ // not carry; the runtime contract is the call signature alone.
423
+ globalThis.fetch = wrappedFetch;
424
+ restores.push(() => {
425
+ if (globalThis.fetch === wrappedFetch)
426
+ globalThis.fetch = origFetch;
427
+ });
428
+ const XHR = globalThis.XMLHttpRequest;
429
+ // A runtime without XHR (or a stubbed one) must not take the recorder down —
430
+ // patchNetwork() runs during provider mount, so a throw here would break the
431
+ // whole app tree, not just capture.
432
+ if (!XHR?.prototype)
433
+ return;
434
+ const origOpen = XHR.prototype.open;
435
+ const origSend = XHR.prototype.send;
436
+ const origSetHeader = XHR.prototype.setRequestHeader;
437
+ restores.push(() => {
438
+ const p = XHR.prototype;
439
+ if (p.open === patchedOpen)
440
+ p.open = origOpen;
441
+ if (p.send === patchedSend)
442
+ p.send = origSend;
443
+ if (p.setRequestHeader === patchedSetHeader)
444
+ p.setRequestHeader = origSetHeader;
445
+ });
446
+ const patchedOpen = (XHR.prototype.open = function (...args) {
447
+ this.__vt = {
448
+ method: args[0],
449
+ url: String(args[1]),
450
+ };
451
+ return origOpen.apply(this, args);
452
+ });
453
+ // Request headers are only observable at the call site — record them as the
454
+ // app sets them (redaction happens at event build, against the live rules).
455
+ // The original may be absent on a stubbed XHR; observing must survive that.
456
+ const patchedSetHeader = (XHR.prototype.setRequestHeader = function (name, value) {
457
+ const meta = this.__vt;
458
+ if (meta)
459
+ (meta.reqHeaders ??= {})[name] = value;
460
+ return origSetHeader?.call(this, name, value);
461
+ });
462
+ const patchedSend = (XHR.prototype.send = function (body) {
463
+ const meta = this.__vt;
464
+ const origin = activeSessionId();
465
+ if (meta && !isVitrinkaUrl(meta.url) && origin !== null) {
466
+ const started = Date.now();
467
+ this.addEventListener('loadend', () => {
468
+ // Re-check at COMPLETION, and require the SAME session: a pause/stop
469
+ // mid-flight means no event is recorded (so reading responseText would
470
+ // be waste), and a request that outlives its session must
471
+ // not be attributed to the next one.
472
+ if (!stillCapturing(origin))
473
+ return;
474
+ let rawResHeaders;
475
+ try {
476
+ rawResHeaders = parseRawHeaders(this.getAllResponseHeaders());
477
+ }
478
+ catch {
479
+ rawResHeaders = undefined;
480
+ }
481
+ let resBody;
482
+ try {
483
+ resBody =
484
+ this.responseType === '' || this.responseType === 'text'
485
+ ? capBody(this.responseText, ctOf(rawResHeaders))
486
+ : undefined;
487
+ }
488
+ catch {
489
+ resBody = undefined;
490
+ }
491
+ recordNet({
492
+ method: meta.method,
493
+ url: meta.url,
494
+ status: this.status,
495
+ ms: Date.now() - started,
496
+ reqHeaders: redactHeaders(meta.reqHeaders),
497
+ resHeaders: redactHeaders(rawResHeaders),
498
+ reqBody: capBody(body, ctOf(meta.reqHeaders)),
499
+ resBody,
500
+ via: 'xhr',
501
+ });
502
+ });
503
+ }
504
+ return origSend.call(this, body);
505
+ });
506
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Redaction bridge — binds the shared engine (@vitrinka/redact) to the
3
+ * CURRENT session's workspace policy.
4
+ *
5
+ * The engine itself owns all semantics (key classification, JSON/form/
6
+ * multipart bodies, URL query+fragment scrubbing, patterns, fullFidelity);
7
+ * this module owns which RULES apply right now: the safe defaults until a
8
+ * session starts, the session's fetched policy after. FAIL CLOSED — a failed
9
+ * or slow policy fetch means the defaults, never capture-everything.
10
+ *
11
+ * Capture layers import the bound helpers below so every call site stays a
12
+ * one-liner and can never forget to pass the rules.
13
+ */
14
+ import { type MaskDirectives, type RedactionPolicy, type RuleSet } from '@vitrinka/redact';
15
+ export { isSecretKey, REDACTED, type RedactionPolicy } from '@vitrinka/redact';
16
+ /**
17
+ * Apply a session's workspace policy (null = the safe defaults). Called at
18
+ * session start when the fetch resolves, and on provider mount when a session
19
+ * (with its policy) is recovered from storage after a reload.
20
+ */
21
+ export declare function setRedactionPolicy(policy: RedactionPolicy | null | undefined): void;
22
+ /** The active rule set (test + capture-layer introspection). */
23
+ export declare function currentRules(): RuleSet;
24
+ /** Redact a captured body/log string under the active rules. Never throws. */
25
+ export declare function redactText(text: string | undefined): string | undefined;
26
+ /**
27
+ * Redact then cap (shape-aware order) under the active rules. Pass the
28
+ * captured content type when known — the engine dispatches its form-encoded
29
+ * and multipart transforms on it; without it those bodies only get the
30
+ * generic text scanner.
31
+ */
32
+ export declare function redactAndCap(body: string, cap: number, contentType?: string): string | undefined;
33
+ /** Scrub URL query/fragment secrets under the active rules. */
34
+ export declare function redactUrl(url: string): string;
35
+ /** Scrub + cap a captured header map under the active rules. */
36
+ export declare function redactHeaders(headers: Record<string, unknown> | undefined): Record<string, string> | undefined;
37
+ /** rrweb masking options under the active rules (maskAllInputs / maskAllText). */
38
+ export declare function maskDirectives(): MaskDirectives;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Redaction bridge — binds the shared engine (@vitrinka/redact) to the
3
+ * CURRENT session's workspace policy.
4
+ *
5
+ * The engine itself owns all semantics (key classification, JSON/form/
6
+ * multipart bodies, URL query+fragment scrubbing, patterns, fullFidelity);
7
+ * this module owns which RULES apply right now: the safe defaults until a
8
+ * session starts, the session's fetched policy after. FAIL CLOSED — a failed
9
+ * or slow policy fetch means the defaults, never capture-everything.
10
+ *
11
+ * Capture layers import the bound helpers below so every call site stays a
12
+ * one-liner and can never forget to pass the rules.
13
+ */
14
+ import { compileRules, redactAndCap as engineRedactAndCap, redactHeaders as engineRedactHeaders, redactText as engineRedactText, redactUrl as engineRedactUrl, maskDirectives as engineMaskDirectives, } from '@vitrinka/redact';
15
+ // Engine primitives that need no binding, re-exported for tests and callers.
16
+ export { isSecretKey, REDACTED } from '@vitrinka/redact';
17
+ let rules = compileRules(null);
18
+ /**
19
+ * Apply a session's workspace policy (null = the safe defaults). Called at
20
+ * session start when the fetch resolves, and on provider mount when a session
21
+ * (with its policy) is recovered from storage after a reload.
22
+ */
23
+ export function setRedactionPolicy(policy) {
24
+ rules = compileRules(policy ?? null);
25
+ }
26
+ /** The active rule set (test + capture-layer introspection). */
27
+ export function currentRules() {
28
+ return rules;
29
+ }
30
+ /** Redact a captured body/log string under the active rules. Never throws. */
31
+ export function redactText(text) {
32
+ return engineRedactText(rules, text);
33
+ }
34
+ /**
35
+ * Redact then cap (shape-aware order) under the active rules. Pass the
36
+ * captured content type when known — the engine dispatches its form-encoded
37
+ * and multipart transforms on it; without it those bodies only get the
38
+ * generic text scanner.
39
+ */
40
+ export function redactAndCap(body, cap, contentType) {
41
+ return engineRedactAndCap(rules, body, cap, contentType);
42
+ }
43
+ /** Scrub URL query/fragment secrets under the active rules. */
44
+ export function redactUrl(url) {
45
+ return engineRedactUrl(rules, url);
46
+ }
47
+ /** Scrub + cap a captured header map under the active rules. */
48
+ export function redactHeaders(headers) {
49
+ return engineRedactHeaders(rules, headers);
50
+ }
51
+ /** rrweb masking options under the active rules (maskAllInputs / maskAllText). */
52
+ export function maskDirectives() {
53
+ return engineMaskDirectives(rules);
54
+ }
@@ -0,0 +1,10 @@
1
+ /** Elements carrying this attribute are never recorded (the HUD host). */
2
+ export declare const RRWEB_BLOCK_ATTR = "data-vitrinka-recorder";
3
+ /** Start recording the DOM; idempotent. */
4
+ export declare function startRRWeb(): void;
5
+ /** Ship the tail and stop recording. */
6
+ export declare function stopRRWeb(): void;
7
+ /** Take a fresh full snapshot (resume after pause, policy change). */
8
+ export declare function checkoutRRWeb(): void;
9
+ /** Flush the current batch now (pagehide, stop). */
10
+ export declare function flushRRWeb(): void;
@@ -0,0 +1,76 @@
1
+ import { record } from 'rrweb';
2
+ import { pushRRWebBatch } from '../queue';
3
+ import { currentRoute } from '../state';
4
+ import { maskDirectives } from './redact';
5
+ const BATCH_MS = 2000;
6
+ /** A fresh full snapshot every 5 min keeps long recordings seekable. */
7
+ const CHECKOUT_MS = 5 * 60 * 1000;
8
+ /** Elements carrying this attribute are never recorded (the HUD host). */
9
+ export const RRWEB_BLOCK_ATTR = 'data-vitrinka-recorder';
10
+ let stop = null;
11
+ let buf = [];
12
+ let timer = null;
13
+ function ship() {
14
+ if (!buf.length)
15
+ return;
16
+ const events = buf;
17
+ buf = [];
18
+ // Not capturing (paused): the batch is dropped on purpose — paused means
19
+ // paused, and the next resume starts from a fresh checkout.
20
+ pushRRWebBatch(events, { tabId: currentRoute.tabId, tabHost: currentRoute.tabHost });
21
+ }
22
+ /** Start recording the DOM; idempotent. */
23
+ export function startRRWeb() {
24
+ if (stop)
25
+ return;
26
+ const mask = maskDirectives();
27
+ try {
28
+ const stopFn = record({
29
+ emit: (ev) => {
30
+ buf.push(ev);
31
+ },
32
+ checkoutEveryNms: CHECKOUT_MS,
33
+ inlineImages: true,
34
+ collectFonts: true,
35
+ maskAllInputs: mask.maskAllInputs,
36
+ ...(mask.maskTextSelector ? { maskTextSelector: mask.maskTextSelector } : {}),
37
+ blockSelector: `[${RRWEB_BLOCK_ATTR}]`,
38
+ });
39
+ stop = stopFn ?? null;
40
+ }
41
+ catch (e) {
42
+ console.warn('vitrinka: rrweb failed to start', e);
43
+ return;
44
+ }
45
+ timer = setInterval(ship, BATCH_MS);
46
+ }
47
+ /** Ship the tail and stop recording. */
48
+ export function stopRRWeb() {
49
+ if (timer)
50
+ clearInterval(timer);
51
+ timer = null;
52
+ ship();
53
+ try {
54
+ stop?.();
55
+ }
56
+ catch {
57
+ // already torn down
58
+ }
59
+ stop = null;
60
+ buf = [];
61
+ }
62
+ /** Take a fresh full snapshot (resume after pause, policy change). */
63
+ export function checkoutRRWeb() {
64
+ if (!stop)
65
+ return;
66
+ try {
67
+ record.takeFullSnapshot?.(true);
68
+ }
69
+ catch {
70
+ // older rrweb — the periodic checkout covers it
71
+ }
72
+ }
73
+ /** Flush the current batch now (pagehide, stop). */
74
+ export function flushRRWeb() {
75
+ ship();
76
+ }