@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +93 -0
- package/README.md +177 -0
- package/build/index.d.ts +11 -0
- package/build/index.js +1 -0
- package/build/next.d.ts +23 -0
- package/build/next.js +27 -0
- package/build/protocol/index.d.ts +74 -0
- package/build/protocol/index.js +22 -0
- package/build/recorder/RecorderProvider.d.ts +17 -0
- package/build/recorder/RecorderProvider.js +126 -0
- package/build/recorder/api-status.d.ts +6 -0
- package/build/recorder/api-status.js +6 -0
- package/build/recorder/api.d.ts +40 -0
- package/build/recorder/api.js +84 -0
- package/build/recorder/capture/click.d.ts +17 -0
- package/build/recorder/capture/click.js +77 -0
- package/build/recorder/capture/console.d.ts +3 -0
- package/build/recorder/capture/console.js +89 -0
- package/build/recorder/capture/nav.d.ts +8 -0
- package/build/recorder/capture/nav.js +54 -0
- package/build/recorder/capture/net.d.ts +22 -0
- package/build/recorder/capture/net.js +506 -0
- package/build/recorder/capture/redact.d.ts +38 -0
- package/build/recorder/capture/redact.js +54 -0
- package/build/recorder/capture/rrweb.d.ts +10 -0
- package/build/recorder/capture/rrweb.js +76 -0
- package/build/recorder/config.d.ts +50 -0
- package/build/recorder/config.js +100 -0
- package/build/recorder/control.d.ts +29 -0
- package/build/recorder/control.js +63 -0
- package/build/recorder/hud/AnnotateOverlay.d.ts +25 -0
- package/build/recorder/hud/AnnotateOverlay.js +122 -0
- package/build/recorder/hud/Hud.d.ts +12 -0
- package/build/recorder/hud/Hud.js +190 -0
- package/build/recorder/hud/LinkSheet.d.ts +26 -0
- package/build/recorder/hud/LinkSheet.js +15 -0
- package/build/recorder/hud/RecorderPill.d.ts +36 -0
- package/build/recorder/hud/RecorderPill.js +73 -0
- package/build/recorder/hud/Sheet.d.ts +20 -0
- package/build/recorder/hud/Sheet.js +36 -0
- package/build/recorder/hud/host.d.ts +27 -0
- package/build/recorder/hud/host.js +170 -0
- package/build/recorder/hud/icons.d.ts +15 -0
- package/build/recorder/hud/icons.js +40 -0
- package/build/recorder/hud/styles.d.ts +13 -0
- package/build/recorder/hud/styles.js +111 -0
- package/build/recorder/index.d.ts +46 -0
- package/build/recorder/index.js +61 -0
- package/build/recorder/link.d.ts +18 -0
- package/build/recorder/link.js +37 -0
- package/build/recorder/queue.d.ts +163 -0
- package/build/recorder/queue.js +642 -0
- package/build/recorder/session.d.ts +73 -0
- package/build/recorder/session.js +246 -0
- package/build/recorder/state.d.ts +26 -0
- package/build/recorder/state.js +42 -0
- package/build/recorder/storage/index.d.ts +35 -0
- package/build/recorder/storage/index.js +69 -0
- package/build/recorder/storage/memory.d.ts +2 -0
- package/build/recorder/storage/memory.js +2 -0
- 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
|
+
}
|