@harness-fe/runtime 3.0.1 → 3.2.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/dist/capture.d.ts +82 -11
- package/dist/capture.js +204 -85
- package/dist/client.js +2 -1
- package/dist/commands.js +178 -3
- package/package.json +3 -2
- package/src/capture.ts +233 -87
- package/src/client.ts +5 -1
- package/src/commands.ts +193 -6
- package/src/commandsFilter.test.ts +167 -0
- package/src/commandsNetwork.e2e.test.ts +146 -0
- package/src/runtimeClient.e2e.test.ts +264 -0
- package/dist/fetchPatch.d.ts +0 -39
- package/dist/fetchPatch.js +0 -311
- package/dist/xhrPatch.d.ts +0 -26
- package/dist/xhrPatch.js +0 -269
- package/src/fetchPatch.test.ts +0 -203
- package/src/fetchPatch.ts +0 -371
- package/src/xhrPatch.test.ts +0 -191
- package/src/xhrPatch.ts +0 -314
package/dist/fetchPatch.js
DELETED
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* fetch monkey-patch — captures URL/method/headers/body for request and
|
|
3
|
-
* response, including streaming SSE responses, without changing
|
|
4
|
-
* business-observable fetch behavior.
|
|
5
|
-
*
|
|
6
|
-
* Safety contract (do not weaken without updating the spec):
|
|
7
|
-
* 1. Identity-preserving: replacement is a named `fetch`, with
|
|
8
|
-
* defineProperty'd name/length/toString so library fingerprint checks
|
|
9
|
-
* still pass. Response / Request instances are NOT wrapped.
|
|
10
|
-
* 2. Error-isolated: capture failures are swallowed via `safeEmit`; they
|
|
11
|
-
* NEVER propagate to business code.
|
|
12
|
-
* 3. No timing or value change: the original Promise is returned to the
|
|
13
|
-
* caller unchanged. body capture reads `response.clone()` on a side
|
|
14
|
-
* branch — the business path retains an untouched stream.
|
|
15
|
-
* 4. Self-traffic guard: requests carrying `init.__hfeInternal === true`
|
|
16
|
-
* short-circuit to the original fetch. A URL denylist also skips HMR /
|
|
17
|
-
* dev-server traffic to prevent capture feedback loops.
|
|
18
|
-
* 5. Bounded memory: bodies are capped at BODY_CAP per request. SSE
|
|
19
|
-
* streams stop accumulating and `cancel()` the cloned reader once
|
|
20
|
-
* the cap is hit.
|
|
21
|
-
*
|
|
22
|
-
* The patch is idempotent (re-install is a no-op) and returns a dispose
|
|
23
|
-
* function that restores the original `window.fetch`.
|
|
24
|
-
*/
|
|
25
|
-
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
26
|
-
const INTERNAL_FLAG = '__hfeInternal';
|
|
27
|
-
const PATCHED_FLAG = '__hfePatched';
|
|
28
|
-
const DEFAULT_DENYLIST = [/\/__hfe\//, /sockjs-node/, /\.hot-update\./];
|
|
29
|
-
const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
|
|
30
|
-
/**
|
|
31
|
-
* Install the fetch patch. Returns a dispose function that restores the
|
|
32
|
-
* original window.fetch. Safe to call multiple times (subsequent calls
|
|
33
|
-
* are no-ops while a patch is active).
|
|
34
|
-
*/
|
|
35
|
-
export function installFetchPatch(opts) {
|
|
36
|
-
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
|
37
|
-
return () => { };
|
|
38
|
-
}
|
|
39
|
-
const original = window.fetch;
|
|
40
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
-
if (original[PATCHED_FLAG])
|
|
42
|
-
return () => { };
|
|
43
|
-
const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
|
|
44
|
-
const denylist = opts.denylist ?? DEFAULT_DENYLIST;
|
|
45
|
-
const emit = (entry) => safeEmit(opts.onEntry, entry);
|
|
46
|
-
// Named function so .name === 'fetch'.
|
|
47
|
-
const patched = function fetch(input, init) {
|
|
48
|
-
// Self-traffic short-circuit — internal requests bypass capture.
|
|
49
|
-
if (init && init[INTERNAL_FLAG]) {
|
|
50
|
-
return original.call(this, input, init);
|
|
51
|
-
}
|
|
52
|
-
const meta = extractRequestMeta(input, init);
|
|
53
|
-
if (denylist.some((re) => re.test(meta.url))) {
|
|
54
|
-
return original.call(this, input, init);
|
|
55
|
-
}
|
|
56
|
-
const id = generateId();
|
|
57
|
-
const startedAt = performance.now();
|
|
58
|
-
const startedTs = Date.now();
|
|
59
|
-
// Emit request record eagerly (req body is read async — second emit
|
|
60
|
-
// updates the record once body is serialized; consumers join by id).
|
|
61
|
-
const reqRecord = {
|
|
62
|
-
ts: startedTs,
|
|
63
|
-
id,
|
|
64
|
-
phase: 'req',
|
|
65
|
-
method: meta.method,
|
|
66
|
-
url: meta.url,
|
|
67
|
-
requestHeaders: meta.headers,
|
|
68
|
-
};
|
|
69
|
-
emit(reqRecord);
|
|
70
|
-
cloneRequestBody(input, init, bodyCap).then(({ body, truncated }) => {
|
|
71
|
-
if (body === undefined && !truncated)
|
|
72
|
-
return;
|
|
73
|
-
emit({
|
|
74
|
-
...reqRecord,
|
|
75
|
-
requestBody: body,
|
|
76
|
-
requestBodyTruncated: truncated || undefined,
|
|
77
|
-
});
|
|
78
|
-
}, () => {
|
|
79
|
-
/* serialization error — ignore, req already emitted */
|
|
80
|
-
});
|
|
81
|
-
const promise = original.call(this, input, init);
|
|
82
|
-
promise.then((response) => {
|
|
83
|
-
let cloned;
|
|
84
|
-
try {
|
|
85
|
-
cloned = response.clone();
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
emit({
|
|
89
|
-
ts: Date.now(),
|
|
90
|
-
id,
|
|
91
|
-
phase: 'res',
|
|
92
|
-
method: meta.method,
|
|
93
|
-
url: meta.url,
|
|
94
|
-
status: response.status,
|
|
95
|
-
responseHeaders: headersToObject(response.headers),
|
|
96
|
-
durationMs: performance.now() - startedAt,
|
|
97
|
-
});
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const finalize = (body, truncated) => {
|
|
101
|
-
emit({
|
|
102
|
-
ts: Date.now(),
|
|
103
|
-
id,
|
|
104
|
-
phase: 'res',
|
|
105
|
-
method: meta.method,
|
|
106
|
-
url: meta.url,
|
|
107
|
-
status: response.status,
|
|
108
|
-
responseHeaders: headersToObject(response.headers),
|
|
109
|
-
responseBody: body,
|
|
110
|
-
responseBodyTruncated: truncated || undefined,
|
|
111
|
-
durationMs: performance.now() - startedAt,
|
|
112
|
-
});
|
|
113
|
-
};
|
|
114
|
-
const ct = response.headers.get('content-type') ?? '';
|
|
115
|
-
if (isSSE(ct)) {
|
|
116
|
-
pumpSSE(cloned, bodyCap, finalize);
|
|
117
|
-
}
|
|
118
|
-
else if (isTextLike(ct)) {
|
|
119
|
-
readTextWithCap(cloned, bodyCap).then(({ body, truncated }) => finalize(maybeParseJson(body, ct), truncated), () => finalize(undefined, false));
|
|
120
|
-
}
|
|
121
|
-
else {
|
|
122
|
-
// Binary or unknown: only record size, do not pull bytes.
|
|
123
|
-
cloned.arrayBuffer().then((buf) => finalize(`[binary ${buf.byteLength}B]`, false), () => finalize(undefined, false));
|
|
124
|
-
}
|
|
125
|
-
}, (err) => {
|
|
126
|
-
emit({
|
|
127
|
-
ts: Date.now(),
|
|
128
|
-
id,
|
|
129
|
-
phase: 'res',
|
|
130
|
-
method: meta.method,
|
|
131
|
-
url: meta.url,
|
|
132
|
-
durationMs: performance.now() - startedAt,
|
|
133
|
-
error: err instanceof Error ? err.message : String(err),
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
return promise;
|
|
137
|
-
};
|
|
138
|
-
// Preserve fingerprint — library detection commonly inspects these.
|
|
139
|
-
try {
|
|
140
|
-
Object.defineProperty(patched, 'name', { value: 'fetch' });
|
|
141
|
-
Object.defineProperty(patched, 'length', { value: original.length });
|
|
142
|
-
Object.defineProperty(patched, 'toString', {
|
|
143
|
-
value: () => original.toString(),
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
/* sealed property — safe to skip */
|
|
148
|
-
}
|
|
149
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
|
-
patched[PATCHED_FLAG] = true;
|
|
151
|
-
window.fetch = patched;
|
|
152
|
-
return () => {
|
|
153
|
-
if (window.fetch === patched) {
|
|
154
|
-
window.fetch = original;
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
159
|
-
function generateId() {
|
|
160
|
-
try {
|
|
161
|
-
return crypto.randomUUID();
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
function extractRequestMeta(input, init) {
|
|
168
|
-
let url;
|
|
169
|
-
let method = 'GET';
|
|
170
|
-
let headers;
|
|
171
|
-
if (typeof input === 'string') {
|
|
172
|
-
url = input;
|
|
173
|
-
}
|
|
174
|
-
else if (input instanceof URL) {
|
|
175
|
-
url = input.toString();
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
url = input.url;
|
|
179
|
-
method = input.method;
|
|
180
|
-
headers = headersToObject(input.headers);
|
|
181
|
-
}
|
|
182
|
-
if (init?.method)
|
|
183
|
-
method = init.method;
|
|
184
|
-
if (init?.headers) {
|
|
185
|
-
headers = { ...(headers ?? {}), ...(headersToObject(init.headers) ?? {}) };
|
|
186
|
-
}
|
|
187
|
-
return { url, method, headers };
|
|
188
|
-
}
|
|
189
|
-
function headersToObject(h) {
|
|
190
|
-
if (!h)
|
|
191
|
-
return undefined;
|
|
192
|
-
const out = {};
|
|
193
|
-
if (h instanceof Headers) {
|
|
194
|
-
h.forEach((v, k) => {
|
|
195
|
-
out[k] = v;
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
else if (Array.isArray(h)) {
|
|
199
|
-
for (const [k, v] of h)
|
|
200
|
-
out[k] = v;
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
Object.assign(out, h);
|
|
204
|
-
}
|
|
205
|
-
return redactHeaders(out);
|
|
206
|
-
}
|
|
207
|
-
function redactHeaders(h) {
|
|
208
|
-
const out = {};
|
|
209
|
-
for (const [k, v] of Object.entries(h)) {
|
|
210
|
-
out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
|
|
211
|
-
}
|
|
212
|
-
return out;
|
|
213
|
-
}
|
|
214
|
-
async function cloneRequestBody(input, init, cap) {
|
|
215
|
-
if (init?.body !== undefined && init.body !== null) {
|
|
216
|
-
return serializeBodyInit(init.body, cap);
|
|
217
|
-
}
|
|
218
|
-
if (input instanceof Request && input.body) {
|
|
219
|
-
try {
|
|
220
|
-
const text = await input.clone().text();
|
|
221
|
-
return capText(text, cap);
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
return { truncated: false };
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
return { truncated: false };
|
|
228
|
-
}
|
|
229
|
-
function serializeBodyInit(body, cap) {
|
|
230
|
-
if (typeof body === 'string')
|
|
231
|
-
return capText(body, cap);
|
|
232
|
-
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
233
|
-
const obj = {};
|
|
234
|
-
body.forEach((v, k) => {
|
|
235
|
-
obj[k] = typeof v === 'string'
|
|
236
|
-
? v
|
|
237
|
-
: `[File ${v.name} ${v.size}B]`;
|
|
238
|
-
});
|
|
239
|
-
return { body: obj, truncated: false };
|
|
240
|
-
}
|
|
241
|
-
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
242
|
-
return { body: body.toString(), truncated: false };
|
|
243
|
-
}
|
|
244
|
-
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
245
|
-
return { body: `[Blob ${body.size}B]`, truncated: false };
|
|
246
|
-
}
|
|
247
|
-
if (body instanceof ArrayBuffer) {
|
|
248
|
-
return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
|
|
249
|
-
}
|
|
250
|
-
if (ArrayBuffer.isView(body)) {
|
|
251
|
-
return { body: `[${body.constructor.name} ${body.byteLength}B]`, truncated: false };
|
|
252
|
-
}
|
|
253
|
-
return { body: '[unknown body]', truncated: false };
|
|
254
|
-
}
|
|
255
|
-
function capText(text, cap) {
|
|
256
|
-
if (text.length > cap)
|
|
257
|
-
return { body: text.slice(0, cap), truncated: true };
|
|
258
|
-
return { body: text, truncated: false };
|
|
259
|
-
}
|
|
260
|
-
async function readTextWithCap(res, cap) {
|
|
261
|
-
const text = await res.text();
|
|
262
|
-
return capText(text, cap);
|
|
263
|
-
}
|
|
264
|
-
function maybeParseJson(text, contentType) {
|
|
265
|
-
if (!/json/i.test(contentType))
|
|
266
|
-
return text;
|
|
267
|
-
try {
|
|
268
|
-
return JSON.parse(text);
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
return text;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
function isSSE(ct) {
|
|
275
|
-
return /text\/event-stream/i.test(ct);
|
|
276
|
-
}
|
|
277
|
-
function isTextLike(ct) {
|
|
278
|
-
return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
|
|
279
|
-
}
|
|
280
|
-
function pumpSSE(res, cap, done) {
|
|
281
|
-
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
282
|
-
return done('', false);
|
|
283
|
-
}
|
|
284
|
-
const reader = res.body.getReader();
|
|
285
|
-
const dec = new TextDecoder();
|
|
286
|
-
let total = '';
|
|
287
|
-
let truncated = false;
|
|
288
|
-
const step = () => {
|
|
289
|
-
reader.read().then(({ done: end, value }) => {
|
|
290
|
-
if (end)
|
|
291
|
-
return done(total, truncated);
|
|
292
|
-
total += dec.decode(value, { stream: true });
|
|
293
|
-
if (total.length > cap) {
|
|
294
|
-
total = total.slice(0, cap);
|
|
295
|
-
truncated = true;
|
|
296
|
-
reader.cancel().catch(() => { });
|
|
297
|
-
return done(total, truncated);
|
|
298
|
-
}
|
|
299
|
-
step();
|
|
300
|
-
}, () => done(total, truncated));
|
|
301
|
-
};
|
|
302
|
-
step();
|
|
303
|
-
}
|
|
304
|
-
function safeEmit(fn, entry) {
|
|
305
|
-
try {
|
|
306
|
-
fn(entry);
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
/* swallow — capture must not break business */
|
|
310
|
-
}
|
|
311
|
-
}
|
package/dist/xhrPatch.d.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* XMLHttpRequest monkey-patch — captures URL/method/headers/body for
|
|
3
|
-
* request and response WITHOUT replacing the XMLHttpRequest constructor.
|
|
4
|
-
*
|
|
5
|
-
* The previous implementation wrapped `window.XMLHttpRequest` with a new
|
|
6
|
-
* constructor, which broke `xhr instanceof XMLHttpRequest` checks in
|
|
7
|
-
* business code. This patch attaches per-instance metadata via a
|
|
8
|
-
* non-enumerable Symbol key and overrides only prototype methods, leaving
|
|
9
|
-
* the constructor and prototype chain native.
|
|
10
|
-
*
|
|
11
|
-
* Capture rules mirror fetchPatch.ts:
|
|
12
|
-
* - 256 KB body cap with content-type routing (json / text / binary)
|
|
13
|
-
* - Sensitive header redaction (Authorization / Cookie / x-api-key / x-auth-*)
|
|
14
|
-
* - Two events per request keyed by a shared `id` (`phase: 'req' | 'res'`)
|
|
15
|
-
* - Errors inside capture are swallowed via `safeEmit`
|
|
16
|
-
* - `__hfeInternal` opt-out via a magic header `x-hfe-internal: 1`
|
|
17
|
-
* (XHR has no init-style options bag like fetch)
|
|
18
|
-
* - Idempotent install + dispose() restores original prototype methods
|
|
19
|
-
*/
|
|
20
|
-
import type { NetworkEntry } from '@harness-fe/protocol';
|
|
21
|
-
export interface XhrPatchOptions {
|
|
22
|
-
onEntry: (entry: NetworkEntry) => void;
|
|
23
|
-
bodyCap?: number;
|
|
24
|
-
denylist?: RegExp[];
|
|
25
|
-
}
|
|
26
|
-
export declare function installXhrPatch(opts: XhrPatchOptions): () => void;
|
package/dist/xhrPatch.js
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* XMLHttpRequest monkey-patch — captures URL/method/headers/body for
|
|
3
|
-
* request and response WITHOUT replacing the XMLHttpRequest constructor.
|
|
4
|
-
*
|
|
5
|
-
* The previous implementation wrapped `window.XMLHttpRequest` with a new
|
|
6
|
-
* constructor, which broke `xhr instanceof XMLHttpRequest` checks in
|
|
7
|
-
* business code. This patch attaches per-instance metadata via a
|
|
8
|
-
* non-enumerable Symbol key and overrides only prototype methods, leaving
|
|
9
|
-
* the constructor and prototype chain native.
|
|
10
|
-
*
|
|
11
|
-
* Capture rules mirror fetchPatch.ts:
|
|
12
|
-
* - 256 KB body cap with content-type routing (json / text / binary)
|
|
13
|
-
* - Sensitive header redaction (Authorization / Cookie / x-api-key / x-auth-*)
|
|
14
|
-
* - Two events per request keyed by a shared `id` (`phase: 'req' | 'res'`)
|
|
15
|
-
* - Errors inside capture are swallowed via `safeEmit`
|
|
16
|
-
* - `__hfeInternal` opt-out via a magic header `x-hfe-internal: 1`
|
|
17
|
-
* (XHR has no init-style options bag like fetch)
|
|
18
|
-
* - Idempotent install + dispose() restores original prototype methods
|
|
19
|
-
*/
|
|
20
|
-
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
21
|
-
const PATCHED_FLAG = '__hfeXhrPatched';
|
|
22
|
-
const META_KEY = Symbol.for('@harness-fe/xhr-meta');
|
|
23
|
-
const INTERNAL_HEADER = 'x-hfe-internal';
|
|
24
|
-
const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
|
|
25
|
-
export function installXhrPatch(opts) {
|
|
26
|
-
if (typeof XMLHttpRequest === 'undefined')
|
|
27
|
-
return () => { };
|
|
28
|
-
const proto = XMLHttpRequest.prototype;
|
|
29
|
-
if (proto[PATCHED_FLAG])
|
|
30
|
-
return () => { };
|
|
31
|
-
const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
|
|
32
|
-
const denylist = opts.denylist ?? [];
|
|
33
|
-
const emit = (entry) => safeEmit(opts.onEntry, entry);
|
|
34
|
-
const origOpen = proto.open;
|
|
35
|
-
const origSetHeader = proto.setRequestHeader;
|
|
36
|
-
const origSend = proto.send;
|
|
37
|
-
const patchedOpen = function open(method, url, ...rest) {
|
|
38
|
-
const meta = {
|
|
39
|
-
id: generateId(),
|
|
40
|
-
method,
|
|
41
|
-
url: typeof url === 'string' ? url : url.toString(),
|
|
42
|
-
headers: {},
|
|
43
|
-
startedAt: 0,
|
|
44
|
-
startedTs: 0,
|
|
45
|
-
bodyCap,
|
|
46
|
-
internal: false,
|
|
47
|
-
skipped: denylist.some((re) => re.test(typeof url === 'string' ? url : url.toString())),
|
|
48
|
-
reqEmitted: false,
|
|
49
|
-
};
|
|
50
|
-
this[META_KEY] = meta;
|
|
51
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
-
return origOpen.call(this, method, url, ...rest);
|
|
53
|
-
};
|
|
54
|
-
const patchedSetHeader = function setRequestHeader(name, value) {
|
|
55
|
-
const meta = this[META_KEY];
|
|
56
|
-
if (meta) {
|
|
57
|
-
if (name.toLowerCase() === INTERNAL_HEADER) {
|
|
58
|
-
meta.internal = true;
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
meta.headers[name] = value;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
// Do NOT forward the internal sentinel to the server.
|
|
65
|
-
if (name.toLowerCase() === INTERNAL_HEADER)
|
|
66
|
-
return;
|
|
67
|
-
return origSetHeader.call(this, name, value);
|
|
68
|
-
};
|
|
69
|
-
const patchedSend = function send(body) {
|
|
70
|
-
const meta = this[META_KEY];
|
|
71
|
-
if (!meta || meta.internal || meta.skipped) {
|
|
72
|
-
return origSend.call(this, body ?? null);
|
|
73
|
-
}
|
|
74
|
-
meta.startedAt = performance.now();
|
|
75
|
-
meta.startedTs = Date.now();
|
|
76
|
-
// Emit req eagerly with headers; body added on second emit after
|
|
77
|
-
// serialization (mirrors fetchPatch behavior).
|
|
78
|
-
const reqRecord = {
|
|
79
|
-
ts: meta.startedTs,
|
|
80
|
-
id: meta.id,
|
|
81
|
-
phase: 'req',
|
|
82
|
-
method: meta.method,
|
|
83
|
-
url: meta.url,
|
|
84
|
-
requestHeaders: redactHeaders(meta.headers),
|
|
85
|
-
};
|
|
86
|
-
emit(reqRecord);
|
|
87
|
-
meta.reqEmitted = true;
|
|
88
|
-
const serialized = serializeBody(body, meta.bodyCap);
|
|
89
|
-
if (serialized.body !== undefined || serialized.truncated) {
|
|
90
|
-
emit({
|
|
91
|
-
...reqRecord,
|
|
92
|
-
requestBody: serialized.body,
|
|
93
|
-
requestBodyTruncated: serialized.truncated || undefined,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
this.addEventListener('loadend', () => {
|
|
97
|
-
const status = this.status;
|
|
98
|
-
const ct = safeGetResponseHeader(this, 'content-type') ?? '';
|
|
99
|
-
const respHeaders = parseAllResponseHeaders(this);
|
|
100
|
-
const isErr = status === 0;
|
|
101
|
-
const baseRes = {
|
|
102
|
-
ts: Date.now(),
|
|
103
|
-
id: meta.id,
|
|
104
|
-
phase: 'res',
|
|
105
|
-
method: meta.method,
|
|
106
|
-
url: meta.url,
|
|
107
|
-
status: isErr ? undefined : status,
|
|
108
|
-
responseHeaders: respHeaders,
|
|
109
|
-
durationMs: performance.now() - meta.startedAt,
|
|
110
|
-
};
|
|
111
|
-
if (isErr) {
|
|
112
|
-
emit({ ...baseRes, error: 'xhr error or aborted' });
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (isTextLike(ct)) {
|
|
116
|
-
const text = safeReadResponseText(this);
|
|
117
|
-
if (text === undefined) {
|
|
118
|
-
emit(baseRes);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
const capped = capText(text, meta.bodyCap);
|
|
122
|
-
emit({
|
|
123
|
-
...baseRes,
|
|
124
|
-
responseBody: /json/i.test(ct) ? safeParseJson(capped.body) : capped.body,
|
|
125
|
-
responseBodyTruncated: capped.truncated || undefined,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
// Binary or unknown → don't pull bytes, just record size when available.
|
|
130
|
-
const len = Number(safeGetResponseHeader(this, 'content-length') ?? '0') || 0;
|
|
131
|
-
emit({
|
|
132
|
-
...baseRes,
|
|
133
|
-
responseBody: len ? `[binary ${len}B]` : '[binary]',
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
return origSend.call(this, body ?? null);
|
|
138
|
-
};
|
|
139
|
-
proto.open = patchedOpen;
|
|
140
|
-
proto.setRequestHeader = patchedSetHeader;
|
|
141
|
-
proto.send = patchedSend;
|
|
142
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
143
|
-
proto[PATCHED_FLAG] = true;
|
|
144
|
-
return () => {
|
|
145
|
-
// Only restore if we still own the patch — don't clobber a later patch.
|
|
146
|
-
if (proto.open !== patchedOpen)
|
|
147
|
-
return;
|
|
148
|
-
proto.open = origOpen;
|
|
149
|
-
proto.setRequestHeader = origSetHeader;
|
|
150
|
-
proto.send = origSend;
|
|
151
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
-
delete proto[PATCHED_FLAG];
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
156
|
-
function generateId() {
|
|
157
|
-
try {
|
|
158
|
-
return crypto.randomUUID();
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
function redactHeaders(h) {
|
|
165
|
-
const out = {};
|
|
166
|
-
for (const [k, v] of Object.entries(h)) {
|
|
167
|
-
out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
|
|
168
|
-
}
|
|
169
|
-
return out;
|
|
170
|
-
}
|
|
171
|
-
function serializeBody(body, cap) {
|
|
172
|
-
if (body === undefined || body === null)
|
|
173
|
-
return { truncated: false };
|
|
174
|
-
if (typeof body === 'string')
|
|
175
|
-
return capText(body, cap);
|
|
176
|
-
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
177
|
-
const obj = {};
|
|
178
|
-
body.forEach((v, k) => {
|
|
179
|
-
obj[k] = typeof v === 'string'
|
|
180
|
-
? v
|
|
181
|
-
: `[File ${v.name} ${v.size}B]`;
|
|
182
|
-
});
|
|
183
|
-
return { body: obj, truncated: false };
|
|
184
|
-
}
|
|
185
|
-
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
186
|
-
return { body: body.toString(), truncated: false };
|
|
187
|
-
}
|
|
188
|
-
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
189
|
-
return { body: `[Blob ${body.size}B]`, truncated: false };
|
|
190
|
-
}
|
|
191
|
-
if (body instanceof ArrayBuffer) {
|
|
192
|
-
return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
|
|
193
|
-
}
|
|
194
|
-
if (ArrayBuffer.isView(body)) {
|
|
195
|
-
return {
|
|
196
|
-
body: `[${body.constructor.name} ${body.byteLength}B]`,
|
|
197
|
-
truncated: false,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
if (typeof Document !== 'undefined' && body instanceof Document) {
|
|
201
|
-
return { body: '[Document]', truncated: false };
|
|
202
|
-
}
|
|
203
|
-
return { body: '[unknown body]', truncated: false };
|
|
204
|
-
}
|
|
205
|
-
function capText(text, cap) {
|
|
206
|
-
if (text.length > cap)
|
|
207
|
-
return { body: text.slice(0, cap), truncated: true };
|
|
208
|
-
return { body: text, truncated: false };
|
|
209
|
-
}
|
|
210
|
-
function safeGetResponseHeader(xhr, name) {
|
|
211
|
-
try {
|
|
212
|
-
return xhr.getResponseHeader(name);
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
function parseAllResponseHeaders(xhr) {
|
|
219
|
-
let raw;
|
|
220
|
-
try {
|
|
221
|
-
raw = xhr.getAllResponseHeaders();
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
return undefined;
|
|
225
|
-
}
|
|
226
|
-
if (!raw)
|
|
227
|
-
return undefined;
|
|
228
|
-
const out = {};
|
|
229
|
-
for (const line of raw.split('\r\n')) {
|
|
230
|
-
const idx = line.indexOf(':');
|
|
231
|
-
if (idx < 0)
|
|
232
|
-
continue;
|
|
233
|
-
const k = line.slice(0, idx).trim();
|
|
234
|
-
const v = line.slice(idx + 1).trim();
|
|
235
|
-
if (k)
|
|
236
|
-
out[k] = v;
|
|
237
|
-
}
|
|
238
|
-
return Object.keys(out).length ? redactHeaders(out) : undefined;
|
|
239
|
-
}
|
|
240
|
-
function safeReadResponseText(xhr) {
|
|
241
|
-
try {
|
|
242
|
-
// responseType must be '' or 'text' to access responseText.
|
|
243
|
-
if (xhr.responseType !== '' && xhr.responseType !== 'text')
|
|
244
|
-
return undefined;
|
|
245
|
-
return xhr.responseText;
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
return undefined;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
function safeParseJson(text) {
|
|
252
|
-
try {
|
|
253
|
-
return JSON.parse(text);
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
return text;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
function isTextLike(ct) {
|
|
260
|
-
return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
|
|
261
|
-
}
|
|
262
|
-
function safeEmit(fn, entry) {
|
|
263
|
-
try {
|
|
264
|
-
fn(entry);
|
|
265
|
-
}
|
|
266
|
-
catch {
|
|
267
|
-
/* swallow */
|
|
268
|
-
}
|
|
269
|
-
}
|