@cubenest/rrweb-core 0.1.0-alpha.0 → 0.1.0-alpha.2

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 (99) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +12 -0
  3. package/dist/compression/index.d.ts +1 -1
  4. package/dist/compression/index.d.ts.map +1 -1
  5. package/dist/console/buffer.d.ts +3 -3
  6. package/dist/console/buffer.d.ts.map +1 -1
  7. package/dist/console/buffer.js +1 -1
  8. package/dist/console/buffer.js.map +1 -1
  9. package/dist/console/index.d.ts +2 -2
  10. package/dist/console/index.d.ts.map +1 -1
  11. package/dist/console/index.js +1 -1
  12. package/dist/console/index.js.map +1 -1
  13. package/dist/index.d.ts +18 -16
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +15 -10
  16. package/dist/index.js.map +1 -1
  17. package/dist/masking/body.js +1 -1
  18. package/dist/masking/body.js.map +1 -1
  19. package/dist/masking/index.d.ts +7 -7
  20. package/dist/masking/index.d.ts.map +1 -1
  21. package/dist/masking/index.js +5 -5
  22. package/dist/masking/index.js.map +1 -1
  23. package/dist/masking/inputs.js +1 -1
  24. package/dist/masking/inputs.js.map +1 -1
  25. package/dist/masking/text.js +1 -1
  26. package/dist/masking/text.js.map +1 -1
  27. package/dist/network/cdp.d.ts +1 -1
  28. package/dist/network/cdp.d.ts.map +1 -1
  29. package/dist/network/cdp.js +1 -1
  30. package/dist/network/cdp.js.map +1 -1
  31. package/dist/network/index.d.ts +3 -3
  32. package/dist/network/index.d.ts.map +1 -1
  33. package/dist/network/index.js +2 -2
  34. package/dist/network/index.js.map +1 -1
  35. package/dist/network/web-request.d.ts +1 -1
  36. package/dist/network/web-request.d.ts.map +1 -1
  37. package/dist/network/web-request.js +1 -1
  38. package/dist/network/web-request.js.map +1 -1
  39. package/dist/persistence/index.d.ts +2 -2
  40. package/dist/persistence/index.d.ts.map +1 -1
  41. package/dist/persistence/index.js +1 -1
  42. package/dist/persistence/index.js.map +1 -1
  43. package/dist/persistence/store.d.ts +1 -1
  44. package/dist/persistence/store.d.ts.map +1 -1
  45. package/dist/plugins/network/defaults.d.ts +36 -0
  46. package/dist/plugins/network/defaults.d.ts.map +1 -0
  47. package/dist/plugins/network/defaults.js +80 -0
  48. package/dist/plugins/network/defaults.js.map +1 -0
  49. package/dist/plugins/network/index.d.ts +3 -0
  50. package/dist/plugins/network/index.d.ts.map +1 -0
  51. package/dist/plugins/network/index.js +9 -0
  52. package/dist/plugins/network/index.js.map +1 -0
  53. package/dist/plugins/network/patch.d.ts +15 -0
  54. package/dist/plugins/network/patch.d.ts.map +1 -0
  55. package/dist/plugins/network/patch.js +64 -0
  56. package/dist/plugins/network/patch.js.map +1 -0
  57. package/dist/plugins/network/record.d.ts +38 -0
  58. package/dist/plugins/network/record.d.ts.map +1 -0
  59. package/dist/plugins/network/record.js +911 -0
  60. package/dist/plugins/network/record.js.map +1 -0
  61. package/dist/plugins/network/types.d.ts +208 -0
  62. package/dist/plugins/network/types.d.ts.map +1 -0
  63. package/dist/plugins/network/types.js +16 -0
  64. package/dist/plugins/network/types.js.map +1 -0
  65. package/dist/rrweb.d.ts +2 -1
  66. package/dist/rrweb.d.ts.map +1 -1
  67. package/dist/rrweb.js +4 -0
  68. package/dist/rrweb.js.map +1 -1
  69. package/dist/screenshot/cdp.d.ts +1 -1
  70. package/dist/screenshot/cdp.d.ts.map +1 -1
  71. package/dist/screenshot/cdp.js +1 -1
  72. package/dist/screenshot/cdp.js.map +1 -1
  73. package/dist/screenshot/index.d.ts +3 -3
  74. package/dist/screenshot/index.d.ts.map +1 -1
  75. package/dist/screenshot/index.js +2 -2
  76. package/dist/screenshot/index.js.map +1 -1
  77. package/dist/screenshot/tabs.d.ts +1 -1
  78. package/dist/screenshot/tabs.d.ts.map +1 -1
  79. package/dist/screenshot/tabs.js +1 -1
  80. package/dist/screenshot/tabs.js.map +1 -1
  81. package/dist/shadow-dom/index.d.ts +2 -2
  82. package/dist/shadow-dom/index.d.ts.map +1 -1
  83. package/dist/shadow-dom/index.js +1 -1
  84. package/dist/shadow-dom/index.js.map +1 -1
  85. package/dist/shadow-dom/traverse.d.ts +1 -1
  86. package/dist/shadow-dom/traverse.d.ts.map +1 -1
  87. package/dist/throttling/apply.d.ts +2 -2
  88. package/dist/throttling/apply.d.ts.map +1 -1
  89. package/dist/throttling/apply.js +2 -2
  90. package/dist/throttling/apply.js.map +1 -1
  91. package/dist/throttling/guards.d.ts +1 -1
  92. package/dist/throttling/guards.d.ts.map +1 -1
  93. package/dist/throttling/guards.js +1 -1
  94. package/dist/throttling/guards.js.map +1 -1
  95. package/dist/throttling/index.d.ts +4 -4
  96. package/dist/throttling/index.d.ts.map +1 -1
  97. package/dist/throttling/index.js +2 -2
  98. package/dist/throttling/index.js.map +1 -1
  99. package/package.json +22 -12
@@ -0,0 +1,911 @@
1
+ // Framework-agnostic rrweb network capture plugin.
2
+ //
3
+ // Adapted from PostHog's `network-plugin.ts` (Apache-2.0). The chain of
4
+ // vendoring + attribution is logged in NOTICE.
5
+ //
6
+ // Trimmed differences vs PostHog's version (PR #1689's draft):
7
+ // - Drops `@posthog/core` type-guard imports; inlines minimal ones.
8
+ // - Drops `createLogger` — substrate stays silent; consumers wrap
9
+ // `maskRequestFn` if they want logging.
10
+ // - Drops `convertToURL` / `formDataToQuery` heavy utilities; inlines
11
+ // the FormData → query stringifier (5 LOC).
12
+ // - The default `maskRequestFn` (when consumer doesn't override) pipes
13
+ // headers through {@link redactNetworkHeaders} and bodies through
14
+ // {@link redactBody}. The consumer's `maskRequestFn` (if any) runs
15
+ // AFTER the default mask — defense in depth.
16
+ // - The `initialisedHandler` module-level singleton is preserved
17
+ // verbatim; same teardown contract.
18
+ import { redactBody } from '../../masking/body.js';
19
+ import { redactNetworkHeaders } from '../../masking/headers.js';
20
+ import { defaultNetworkOptions } from './defaults.js';
21
+ import { patch } from './patch.js';
22
+ import { NETWORK_PLUGIN_NAME, } from './types.js';
23
+ // ─── Minimal type guards (replaces @posthog/core) ────────────────────────────
24
+ const isArray = Array.isArray;
25
+ const isString = (v) => typeof v === 'string';
26
+ const isBoolean = (v) => typeof v === 'boolean';
27
+ const isUndefined = (v) => typeof v === 'undefined';
28
+ const isNull = (v) => v === null;
29
+ const isNullish = (v) => v == null;
30
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
31
+ const isFormData = (v) => typeof FormData !== 'undefined' && v instanceof FormData;
32
+ const isDocument = (v) => typeof Document !== 'undefined' && v instanceof Document;
33
+ // ─── Local utilities (replaces formDataToQuery / denylist / logger) ─────────
34
+ /**
35
+ * `FormData` → query string. Defensive serialization for request body
36
+ * capture; File parts emit their filename so the redaction layer can
37
+ * see SOMETHING without trying to read the bytes.
38
+ */
39
+ function formDataToQuery(formData) {
40
+ const params = new URLSearchParams();
41
+ formData.forEach((value, key) => {
42
+ if (typeof value === 'string') {
43
+ params.append(key, value);
44
+ }
45
+ else if (value && typeof value === 'object' && 'name' in value) {
46
+ params.append(key, `[File: ${value.name}]`);
47
+ }
48
+ else {
49
+ params.append(key, '[Blob]');
50
+ }
51
+ });
52
+ return params.toString();
53
+ }
54
+ /**
55
+ * Suffix-match a hostname against the consumer-supplied deny-list.
56
+ * Returns `{ hostname, isHostDenied }` so callers can surface the
57
+ * matched host in the replacement payload.
58
+ */
59
+ function isHostOnDenyList(url, options) {
60
+ const hostname = hostnameFromURL(url);
61
+ const denyList = options.payloadHostDenyList ?? [];
62
+ if (denyList.length === 0 || !hostname || hostname.trim().length === 0) {
63
+ return { hostname, isHostDenied: false };
64
+ }
65
+ for (const deny of denyList) {
66
+ if (hostname.endsWith(deny)) {
67
+ return { hostname, isHostDenied: true };
68
+ }
69
+ }
70
+ return { hostname, isHostDenied: false };
71
+ }
72
+ function hostnameFromURL(url) {
73
+ try {
74
+ if (typeof url === 'string') {
75
+ return new URL(url, getDocumentBase()).hostname;
76
+ }
77
+ if (url instanceof URL) {
78
+ return url.hostname;
79
+ }
80
+ if ('url' in url) {
81
+ return new URL(url.url, getDocumentBase()).hostname;
82
+ }
83
+ return null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * Provides a base URL for resolving relative URLs. In the browser we use
91
+ * `document.baseURI`; in tests/Node we fall back to an opaque
92
+ * placeholder so `new URL('/foo', base)` resolves rather than throws.
93
+ */
94
+ function getDocumentBase() {
95
+ try {
96
+ if (typeof document !== 'undefined' && document.baseURI) {
97
+ return document.baseURI;
98
+ }
99
+ }
100
+ catch {
101
+ /* ignore */
102
+ }
103
+ return 'http://localhost/';
104
+ }
105
+ /**
106
+ * Best-effort silent logger. Substrate refuses to console.log on the
107
+ * host page; consumers needing diagnostics wrap `maskRequestFn`.
108
+ */
109
+ const logger = {
110
+ warn: (_msg, _ctx) => {
111
+ /* silent */
112
+ },
113
+ error: (_msg, _ctx) => {
114
+ /* silent */
115
+ },
116
+ info: (_msg, _ctx) => {
117
+ /* silent */
118
+ },
119
+ };
120
+ // ─── Performance entry classifiers (verbatim from PostHog) ───────────────────
121
+ const isNavigationTiming = (entry) => entry.entryType === 'navigation';
122
+ const isResourceTiming = (entry) => entry.entryType === 'resource';
123
+ function findLast(array, predicate) {
124
+ for (let i = array.length - 1; i >= 0; i -= 1) {
125
+ const v = array[i];
126
+ if (v !== undefined && predicate(v)) {
127
+ return v;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+ // ─── Should-record helpers ───────────────────────────────────────────────────
133
+ function shouldRecordHeaders(type, recordHeaders) {
134
+ if (!recordHeaders)
135
+ return false;
136
+ if (isBoolean(recordHeaders))
137
+ return true;
138
+ return !!recordHeaders[type];
139
+ }
140
+ export function shouldRecordBody({ type, recordBody, headers, url, }) {
141
+ function matchesContentType(contentTypes) {
142
+ const contentTypeHeader = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
143
+ const contentType = contentTypeHeader && headers[contentTypeHeader];
144
+ return contentTypes.some((ct) => contentType?.includes(ct));
145
+ }
146
+ function isBlobURL(u) {
147
+ try {
148
+ if (typeof u === 'string')
149
+ return u.startsWith('blob:');
150
+ if (u instanceof URL)
151
+ return u.protocol === 'blob:';
152
+ if (typeof Request !== 'undefined' && u instanceof Request) {
153
+ return isBlobURL(u.url);
154
+ }
155
+ return false;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ if (!recordBody)
162
+ return false;
163
+ if (isBlobURL(url))
164
+ return false;
165
+ if (isBoolean(recordBody))
166
+ return true;
167
+ if (isArray(recordBody))
168
+ return matchesContentType(recordBody);
169
+ const recordBodyType = recordBody[type];
170
+ if (isBoolean(recordBodyType))
171
+ return recordBodyType;
172
+ return matchesContentType(recordBodyType);
173
+ }
174
+ function normalizeOptions(opts) {
175
+ return {
176
+ recordInitialRequests: opts?.recordInitialRequests ?? defaultNetworkOptions.recordInitialRequests,
177
+ recordHeaders: opts?.recordHeaders ?? defaultNetworkOptions.recordHeaders,
178
+ recordBody: opts?.recordBody ?? defaultNetworkOptions.recordBody,
179
+ capturePerformance: opts?.capturePerformance ?? defaultNetworkOptions.capturePerformance,
180
+ performanceEntryTypeToObserve: opts?.performanceEntryTypeToObserve ?? defaultNetworkOptions.performanceEntryTypeToObserve,
181
+ initiatorTypes: opts?.initiatorTypes ?? defaultNetworkOptions.initiatorTypes,
182
+ payloadSizeLimitBytes: opts?.payloadSizeLimitBytes ?? defaultNetworkOptions.payloadSizeLimitBytes,
183
+ bodyByteLimit: opts?.bodyByteLimit ?? defaultNetworkOptions.bodyByteLimit,
184
+ maxRequestsPerBatch: opts?.maxRequestsPerBatch ?? defaultNetworkOptions.maxRequestsPerBatch,
185
+ payloadHostDenyList: opts?.payloadHostDenyList ?? defaultNetworkOptions.payloadHostDenyList,
186
+ maskRequestFn: opts?.maskRequestFn ?? defaultNetworkOptions.maskRequestFn,
187
+ };
188
+ }
189
+ function initPerformanceObserver(cb, win, options) {
190
+ if (!options.capturePerformance) {
191
+ return () => {
192
+ /* no-op */
193
+ };
194
+ }
195
+ // emit pre-existing entries first (the page-load burst before the
196
+ // recorder attached). Marked isInitial so replay can render them
197
+ // differently — they never carry method/status/headers/body.
198
+ if (options.recordInitialRequests) {
199
+ const initialPerformanceEntries = win.performance
200
+ .getEntries()
201
+ .filter((entry) => isNavigationTiming(entry) ||
202
+ (isResourceTiming(entry) &&
203
+ options.initiatorTypes.includes(entry.initiatorType)));
204
+ if (initialPerformanceEntries.length > 0) {
205
+ cb({
206
+ requests: initialPerformanceEntries.flatMap((entry) => prepareRequest({
207
+ entry,
208
+ method: undefined,
209
+ status: undefined,
210
+ networkRequest: {},
211
+ isInitial: true,
212
+ })),
213
+ isInitial: true,
214
+ });
215
+ }
216
+ }
217
+ if (typeof win.PerformanceObserver === 'undefined') {
218
+ return () => {
219
+ /* no-op */
220
+ };
221
+ }
222
+ const observer = new win.PerformanceObserver((entries) => {
223
+ // when fetch/XHR are wrapped (i.e. we capture bodies/headers via
224
+ // the wrappers), avoid double-emit for those initiator types here.
225
+ const wrappedInitiatorFilter = (entry) => options.recordBody || options.recordHeaders
226
+ ? entry.initiatorType !== 'xmlhttprequest' && entry.initiatorType !== 'fetch'
227
+ : true;
228
+ const performanceEntries = entries
229
+ .getEntries()
230
+ .filter((entry) => isNavigationTiming(entry) ||
231
+ (isResourceTiming(entry) &&
232
+ options.initiatorTypes.includes(entry.initiatorType) &&
233
+ wrappedInitiatorFilter(entry)));
234
+ if (performanceEntries.length === 0)
235
+ return;
236
+ cb({
237
+ requests: performanceEntries.flatMap((entry) => prepareRequest({
238
+ entry,
239
+ method: undefined,
240
+ status: undefined,
241
+ networkRequest: {},
242
+ })),
243
+ });
244
+ });
245
+ const supportedTypes = win.PerformanceObserver
246
+ .supportedEntryTypes;
247
+ const entryTypes = supportedTypes.filter((x) => options.performanceEntryTypeToObserve.includes(x));
248
+ if (entryTypes.length === 0) {
249
+ return () => {
250
+ /* no-op */
251
+ };
252
+ }
253
+ observer.observe({ entryTypes });
254
+ return () => {
255
+ observer.disconnect();
256
+ };
257
+ }
258
+ /**
259
+ * Maximum retry attempts when looking up the PerformanceResourceTiming
260
+ * entry for a fetch/XHR we just wrapped. The browser buffers resource
261
+ * timings asynchronously, so the entry isn't always available the moment
262
+ * the response resolves. PostHog tuned this at 10 attempts × 50*attempt
263
+ * ms — up to ~2.75s. We hold these as mutable bindings so tests can
264
+ * override at module load time via `_setPerfEntryRetryConfigForTests`.
265
+ */
266
+ let perfEntryMaxAttempts = 10;
267
+ let perfEntryBackoffMs = 50;
268
+ /**
269
+ * Test-only — override the retry config. Returns the previous values so
270
+ * tests can restore. Not exported from the public barrel.
271
+ */
272
+ export function _setPerfEntryRetryConfigForTests(maxAttempts, backoffMs) {
273
+ const previousMax = perfEntryMaxAttempts;
274
+ const previousBackoff = perfEntryBackoffMs;
275
+ perfEntryMaxAttempts = maxAttempts;
276
+ perfEntryBackoffMs = backoffMs;
277
+ return { previousMax, previousBackoff };
278
+ }
279
+ async function getRequestPerformanceEntry(win, initiatorType, url, start, end, attempt = 0) {
280
+ if (attempt > perfEntryMaxAttempts) {
281
+ logger.warn('Failed to get performance entry for request', { url, initiatorType });
282
+ return null;
283
+ }
284
+ if (!win.performance || typeof win.performance.getEntriesByName !== 'function') {
285
+ return null;
286
+ }
287
+ const urlPerformanceEntries = win.performance.getEntriesByName(url);
288
+ const performanceEntry = findLast(urlPerformanceEntries, (entry) => isResourceTiming(entry) &&
289
+ entry.initiatorType === initiatorType &&
290
+ (isUndefined(start) || entry.startTime >= start) &&
291
+ (isUndefined(end) || entry.startTime <= end));
292
+ if (!performanceEntry) {
293
+ await new Promise((resolve) => setTimeout(resolve, perfEntryBackoffMs * attempt));
294
+ return getRequestPerformanceEntry(win, initiatorType, url, start, end, attempt + 1);
295
+ }
296
+ return performanceEntry;
297
+ }
298
+ // ─── XHR body reader ────────────────────────────────────────────────────────
299
+ function _tryReadXHRBody({ body, options, url, }) {
300
+ if (isNullish(body))
301
+ return null;
302
+ const { hostname, isHostDenied } = isHostOnDenyList(url, options);
303
+ if (isHostDenied && hostname) {
304
+ return `${hostname} is in deny list`;
305
+ }
306
+ if (isString(body))
307
+ return body;
308
+ if (isDocument(body))
309
+ return body.textContent;
310
+ if (isFormData(body))
311
+ return formDataToQuery(body);
312
+ if (isObject(body)) {
313
+ try {
314
+ return JSON.stringify(body);
315
+ }
316
+ catch {
317
+ return '[Cubenest] Failed to stringify response object';
318
+ }
319
+ }
320
+ return `[Cubenest] Cannot read body of type ${Object.prototype.toString.call(body)}`;
321
+ }
322
+ // ─── XHR observer ────────────────────────────────────────────────────────────
323
+ function initXhrObserver(cb, win, options) {
324
+ if (!options.initiatorTypes.includes('xmlhttprequest')) {
325
+ return () => {
326
+ /* no-op */
327
+ };
328
+ }
329
+ if (typeof win.XMLHttpRequest === 'undefined') {
330
+ return () => {
331
+ /* no-op */
332
+ };
333
+ }
334
+ const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders);
335
+ const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders);
336
+ const restorePatch = patch(win.XMLHttpRequest.prototype, 'open', (originalOpenUnknown) => {
337
+ const originalOpen = originalOpenUnknown;
338
+ return function (method, url, async = true, username, password) {
339
+ const reqUrlStr = url.toString();
340
+ const networkRequest = {};
341
+ let start;
342
+ let end;
343
+ const requestHeaders = {};
344
+ const originalSetRequestHeader = this.setRequestHeader.bind(this);
345
+ this.setRequestHeader = (header, value) => {
346
+ requestHeaders[header] = value;
347
+ return originalSetRequestHeader(header, value);
348
+ };
349
+ if (recordRequestHeaders) {
350
+ networkRequest.requestHeaders = requestHeaders;
351
+ }
352
+ const originalSend = this.send.bind(this);
353
+ this.send = (body) => {
354
+ if (shouldRecordBody({
355
+ type: 'request',
356
+ headers: requestHeaders,
357
+ url,
358
+ recordBody: options.recordBody,
359
+ })) {
360
+ const read = _tryReadXHRBody({ body, options, url });
361
+ if (read !== null)
362
+ networkRequest.requestBody = read;
363
+ }
364
+ start = win.performance.now();
365
+ return originalSend(body);
366
+ };
367
+ // Cleanup function to remove all event listeners and prevent memory
368
+ // leaks. Listener references MUST match what was passed to
369
+ // addEventListener — `removeEventListener` silently no-ops on a
370
+ // mismatched reference. PostHog's upstream had this bug (passing
371
+ // `cleanup` to remove listeners added with `errorCleanup`); the
372
+ // fix here is intentional, do not "simplify" by reverting to a
373
+ // single function reference for both add and remove.
374
+ const cleanup = () => {
375
+ this.removeEventListener('readystatechange', readyStateListener);
376
+ this.removeEventListener('error', errorCleanup);
377
+ this.removeEventListener('abort', errorCleanup);
378
+ this.removeEventListener('timeout', errorCleanup);
379
+ };
380
+ // For aborted/errored requests, emit a synthetic record so the
381
+ // replay panel still shows the attempt. Preserves PostHog's
382
+ // behavior of cleaning listeners + recording status=0.
383
+ const errorCleanup = () => {
384
+ if (start !== undefined && end === undefined) {
385
+ end = win.performance.now();
386
+ void getRequestPerformanceEntry(win, 'xmlhttprequest', reqUrlStr, start, end)
387
+ .then((entry) => {
388
+ const requests = prepareRequest({
389
+ entry,
390
+ method,
391
+ status: this.status || 0,
392
+ networkRequest,
393
+ start,
394
+ end,
395
+ url: reqUrlStr,
396
+ initiatorType: 'xmlhttprequest',
397
+ });
398
+ cb({ requests });
399
+ })
400
+ .catch(() => {
401
+ /* ignore */
402
+ });
403
+ }
404
+ cleanup();
405
+ };
406
+ const readyStateListener = () => {
407
+ if (this.readyState !== this.DONE) {
408
+ return;
409
+ }
410
+ cleanup();
411
+ end = win.performance.now();
412
+ const responseHeaders = {};
413
+ const rawHeaders = this.getAllResponseHeaders();
414
+ const headers = rawHeaders.trim().split(/[\r\n]+/);
415
+ for (const line of headers) {
416
+ const parts = line.split(': ');
417
+ const header = parts.shift();
418
+ const value = parts.join(': ');
419
+ if (header) {
420
+ responseHeaders[header] = value;
421
+ }
422
+ }
423
+ if (recordResponseHeaders) {
424
+ networkRequest.responseHeaders = responseHeaders;
425
+ }
426
+ if (shouldRecordBody({
427
+ type: 'response',
428
+ headers: responseHeaders,
429
+ url,
430
+ recordBody: options.recordBody,
431
+ })) {
432
+ const read = _tryReadXHRBody({ body: this.response, options, url });
433
+ if (read !== null)
434
+ networkRequest.responseBody = read;
435
+ }
436
+ void getRequestPerformanceEntry(win, 'xmlhttprequest', reqUrlStr, start, end)
437
+ .then((entry) => {
438
+ const requests = prepareRequest({
439
+ entry,
440
+ method,
441
+ status: this.status,
442
+ networkRequest,
443
+ start,
444
+ end,
445
+ url: reqUrlStr,
446
+ initiatorType: 'xmlhttprequest',
447
+ });
448
+ cb({ requests });
449
+ })
450
+ .catch(() => {
451
+ /* ignore */
452
+ });
453
+ };
454
+ this.addEventListener('readystatechange', readyStateListener);
455
+ this.addEventListener('error', errorCleanup);
456
+ this.addEventListener('abort', errorCleanup);
457
+ this.addEventListener('timeout', errorCleanup);
458
+ originalOpen.call(this, method, reqUrlStr, async, username, password);
459
+ };
460
+ });
461
+ return () => {
462
+ restorePatch();
463
+ };
464
+ }
465
+ // ─── Fetch body readers ──────────────────────────────────────────────────────
466
+ const contentTypePrefixDenyList = ['video/', 'audio/'];
467
+ function _checkForCannotReadResponseBody({ r, options, url, }) {
468
+ if (r.headers.get('Transfer-Encoding') === 'chunked') {
469
+ return 'Chunked Transfer-Encoding is not supported';
470
+ }
471
+ const contentType = r.headers.get('Content-Type')?.toLowerCase();
472
+ const contentTypeIsDenied = contentTypePrefixDenyList.some((prefix) => contentType?.startsWith(prefix));
473
+ if (contentType && contentTypeIsDenied) {
474
+ return `Content-Type ${contentType} is not supported`;
475
+ }
476
+ const { hostname, isHostDenied } = isHostOnDenyList(url, options);
477
+ if (isHostDenied && hostname) {
478
+ return `${hostname} is in deny list`;
479
+ }
480
+ return null;
481
+ }
482
+ function _tryReadBody(r) {
483
+ return new Promise((resolve) => {
484
+ const timeout = setTimeout(() => resolve('[Cubenest] Timeout while trying to read body'), 500);
485
+ try {
486
+ r.clone()
487
+ .text()
488
+ .then((txt) => resolve(txt), (reason) => resolve(`[Cubenest] Failed to read body: ${String(reason)}`))
489
+ .finally(() => clearTimeout(timeout));
490
+ }
491
+ catch {
492
+ clearTimeout(timeout);
493
+ resolve('[Cubenest] Failed to read body');
494
+ }
495
+ });
496
+ }
497
+ async function _tryReadRequestBody({ r, options, url, }) {
498
+ const { hostname, isHostDenied } = isHostOnDenyList(url, options);
499
+ if (isHostDenied && hostname) {
500
+ return Promise.resolve(`${hostname} is in deny list`);
501
+ }
502
+ return _tryReadBody(r);
503
+ }
504
+ async function _tryReadResponseBody({ r, options, url, }) {
505
+ const cannot = _checkForCannotReadResponseBody({ r, options, url });
506
+ if (!isNull(cannot))
507
+ return Promise.resolve(cannot);
508
+ return _tryReadBody(r);
509
+ }
510
+ // ─── Fetch observer ──────────────────────────────────────────────────────────
511
+ function initFetchObserver(cb, win, options) {
512
+ if (!options.initiatorTypes.includes('fetch')) {
513
+ return () => {
514
+ /* no-op */
515
+ };
516
+ }
517
+ if (typeof win.fetch === 'undefined') {
518
+ return () => {
519
+ /* no-op */
520
+ };
521
+ }
522
+ const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders);
523
+ const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders);
524
+ const restorePatch = patch(win, 'fetch', (originalFetchUnknown) => {
525
+ const originalFetch = originalFetchUnknown;
526
+ return async (url, init) => {
527
+ // Defensive: if `url` is a string and the Request constructor
528
+ // would reject it as a relative URL (some envs — Node/jsdom —
529
+ // require absolute), resolve against the document base first.
530
+ let req;
531
+ try {
532
+ req = new Request(url, init);
533
+ }
534
+ catch {
535
+ const resolved = typeof url === 'string' ? new URL(url, getDocumentBase()).toString() : url;
536
+ req = new Request(resolved, init);
537
+ }
538
+ let res;
539
+ const networkRequest = {};
540
+ let start;
541
+ let end;
542
+ try {
543
+ const requestHeaders = {};
544
+ req.headers.forEach((value, header) => {
545
+ requestHeaders[header] = value;
546
+ });
547
+ if (recordRequestHeaders) {
548
+ networkRequest.requestHeaders = requestHeaders;
549
+ }
550
+ if (shouldRecordBody({
551
+ type: 'request',
552
+ headers: requestHeaders,
553
+ url,
554
+ recordBody: options.recordBody,
555
+ })) {
556
+ networkRequest.requestBody = await _tryReadRequestBody({
557
+ r: req,
558
+ options,
559
+ url,
560
+ });
561
+ }
562
+ start = win.performance.now();
563
+ res = await originalFetch(req);
564
+ end = win.performance.now();
565
+ const responseHeaders = {};
566
+ res.headers.forEach((value, header) => {
567
+ responseHeaders[header] = value;
568
+ });
569
+ if (recordResponseHeaders) {
570
+ networkRequest.responseHeaders = responseHeaders;
571
+ }
572
+ if (shouldRecordBody({
573
+ type: 'response',
574
+ headers: responseHeaders,
575
+ url,
576
+ recordBody: options.recordBody,
577
+ })) {
578
+ networkRequest.responseBody = await _tryReadResponseBody({
579
+ r: res,
580
+ options,
581
+ url,
582
+ });
583
+ }
584
+ return res;
585
+ }
586
+ catch (err) {
587
+ // Network-level error (CORS, offline, abort). Emit a status=0
588
+ // record so the replay panel still shows the attempt, then
589
+ // re-throw so the consumer's catch handler gets the original
590
+ // exception.
591
+ if (start !== undefined && end === undefined) {
592
+ end = win.performance.now();
593
+ }
594
+ const requests = prepareRequest({
595
+ entry: null,
596
+ method: req.method,
597
+ status: 0,
598
+ networkRequest,
599
+ start,
600
+ end,
601
+ url: req.url,
602
+ initiatorType: 'fetch',
603
+ });
604
+ cb({ requests });
605
+ throw err;
606
+ }
607
+ finally {
608
+ if (res !== undefined) {
609
+ void getRequestPerformanceEntry(win, 'fetch', req.url, start, end)
610
+ .then((entry) => {
611
+ const requests = prepareRequest({
612
+ entry,
613
+ method: req.method,
614
+ status: res?.status,
615
+ networkRequest,
616
+ start,
617
+ end,
618
+ url: req.url,
619
+ initiatorType: 'fetch',
620
+ });
621
+ cb({ requests });
622
+ })
623
+ .catch(() => {
624
+ /* ignore */
625
+ });
626
+ }
627
+ }
628
+ };
629
+ });
630
+ return () => {
631
+ restorePatch();
632
+ };
633
+ }
634
+ // ─── Prepare-request shape normalization ─────────────────────────────────────
635
+ const exposesServerTiming = (event) => !isNull(event) && (event.entryType === 'navigation' || event.entryType === 'resource');
636
+ function prepareRequest(args) {
637
+ const { entry, method, status, networkRequest, isInitial, url, initiatorType } = args;
638
+ // Browser-side `entry.startTime` / `entry.responseEnd` are PerformanceEntry
639
+ // API reads — these are NOT our wire-format field names. We capture them
640
+ // into local `start`/`end` and rewrite them as `requestMadeAt`/`responseEnd`
641
+ // on the emitted CapturedNetworkRequest.
642
+ const start = entry ? entry.startTime : args.start;
643
+ const end = entry ? entry.responseEnd : args.end;
644
+ const timeOrigin = Math.floor(Date.now() - performance.now());
645
+ const timestamp = Math.floor(timeOrigin + (start || 0));
646
+ // Strip browser PerformanceEntry property names from the spread so we
647
+ // don't leak `startTime`/`endTime`/`responseEnd` etc. alongside our
648
+ // wire-format names. Anything we want from the entry, we copy
649
+ // explicitly below.
650
+ const entryJSON = entry ? entry.toJSON() : { name: url ?? '' };
651
+ const PERF_ENTRY_NATIVE_KEYS = new Set([
652
+ 'startTime',
653
+ 'endTime',
654
+ 'responseEnd',
655
+ 'duration',
656
+ 'transferSize',
657
+ 'initiatorType',
658
+ 'entryType',
659
+ 'name',
660
+ ]);
661
+ const safeSpread = {};
662
+ for (const [k, v] of Object.entries(entryJSON)) {
663
+ if (!PERF_ENTRY_NATIVE_KEYS.has(k))
664
+ safeSpread[k] = v;
665
+ }
666
+ const baseName = typeof entryJSON.name === 'string' ? entryJSON.name : (url ?? '');
667
+ // Modern Chrome (and other browsers with TAO-allowed timings) expose
668
+ // `responseStatus` on the entry. In default mode (no fetch/XHR
669
+ // wrapping), this is the ONLY status info we get — without it, the
670
+ // emitted record has `status: undefined` and replay tooling can't
671
+ // distinguish success from failure. Read it here so the default
672
+ // PerformanceObserver-only mode is meaningfully informative.
673
+ const entryResponseStatus = entry && 'responseStatus' in entry
674
+ ? entry.responseStatus
675
+ : undefined;
676
+ // Order matters: the spread of `safeSpread` is FIRST so explicit fields
677
+ // below (method/status/headers/body/isInitial) take precedence over
678
+ // anything the browser puts into `toJSON()`.
679
+ const baseRequest = {
680
+ ...safeSpread,
681
+ name: baseName,
682
+ requestMadeAt: isUndefined(start) ? undefined : Math.round(start),
683
+ responseEnd: isUndefined(end) ? undefined : Math.round(end),
684
+ timeOrigin,
685
+ timestamp,
686
+ method,
687
+ initiatorType: initiatorType
688
+ ? initiatorType
689
+ : entry
690
+ ? entry.initiatorType
691
+ : undefined,
692
+ status: status ?? entryResponseStatus,
693
+ requestHeaders: networkRequest.requestHeaders,
694
+ requestBody: networkRequest.requestBody,
695
+ responseHeaders: networkRequest.responseHeaders,
696
+ responseBody: networkRequest.responseBody,
697
+ isInitial,
698
+ duration: entry?.duration,
699
+ transferSize: entry && 'transferSize' in entry
700
+ ? entry.transferSize
701
+ : undefined,
702
+ };
703
+ const requests = [baseRequest];
704
+ if (exposesServerTiming(entry)) {
705
+ for (const timing of entry.serverTiming || []) {
706
+ requests.push({
707
+ timeOrigin,
708
+ timestamp,
709
+ requestMadeAt: Math.round(entry.startTime),
710
+ name: timing.name,
711
+ duration: timing.duration,
712
+ // Synthetic entry type so consumers can correlate to a parent
713
+ // navigation/resource by timestamp + URL.
714
+ entryType: 'serverTiming',
715
+ });
716
+ }
717
+ }
718
+ return requests;
719
+ }
720
+ // ─── Default mask: pipes through redactNetworkHeaders + redactBody ─────────
721
+ const REDACTED_VALUE = '<<REDACTED>>';
722
+ const URL_REDACTED_PARAMS = new Set([
723
+ 'token',
724
+ 'access_token',
725
+ 'refresh_token',
726
+ 'api_key',
727
+ 'apikey',
728
+ 'auth',
729
+ 'password',
730
+ 'secret',
731
+ 'sessionid',
732
+ 'session_id',
733
+ ]);
734
+ /**
735
+ * Redact common credential-shaped query-string params in `url`. Leaves
736
+ * the path + non-matching params intact. Used by {@link buildDefaultMask}
737
+ * so a default install doesn't leak `?token=…` URLs to the recorder.
738
+ */
739
+ function redactUrl(url) {
740
+ try {
741
+ // URL constructor needs an absolute URL; provide a base so relative
742
+ // URLs still parse. Re-stringify; relative URLs round-trip cleanly
743
+ // by stripping the base back off.
744
+ const isAbsolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
745
+ const base = isAbsolute ? undefined : getDocumentBase();
746
+ const parsed = new URL(url, base);
747
+ let touched = false;
748
+ parsed.searchParams.forEach((_, name) => {
749
+ if (URL_REDACTED_PARAMS.has(name.toLowerCase())) {
750
+ parsed.searchParams.set(name, REDACTED_VALUE);
751
+ touched = true;
752
+ }
753
+ });
754
+ if (!touched)
755
+ return url;
756
+ if (isAbsolute)
757
+ return parsed.toString();
758
+ // Strip base back off to preserve the relative form.
759
+ return parsed.pathname + parsed.search + parsed.hash;
760
+ }
761
+ catch {
762
+ return url;
763
+ }
764
+ }
765
+ /**
766
+ * Build the default mask. Pipes through:
767
+ * - URL → `redactUrl` (query-string credential params)
768
+ * - Headers → `redactNetworkHeaders` (deny-list values)
769
+ * - Bodies → `redactBody` (PII regex bank + truncation), then enforces
770
+ * `bodyByteLimit` as a final cap.
771
+ *
772
+ * Returns the modified request — never returns `null`. (Consumer
773
+ * `maskRequestFn` may return null to skip emission entirely.)
774
+ */
775
+ function buildDefaultMask(options) {
776
+ const bodyLimit = options.bodyByteLimit ?? 5_000;
777
+ const totalLimit = options.payloadSizeLimitBytes ?? 1_000_000;
778
+ // Body truncation: redactBody itself can truncate, but we want a
779
+ // separate per-direction cap so consumers can opt into a tighter
780
+ // limit without rewriting the whole regex bank. The smaller of
781
+ // bodyByteLimit and payloadSizeLimitBytes wins (defense in depth).
782
+ const limit = Math.min(bodyLimit, totalLimit);
783
+ const maskBody = (body) => {
784
+ if (body === undefined)
785
+ return undefined;
786
+ const masked = redactBody(body, { maxLengthBytes: limit });
787
+ return masked;
788
+ };
789
+ return (req) => ({
790
+ ...req,
791
+ name: redactUrl(req.name),
792
+ requestHeaders: req.requestHeaders ? redactNetworkHeaders(req.requestHeaders) : undefined,
793
+ responseHeaders: req.responseHeaders ? redactNetworkHeaders(req.responseHeaders) : undefined,
794
+ requestBody: maskBody(req.requestBody),
795
+ responseBody: maskBody(req.responseBody),
796
+ });
797
+ }
798
+ // ─── Observer install + teardown ─────────────────────────────────────────────
799
+ let initialisedHandler = null;
800
+ function initNetworkObserver(callback, win, options) {
801
+ if (!('performance' in win)) {
802
+ return () => {
803
+ /* no-op */
804
+ };
805
+ }
806
+ if (initialisedHandler) {
807
+ logger.warn('Network observer already initialised, doing nothing');
808
+ return () => {
809
+ /* the first caller already owns the teardown */
810
+ };
811
+ }
812
+ const networkOptions = normalizeOptions(options);
813
+ const defaultMask = buildDefaultMask(networkOptions);
814
+ const consumerMask = networkOptions.maskRequestFn;
815
+ const hasConsumerMask = consumerMask !== defaultNetworkOptions.maskRequestFn;
816
+ // If the consumer-supplied mask is the identity default we use a
817
+ // simple wrapper; otherwise compose `defaultMask` then `consumerMask`.
818
+ const composedMask = (req) => {
819
+ const afterDefault = defaultMask(req);
820
+ if (!hasConsumerMask) {
821
+ return afterDefault;
822
+ }
823
+ const afterConsumer = consumerMask(afterDefault);
824
+ if (isNullish(afterConsumer))
825
+ return null;
826
+ return afterConsumer;
827
+ };
828
+ let inflightCount = 0;
829
+ const cb = (data) => {
830
+ const requests = [];
831
+ for (const request of data.requests) {
832
+ // Drop overflow within a single batch so a noisy resource flush
833
+ // never balloons the emitted event.
834
+ if (inflightCount >= networkOptions.maxRequestsPerBatch) {
835
+ break;
836
+ }
837
+ const masked = composedMask(request);
838
+ if (masked) {
839
+ requests.push(masked);
840
+ inflightCount += 1;
841
+ }
842
+ }
843
+ if (requests.length > 0) {
844
+ callback({ ...data, requests });
845
+ }
846
+ };
847
+ // Reset the inflight counter periodically so the `maxRequestsPerBatch`
848
+ // cap is a rolling-window cap rather than a session-lifetime cap. We
849
+ // use `setInterval` at 1s intervals (not microtask scheduling — that
850
+ // would reset between every emission inside a single
851
+ // PerformanceObserver flush, which is the case we actually want to
852
+ // cap).
853
+ const resetCounter = () => {
854
+ inflightCount = 0;
855
+ };
856
+ const counterTimer = setInterval(resetCounter, 1_000);
857
+ const performanceObserver = initPerformanceObserver(cb, win, networkOptions);
858
+ // Only install the body-capturing wrappers when bodies or headers are
859
+ // requested — the PerformanceObserver path alone yields no body/header
860
+ // data, so wrapping is wasted work otherwise.
861
+ let xhrObserver = () => {
862
+ /* no-op */
863
+ };
864
+ let fetchObserver = () => {
865
+ /* no-op */
866
+ };
867
+ if (networkOptions.recordHeaders || networkOptions.recordBody) {
868
+ xhrObserver = initXhrObserver(cb, win, networkOptions);
869
+ fetchObserver = initFetchObserver(cb, win, networkOptions);
870
+ }
871
+ initialisedHandler = () => {
872
+ clearInterval(counterTimer);
873
+ performanceObserver();
874
+ xhrObserver();
875
+ fetchObserver();
876
+ initialisedHandler = null;
877
+ };
878
+ return initialisedHandler;
879
+ }
880
+ // ─── Public surface ──────────────────────────────────────────────────────────
881
+ /**
882
+ * Reset internal state. Test-only — exposed so tests can re-initialize
883
+ * the singleton observer between cases.
884
+ */
885
+ export function _resetNetworkObserverForTests() {
886
+ initialisedHandler = null;
887
+ }
888
+ /**
889
+ * Build the rrweb network capture plugin. Pass to `record({ plugins: [
890
+ * getRecordNetworkPlugin() ] })`.
891
+ *
892
+ * Emits `EventType.Plugin` events with `data.plugin === 'rrweb/network@1'`
893
+ * and `data.payload: NetworkData`.
894
+ *
895
+ * @example
896
+ * record({
897
+ * emit(event) { … },
898
+ * plugins: [
899
+ * getRecordNetworkPlugin({ recordBody: true, recordHeaders: true }),
900
+ * ],
901
+ * });
902
+ */
903
+ export function getRecordNetworkPlugin(options) {
904
+ const observer = (cb, win, opts) => initNetworkObserver(cb, win, opts);
905
+ return {
906
+ name: NETWORK_PLUGIN_NAME,
907
+ observer,
908
+ options: options ?? {},
909
+ };
910
+ }
911
+ //# sourceMappingURL=record.js.map