@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/src/fetchPatch.test.ts
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
-
import { installFetchPatch } from './fetchPatch.js';
|
|
4
|
-
import type { NetworkEntry } from '@harness-fe/protocol';
|
|
5
|
-
|
|
6
|
-
// happy-dom installs `window.fetch` via Node's undici. We override with a
|
|
7
|
-
// controllable mock per test so we can shape the Response exactly.
|
|
8
|
-
|
|
9
|
-
let originalFetch: typeof globalThis.fetch;
|
|
10
|
-
let entries: NetworkEntry[];
|
|
11
|
-
let dispose: () => void;
|
|
12
|
-
|
|
13
|
-
function setMockFetch(impl: typeof globalThis.fetch): void {
|
|
14
|
-
window.fetch = impl as typeof window.fetch;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
18
|
-
return new Response(JSON.stringify(body), {
|
|
19
|
-
status: 200,
|
|
20
|
-
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
|
21
|
-
...init,
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
|
26
|
-
const enc = new TextEncoder();
|
|
27
|
-
let i = 0;
|
|
28
|
-
return new ReadableStream({
|
|
29
|
-
pull(controller) {
|
|
30
|
-
if (i < chunks.length) {
|
|
31
|
-
controller.enqueue(enc.encode(chunks[i++]));
|
|
32
|
-
} else {
|
|
33
|
-
controller.close();
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
beforeEach(() => {
|
|
40
|
-
originalFetch = window.fetch;
|
|
41
|
-
entries = [];
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
afterEach(() => {
|
|
45
|
-
dispose?.();
|
|
46
|
-
window.fetch = originalFetch;
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe('installFetchPatch — identity', () => {
|
|
50
|
-
it('keeps fetch.name === "fetch"', () => {
|
|
51
|
-
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
52
|
-
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
53
|
-
expect(window.fetch.name).toBe('fetch');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('returns the original Response without wrapping', async () => {
|
|
57
|
-
const original = new Response('hello', { status: 201 });
|
|
58
|
-
setMockFetch(() => Promise.resolve(original));
|
|
59
|
-
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
60
|
-
const got = await window.fetch('http://x/');
|
|
61
|
-
// business reads the body — capture must not have stolen it
|
|
62
|
-
await expect(got.text()).resolves.toBe('hello');
|
|
63
|
-
expect(got.status).toBe(201);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('dispose restores window.fetch', () => {
|
|
67
|
-
const mock: typeof globalThis.fetch = () => Promise.resolve(new Response());
|
|
68
|
-
setMockFetch(mock);
|
|
69
|
-
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
70
|
-
expect(window.fetch).not.toBe(mock);
|
|
71
|
-
dispose();
|
|
72
|
-
dispose = () => {};
|
|
73
|
-
expect(window.fetch).toBe(mock);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('re-install while patched is a no-op', () => {
|
|
77
|
-
setMockFetch(() => Promise.resolve(new Response()));
|
|
78
|
-
const d1 = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
79
|
-
const first = window.fetch;
|
|
80
|
-
const d2 = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
81
|
-
expect(window.fetch).toBe(first);
|
|
82
|
-
d2(); // no-op
|
|
83
|
-
d1();
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('installFetchPatch — emission', () => {
|
|
88
|
-
it('emits a req event eagerly and a res event after completion', async () => {
|
|
89
|
-
setMockFetch(() => Promise.resolve(jsonResponse({ ok: true })));
|
|
90
|
-
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
91
|
-
await window.fetch('http://x/', { method: 'POST', body: '{"k":1}' });
|
|
92
|
-
// give the body-clone branch a chance to flush
|
|
93
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
94
|
-
const ids = new Set(entries.map((e) => e.id));
|
|
95
|
-
expect(ids.size).toBe(1);
|
|
96
|
-
const phases = entries.map((e) => e.phase);
|
|
97
|
-
expect(phases).toContain('req');
|
|
98
|
-
expect(phases).toContain('res');
|
|
99
|
-
const res = entries.find((e) => e.phase === 'res')!;
|
|
100
|
-
expect(res.status).toBe(200);
|
|
101
|
-
expect(res.responseBody).toEqual({ ok: true });
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('captures and caps a large JSON response body', async () => {
|
|
105
|
-
const huge = 'x'.repeat(2000);
|
|
106
|
-
setMockFetch(() => Promise.resolve(jsonResponse({ s: huge })));
|
|
107
|
-
dispose = installFetchPatch({
|
|
108
|
-
onEntry: (e) => entries.push(e),
|
|
109
|
-
bodyCap: 500,
|
|
110
|
-
});
|
|
111
|
-
await window.fetch('http://x/');
|
|
112
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
113
|
-
const res = entries.find((e) => e.phase === 'res')!;
|
|
114
|
-
expect(res.responseBodyTruncated).toBe(true);
|
|
115
|
-
// body is the raw truncated text when JSON.parse fails
|
|
116
|
-
expect(typeof res.responseBody).toBe('string');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('redacts sensitive request headers', async () => {
|
|
120
|
-
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
121
|
-
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
122
|
-
await window.fetch('http://x/', {
|
|
123
|
-
method: 'POST',
|
|
124
|
-
headers: {
|
|
125
|
-
Authorization: 'Bearer abc.def.ghi',
|
|
126
|
-
'X-Api-Key': 'sk-12345',
|
|
127
|
-
'content-type': 'text/plain',
|
|
128
|
-
},
|
|
129
|
-
body: 'hi',
|
|
130
|
-
});
|
|
131
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
132
|
-
const req = entries.find((e) => e.phase === 'req' && e.requestHeaders)!;
|
|
133
|
-
expect(req.requestHeaders!.Authorization).toMatch(/^\[redacted \d+\]$/);
|
|
134
|
-
expect(req.requestHeaders!['X-Api-Key']).toMatch(/^\[redacted \d+\]$/);
|
|
135
|
-
// non-sensitive header is preserved
|
|
136
|
-
expect(req.requestHeaders!['content-type']).toBe('text/plain');
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('emits res with error field when underlying fetch rejects', async () => {
|
|
140
|
-
setMockFetch(() => Promise.reject(new Error('network down')));
|
|
141
|
-
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
142
|
-
await expect(window.fetch('http://x/')).rejects.toThrow('network down');
|
|
143
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
144
|
-
const res = entries.find((e) => e.phase === 'res')!;
|
|
145
|
-
expect(res.error).toBe('network down');
|
|
146
|
-
expect(res.status).toBeUndefined();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('caps an SSE stream and cancels the cloned reader', async () => {
|
|
150
|
-
const longChunk = 'data: ' + 'x'.repeat(2000) + '\n\n';
|
|
151
|
-
setMockFetch(() =>
|
|
152
|
-
Promise.resolve(
|
|
153
|
-
new Response(sseStream([longChunk, longChunk, longChunk]), {
|
|
154
|
-
status: 200,
|
|
155
|
-
headers: { 'content-type': 'text/event-stream' },
|
|
156
|
-
}),
|
|
157
|
-
),
|
|
158
|
-
);
|
|
159
|
-
dispose = installFetchPatch({
|
|
160
|
-
onEntry: (e) => entries.push(e),
|
|
161
|
-
bodyCap: 500,
|
|
162
|
-
});
|
|
163
|
-
const res = await window.fetch('http://x/');
|
|
164
|
-
// business can still read its own stream
|
|
165
|
-
await res.text();
|
|
166
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
167
|
-
const captured = entries.find((e) => e.phase === 'res')!;
|
|
168
|
-
expect(captured.responseBodyTruncated).toBe(true);
|
|
169
|
-
expect((captured.responseBody as string).length).toBe(500);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('skips internal traffic when __hfeInternal flag is set', async () => {
|
|
173
|
-
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
174
|
-
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
175
|
-
await window.fetch('http://x/', {
|
|
176
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
|
-
...({ __hfeInternal: true } as any),
|
|
178
|
-
});
|
|
179
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
180
|
-
expect(entries).toHaveLength(0);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('skips denylisted URLs', async () => {
|
|
184
|
-
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
185
|
-
dispose = installFetchPatch({
|
|
186
|
-
onEntry: (e) => entries.push(e),
|
|
187
|
-
denylist: [/example/],
|
|
188
|
-
});
|
|
189
|
-
await window.fetch('http://example.com/__hfe__');
|
|
190
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
191
|
-
expect(entries).toHaveLength(0);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('does not let an onEntry throw crash business fetch', async () => {
|
|
195
|
-
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
196
|
-
dispose = installFetchPatch({
|
|
197
|
-
onEntry: () => {
|
|
198
|
-
throw new Error('boom');
|
|
199
|
-
},
|
|
200
|
-
});
|
|
201
|
-
await expect(window.fetch('http://x/')).resolves.toBeInstanceOf(Response);
|
|
202
|
-
});
|
|
203
|
-
});
|
package/src/fetchPatch.ts
DELETED
|
@@ -1,371 +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
|
-
|
|
26
|
-
import type { NetworkEntry } from '@harness-fe/protocol';
|
|
27
|
-
|
|
28
|
-
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
29
|
-
const INTERNAL_FLAG = '__hfeInternal';
|
|
30
|
-
const PATCHED_FLAG = '__hfePatched';
|
|
31
|
-
const DEFAULT_DENYLIST: RegExp[] = [/\/__hfe\//, /sockjs-node/, /\.hot-update\./];
|
|
32
|
-
const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
|
|
33
|
-
|
|
34
|
-
export interface FetchPatchOptions {
|
|
35
|
-
/** Called once for each emitted record (request and response are separate calls). */
|
|
36
|
-
onEntry: (entry: NetworkEntry) => void;
|
|
37
|
-
/** Per-body byte cap. Default 256 KB. */
|
|
38
|
-
bodyCap?: number;
|
|
39
|
-
/** URL patterns to skip capture entirely. */
|
|
40
|
-
denylist?: RegExp[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Install the fetch patch. Returns a dispose function that restores the
|
|
45
|
-
* original window.fetch. Safe to call multiple times (subsequent calls
|
|
46
|
-
* are no-ops while a patch is active).
|
|
47
|
-
*/
|
|
48
|
-
export function installFetchPatch(opts: FetchPatchOptions): () => void {
|
|
49
|
-
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
|
50
|
-
return () => {};
|
|
51
|
-
}
|
|
52
|
-
const original = window.fetch;
|
|
53
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
-
if ((original as any)[PATCHED_FLAG]) return () => {};
|
|
55
|
-
|
|
56
|
-
const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
|
|
57
|
-
const denylist = opts.denylist ?? DEFAULT_DENYLIST;
|
|
58
|
-
const emit = (entry: NetworkEntry): void => safeEmit(opts.onEntry, entry);
|
|
59
|
-
|
|
60
|
-
// Named function so .name === 'fetch'.
|
|
61
|
-
const patched = function fetch(
|
|
62
|
-
this: typeof globalThis,
|
|
63
|
-
input: RequestInfo | URL,
|
|
64
|
-
init?: RequestInit,
|
|
65
|
-
): Promise<Response> {
|
|
66
|
-
// Self-traffic short-circuit — internal requests bypass capture.
|
|
67
|
-
if (init && (init as Record<string, unknown>)[INTERNAL_FLAG]) {
|
|
68
|
-
return original.call(this, input as RequestInfo, init);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const meta = extractRequestMeta(input, init);
|
|
72
|
-
if (denylist.some((re) => re.test(meta.url))) {
|
|
73
|
-
return original.call(this, input as RequestInfo, init);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const id = generateId();
|
|
77
|
-
const startedAt = performance.now();
|
|
78
|
-
const startedTs = Date.now();
|
|
79
|
-
|
|
80
|
-
// Emit request record eagerly (req body is read async — second emit
|
|
81
|
-
// updates the record once body is serialized; consumers join by id).
|
|
82
|
-
const reqRecord: NetworkEntry = {
|
|
83
|
-
ts: startedTs,
|
|
84
|
-
id,
|
|
85
|
-
phase: 'req',
|
|
86
|
-
method: meta.method,
|
|
87
|
-
url: meta.url,
|
|
88
|
-
requestHeaders: meta.headers,
|
|
89
|
-
};
|
|
90
|
-
emit(reqRecord);
|
|
91
|
-
cloneRequestBody(input, init, bodyCap).then(
|
|
92
|
-
({ body, truncated }) => {
|
|
93
|
-
if (body === undefined && !truncated) return;
|
|
94
|
-
emit({
|
|
95
|
-
...reqRecord,
|
|
96
|
-
requestBody: body,
|
|
97
|
-
requestBodyTruncated: truncated || undefined,
|
|
98
|
-
});
|
|
99
|
-
},
|
|
100
|
-
() => {
|
|
101
|
-
/* serialization error — ignore, req already emitted */
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
const promise = original.call(this, input as RequestInfo, init);
|
|
106
|
-
|
|
107
|
-
promise.then(
|
|
108
|
-
(response) => {
|
|
109
|
-
let cloned: Response | undefined;
|
|
110
|
-
try {
|
|
111
|
-
cloned = response.clone();
|
|
112
|
-
} catch {
|
|
113
|
-
emit({
|
|
114
|
-
ts: Date.now(),
|
|
115
|
-
id,
|
|
116
|
-
phase: 'res',
|
|
117
|
-
method: meta.method,
|
|
118
|
-
url: meta.url,
|
|
119
|
-
status: response.status,
|
|
120
|
-
responseHeaders: headersToObject(response.headers),
|
|
121
|
-
durationMs: performance.now() - startedAt,
|
|
122
|
-
});
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
const finalize = (body: unknown, truncated: boolean): void => {
|
|
126
|
-
emit({
|
|
127
|
-
ts: Date.now(),
|
|
128
|
-
id,
|
|
129
|
-
phase: 'res',
|
|
130
|
-
method: meta.method,
|
|
131
|
-
url: meta.url,
|
|
132
|
-
status: response.status,
|
|
133
|
-
responseHeaders: headersToObject(response.headers),
|
|
134
|
-
responseBody: body,
|
|
135
|
-
responseBodyTruncated: truncated || undefined,
|
|
136
|
-
durationMs: performance.now() - startedAt,
|
|
137
|
-
});
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const ct = response.headers.get('content-type') ?? '';
|
|
141
|
-
if (isSSE(ct)) {
|
|
142
|
-
pumpSSE(cloned, bodyCap, finalize);
|
|
143
|
-
} else if (isTextLike(ct)) {
|
|
144
|
-
readTextWithCap(cloned, bodyCap).then(
|
|
145
|
-
({ body, truncated }) =>
|
|
146
|
-
finalize(maybeParseJson(body, ct), truncated),
|
|
147
|
-
() => finalize(undefined, false),
|
|
148
|
-
);
|
|
149
|
-
} else {
|
|
150
|
-
// Binary or unknown: only record size, do not pull bytes.
|
|
151
|
-
cloned.arrayBuffer().then(
|
|
152
|
-
(buf) => finalize(`[binary ${buf.byteLength}B]`, false),
|
|
153
|
-
() => finalize(undefined, false),
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
(err: unknown) => {
|
|
158
|
-
emit({
|
|
159
|
-
ts: Date.now(),
|
|
160
|
-
id,
|
|
161
|
-
phase: 'res',
|
|
162
|
-
method: meta.method,
|
|
163
|
-
url: meta.url,
|
|
164
|
-
durationMs: performance.now() - startedAt,
|
|
165
|
-
error: err instanceof Error ? err.message : String(err),
|
|
166
|
-
});
|
|
167
|
-
},
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
return promise;
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
// Preserve fingerprint — library detection commonly inspects these.
|
|
174
|
-
try {
|
|
175
|
-
Object.defineProperty(patched, 'name', { value: 'fetch' });
|
|
176
|
-
Object.defineProperty(patched, 'length', { value: original.length });
|
|
177
|
-
Object.defineProperty(patched, 'toString', {
|
|
178
|
-
value: () => original.toString(),
|
|
179
|
-
});
|
|
180
|
-
} catch {
|
|
181
|
-
/* sealed property — safe to skip */
|
|
182
|
-
}
|
|
183
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
-
(patched as any)[PATCHED_FLAG] = true;
|
|
185
|
-
|
|
186
|
-
window.fetch = patched as typeof fetch;
|
|
187
|
-
return () => {
|
|
188
|
-
if (window.fetch === (patched as typeof fetch)) {
|
|
189
|
-
window.fetch = original;
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
195
|
-
|
|
196
|
-
function generateId(): string {
|
|
197
|
-
try {
|
|
198
|
-
return crypto.randomUUID();
|
|
199
|
-
} catch {
|
|
200
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
interface RequestMeta {
|
|
205
|
-
url: string;
|
|
206
|
-
method: string;
|
|
207
|
-
headers?: Record<string, string>;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function extractRequestMeta(input: RequestInfo | URL, init?: RequestInit): RequestMeta {
|
|
211
|
-
let url: string;
|
|
212
|
-
let method = 'GET';
|
|
213
|
-
let headers: Record<string, string> | undefined;
|
|
214
|
-
|
|
215
|
-
if (typeof input === 'string') {
|
|
216
|
-
url = input;
|
|
217
|
-
} else if (input instanceof URL) {
|
|
218
|
-
url = input.toString();
|
|
219
|
-
} else {
|
|
220
|
-
url = input.url;
|
|
221
|
-
method = input.method;
|
|
222
|
-
headers = headersToObject(input.headers);
|
|
223
|
-
}
|
|
224
|
-
if (init?.method) method = init.method;
|
|
225
|
-
if (init?.headers) {
|
|
226
|
-
headers = { ...(headers ?? {}), ...(headersToObject(init.headers) ?? {}) };
|
|
227
|
-
}
|
|
228
|
-
return { url, method, headers };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function headersToObject(
|
|
232
|
-
h: HeadersInit | Headers | undefined,
|
|
233
|
-
): Record<string, string> | undefined {
|
|
234
|
-
if (!h) return undefined;
|
|
235
|
-
const out: Record<string, string> = {};
|
|
236
|
-
if (h instanceof Headers) {
|
|
237
|
-
h.forEach((v, k) => {
|
|
238
|
-
out[k] = v;
|
|
239
|
-
});
|
|
240
|
-
} else if (Array.isArray(h)) {
|
|
241
|
-
for (const [k, v] of h) out[k] = v;
|
|
242
|
-
} else {
|
|
243
|
-
Object.assign(out, h);
|
|
244
|
-
}
|
|
245
|
-
return redactHeaders(out);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function redactHeaders(h: Record<string, string>): Record<string, string> {
|
|
249
|
-
const out: Record<string, string> = {};
|
|
250
|
-
for (const [k, v] of Object.entries(h)) {
|
|
251
|
-
out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
|
|
252
|
-
}
|
|
253
|
-
return out;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async function cloneRequestBody(
|
|
257
|
-
input: RequestInfo | URL,
|
|
258
|
-
init: RequestInit | undefined,
|
|
259
|
-
cap: number,
|
|
260
|
-
): Promise<{ body?: unknown; truncated: boolean }> {
|
|
261
|
-
if (init?.body !== undefined && init.body !== null) {
|
|
262
|
-
return serializeBodyInit(init.body, cap);
|
|
263
|
-
}
|
|
264
|
-
if (input instanceof Request && input.body) {
|
|
265
|
-
try {
|
|
266
|
-
const text = await input.clone().text();
|
|
267
|
-
return capText(text, cap);
|
|
268
|
-
} catch {
|
|
269
|
-
return { truncated: false };
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return { truncated: false };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function serializeBodyInit(
|
|
276
|
-
body: BodyInit,
|
|
277
|
-
cap: number,
|
|
278
|
-
): { body?: unknown; truncated: boolean } {
|
|
279
|
-
if (typeof body === 'string') return capText(body, cap);
|
|
280
|
-
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
281
|
-
const obj: Record<string, unknown> = {};
|
|
282
|
-
body.forEach((v, k) => {
|
|
283
|
-
obj[k] = typeof v === 'string'
|
|
284
|
-
? v
|
|
285
|
-
: `[File ${(v as File).name} ${(v as File).size}B]`;
|
|
286
|
-
});
|
|
287
|
-
return { body: obj, truncated: false };
|
|
288
|
-
}
|
|
289
|
-
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
290
|
-
return { body: body.toString(), truncated: false };
|
|
291
|
-
}
|
|
292
|
-
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
293
|
-
return { body: `[Blob ${body.size}B]`, truncated: false };
|
|
294
|
-
}
|
|
295
|
-
if (body instanceof ArrayBuffer) {
|
|
296
|
-
return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
|
|
297
|
-
}
|
|
298
|
-
if (ArrayBuffer.isView(body)) {
|
|
299
|
-
return { body: `[${(body.constructor as { name: string }).name} ${body.byteLength}B]`, truncated: false };
|
|
300
|
-
}
|
|
301
|
-
return { body: '[unknown body]', truncated: false };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function capText(text: string, cap: number): { body: string; truncated: boolean } {
|
|
305
|
-
if (text.length > cap) return { body: text.slice(0, cap), truncated: true };
|
|
306
|
-
return { body: text, truncated: false };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function readTextWithCap(
|
|
310
|
-
res: Response,
|
|
311
|
-
cap: number,
|
|
312
|
-
): Promise<{ body: string; truncated: boolean }> {
|
|
313
|
-
const text = await res.text();
|
|
314
|
-
return capText(text, cap);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function maybeParseJson(text: string, contentType: string): unknown {
|
|
318
|
-
if (!/json/i.test(contentType)) return text;
|
|
319
|
-
try {
|
|
320
|
-
return JSON.parse(text);
|
|
321
|
-
} catch {
|
|
322
|
-
return text;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function isSSE(ct: string): boolean {
|
|
327
|
-
return /text\/event-stream/i.test(ct);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function isTextLike(ct: string): boolean {
|
|
331
|
-
return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function pumpSSE(
|
|
335
|
-
res: Response,
|
|
336
|
-
cap: number,
|
|
337
|
-
done: (body: string, truncated: boolean) => void,
|
|
338
|
-
): void {
|
|
339
|
-
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
340
|
-
return done('', false);
|
|
341
|
-
}
|
|
342
|
-
const reader = res.body.getReader();
|
|
343
|
-
const dec = new TextDecoder();
|
|
344
|
-
let total = '';
|
|
345
|
-
let truncated = false;
|
|
346
|
-
const step = (): void => {
|
|
347
|
-
reader.read().then(
|
|
348
|
-
({ done: end, value }) => {
|
|
349
|
-
if (end) return done(total, truncated);
|
|
350
|
-
total += dec.decode(value, { stream: true });
|
|
351
|
-
if (total.length > cap) {
|
|
352
|
-
total = total.slice(0, cap);
|
|
353
|
-
truncated = true;
|
|
354
|
-
reader.cancel().catch(() => {});
|
|
355
|
-
return done(total, truncated);
|
|
356
|
-
}
|
|
357
|
-
step();
|
|
358
|
-
},
|
|
359
|
-
() => done(total, truncated),
|
|
360
|
-
);
|
|
361
|
-
};
|
|
362
|
-
step();
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function safeEmit(fn: (entry: NetworkEntry) => void, entry: NetworkEntry): void {
|
|
366
|
-
try {
|
|
367
|
-
fn(entry);
|
|
368
|
-
} catch {
|
|
369
|
-
/* swallow — capture must not break business */
|
|
370
|
-
}
|
|
371
|
-
}
|