@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.
- package/LICENSE +202 -0
- package/NOTICE +12 -0
- package/dist/compression/index.d.ts +1 -1
- package/dist/compression/index.d.ts.map +1 -1
- package/dist/console/buffer.d.ts +3 -3
- package/dist/console/buffer.d.ts.map +1 -1
- package/dist/console/buffer.js +1 -1
- package/dist/console/buffer.js.map +1 -1
- package/dist/console/index.d.ts +2 -2
- package/dist/console/index.d.ts.map +1 -1
- package/dist/console/index.js +1 -1
- package/dist/console/index.js.map +1 -1
- package/dist/index.d.ts +18 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -10
- package/dist/index.js.map +1 -1
- package/dist/masking/body.js +1 -1
- package/dist/masking/body.js.map +1 -1
- package/dist/masking/index.d.ts +7 -7
- package/dist/masking/index.d.ts.map +1 -1
- package/dist/masking/index.js +5 -5
- package/dist/masking/index.js.map +1 -1
- package/dist/masking/inputs.js +1 -1
- package/dist/masking/inputs.js.map +1 -1
- package/dist/masking/text.js +1 -1
- package/dist/masking/text.js.map +1 -1
- package/dist/network/cdp.d.ts +1 -1
- package/dist/network/cdp.d.ts.map +1 -1
- package/dist/network/cdp.js +1 -1
- package/dist/network/cdp.js.map +1 -1
- package/dist/network/index.d.ts +3 -3
- package/dist/network/index.d.ts.map +1 -1
- package/dist/network/index.js +2 -2
- package/dist/network/index.js.map +1 -1
- package/dist/network/web-request.d.ts +1 -1
- package/dist/network/web-request.d.ts.map +1 -1
- package/dist/network/web-request.js +1 -1
- package/dist/network/web-request.js.map +1 -1
- package/dist/persistence/index.d.ts +2 -2
- package/dist/persistence/index.d.ts.map +1 -1
- package/dist/persistence/index.js +1 -1
- package/dist/persistence/index.js.map +1 -1
- package/dist/persistence/store.d.ts +1 -1
- package/dist/persistence/store.d.ts.map +1 -1
- package/dist/plugins/network/defaults.d.ts +36 -0
- package/dist/plugins/network/defaults.d.ts.map +1 -0
- package/dist/plugins/network/defaults.js +80 -0
- package/dist/plugins/network/defaults.js.map +1 -0
- package/dist/plugins/network/index.d.ts +3 -0
- package/dist/plugins/network/index.d.ts.map +1 -0
- package/dist/plugins/network/index.js +9 -0
- package/dist/plugins/network/index.js.map +1 -0
- package/dist/plugins/network/patch.d.ts +15 -0
- package/dist/plugins/network/patch.d.ts.map +1 -0
- package/dist/plugins/network/patch.js +64 -0
- package/dist/plugins/network/patch.js.map +1 -0
- package/dist/plugins/network/record.d.ts +38 -0
- package/dist/plugins/network/record.d.ts.map +1 -0
- package/dist/plugins/network/record.js +911 -0
- package/dist/plugins/network/record.js.map +1 -0
- package/dist/plugins/network/types.d.ts +208 -0
- package/dist/plugins/network/types.d.ts.map +1 -0
- package/dist/plugins/network/types.js +16 -0
- package/dist/plugins/network/types.js.map +1 -0
- package/dist/rrweb.d.ts +2 -1
- package/dist/rrweb.d.ts.map +1 -1
- package/dist/rrweb.js +4 -0
- package/dist/rrweb.js.map +1 -1
- package/dist/screenshot/cdp.d.ts +1 -1
- package/dist/screenshot/cdp.d.ts.map +1 -1
- package/dist/screenshot/cdp.js +1 -1
- package/dist/screenshot/cdp.js.map +1 -1
- package/dist/screenshot/index.d.ts +3 -3
- package/dist/screenshot/index.d.ts.map +1 -1
- package/dist/screenshot/index.js +2 -2
- package/dist/screenshot/index.js.map +1 -1
- package/dist/screenshot/tabs.d.ts +1 -1
- package/dist/screenshot/tabs.d.ts.map +1 -1
- package/dist/screenshot/tabs.js +1 -1
- package/dist/screenshot/tabs.js.map +1 -1
- package/dist/shadow-dom/index.d.ts +2 -2
- package/dist/shadow-dom/index.d.ts.map +1 -1
- package/dist/shadow-dom/index.js +1 -1
- package/dist/shadow-dom/index.js.map +1 -1
- package/dist/shadow-dom/traverse.d.ts +1 -1
- package/dist/shadow-dom/traverse.d.ts.map +1 -1
- package/dist/throttling/apply.d.ts +2 -2
- package/dist/throttling/apply.d.ts.map +1 -1
- package/dist/throttling/apply.js +2 -2
- package/dist/throttling/apply.js.map +1 -1
- package/dist/throttling/guards.d.ts +1 -1
- package/dist/throttling/guards.d.ts.map +1 -1
- package/dist/throttling/guards.js +1 -1
- package/dist/throttling/guards.js.map +1 -1
- package/dist/throttling/index.d.ts +4 -4
- package/dist/throttling/index.d.ts.map +1 -1
- package/dist/throttling/index.js +2 -2
- package/dist/throttling/index.js.map +1 -1
- 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
|