@harness-fe/runtime 3.1.0 → 3.3.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/wsPatch.js DELETED
@@ -1,172 +0,0 @@
1
- /**
2
- * WebSocket monkey-patch — captures open / send / recv / close frames so
3
- * agents can see long-lived push channels (IM, presence, sync, kick-out).
4
- *
5
- * Safety contract (mirrors fetchPatch):
6
- * 1. Identity-preserving: replacement is a constructor with the same name,
7
- * prototype, and CONNECTING/OPEN/CLOSING/CLOSED static fields. Existing
8
- * `instanceof WebSocket` checks still pass because we extend the original.
9
- * 2. Error-isolated: capture failures swallowed via safeEmit.
10
- * 3. No timing or value change: pass-through to native WebSocket; we only
11
- * observe events and call sites. The original Promise / data flow is
12
- * untouched.
13
- * 4. Bounded memory: frame payloads capped at BODY_CAP per send/recv. Binary
14
- * frames record a `[binary Nb]` marker rather than the bytes.
15
- *
16
- * The patch is idempotent. Returns a dispose function that restores
17
- * `window.WebSocket`.
18
- */
19
- import { captureInitiator } from './initiator.js';
20
- const DEFAULT_BODY_CAP = 256 * 1024;
21
- const PATCHED_FLAG = '__hfeWsPatched';
22
- const DEFAULT_DENYLIST = [/\/__hfe\//, /sockjs-node/];
23
- export function installWsPatch(opts) {
24
- if (typeof window === 'undefined' || typeof window.WebSocket !== 'function') {
25
- return () => { };
26
- }
27
- const OriginalWS = window.WebSocket;
28
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
- if (OriginalWS[PATCHED_FLAG])
30
- return () => { };
31
- const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
32
- const denylist = opts.denylist ?? DEFAULT_DENYLIST;
33
- const emit = (entry) => {
34
- try {
35
- opts.onEntry(entry);
36
- }
37
- catch {
38
- /* swallow */
39
- }
40
- };
41
- const Patched = function PatchedWebSocket(url, protocols) {
42
- const urlStr = typeof url === 'string' ? url : url.toString();
43
- const ws = protocols !== undefined
44
- ? new OriginalWS(url, protocols)
45
- : new OriginalWS(url);
46
- if (denylist.some((re) => re.test(urlStr)))
47
- return ws;
48
- const id = generateId();
49
- const protoList = Array.isArray(protocols)
50
- ? protocols
51
- : typeof protocols === 'string'
52
- ? [protocols]
53
- : undefined;
54
- const openInitiator = captureInitiator();
55
- emit({
56
- ts: Date.now(),
57
- id,
58
- phase: 'open',
59
- url: urlStr,
60
- protocols: protoList,
61
- initiator: openInitiator,
62
- });
63
- ws.addEventListener('message', (ev) => {
64
- const { payload, truncated } = serializeFrame(ev.data, bodyCap);
65
- emit({
66
- ts: Date.now(),
67
- id,
68
- phase: 'recv',
69
- url: urlStr,
70
- payload,
71
- payloadTruncated: truncated || undefined,
72
- });
73
- });
74
- ws.addEventListener('close', (ev) => {
75
- emit({
76
- ts: Date.now(),
77
- id,
78
- phase: 'close',
79
- url: urlStr,
80
- code: ev.code,
81
- reason: ev.reason || undefined,
82
- wasClean: ev.wasClean,
83
- });
84
- });
85
- // Wrap `send` on this instance so we record outgoing payloads + caller.
86
- const origSend = ws.send.bind(ws);
87
- ws.send = function patchedSend(data) {
88
- const initiator = captureInitiator();
89
- const { payload, truncated } = serializeFrame(data, bodyCap);
90
- emit({
91
- ts: Date.now(),
92
- id,
93
- phase: 'send',
94
- url: urlStr,
95
- payload,
96
- payloadTruncated: truncated || undefined,
97
- initiator,
98
- });
99
- return origSend(data);
100
- };
101
- return ws;
102
- };
103
- // Preserve constructor surface so library detection still works.
104
- Patched.prototype = OriginalWS.prototype;
105
- for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) {
106
- try {
107
- Object.defineProperty(Patched, key, {
108
- value: OriginalWS[key],
109
- writable: false,
110
- configurable: true,
111
- });
112
- }
113
- catch {
114
- /* readonly already — ignore */
115
- }
116
- }
117
- try {
118
- Object.defineProperty(Patched, 'name', { value: 'WebSocket' });
119
- }
120
- catch {
121
- /* ignore */
122
- }
123
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
- Patched[PATCHED_FLAG] = true;
125
- window.WebSocket = Patched;
126
- return () => {
127
- if (window.WebSocket === Patched) {
128
- window.WebSocket = OriginalWS;
129
- }
130
- };
131
- }
132
- function generateId() {
133
- if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
134
- return crypto.randomUUID();
135
- }
136
- return `ws_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
137
- }
138
- function serializeFrame(data, cap) {
139
- if (typeof data === 'string') {
140
- if (data.length <= cap) {
141
- // Try JSON for structured payloads.
142
- const parsed = tryJson(data);
143
- return { payload: parsed !== undefined ? parsed : data, truncated: false };
144
- }
145
- return { payload: data.slice(0, cap), truncated: true };
146
- }
147
- if (data instanceof ArrayBuffer) {
148
- return { payload: `[binary ArrayBuffer ${data.byteLength}B]`, truncated: false };
149
- }
150
- if (typeof Blob !== 'undefined' && data instanceof Blob) {
151
- return { payload: `[binary Blob ${data.size}B]`, truncated: false };
152
- }
153
- if (ArrayBuffer.isView(data)) {
154
- const view = data;
155
- return { payload: `[binary ${view.constructor.name} ${view.byteLength}B]`, truncated: false };
156
- }
157
- return { payload: undefined, truncated: false };
158
- }
159
- function tryJson(s) {
160
- const trimmed = s.trim();
161
- if (!trimmed)
162
- return undefined;
163
- const first = trimmed[0];
164
- if (first !== '{' && first !== '[' && first !== '"')
165
- return undefined;
166
- try {
167
- return JSON.parse(trimmed);
168
- }
169
- catch {
170
- return undefined;
171
- }
172
- }
@@ -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,272 +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 { captureInitiator } from './initiator.js';
21
- const DEFAULT_BODY_CAP = 256 * 1024;
22
- const PATCHED_FLAG = '__hfeXhrPatched';
23
- const META_KEY = Symbol.for('@harness-fe/xhr-meta');
24
- const INTERNAL_HEADER = 'x-hfe-internal';
25
- const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
26
- export function installXhrPatch(opts) {
27
- if (typeof XMLHttpRequest === 'undefined')
28
- return () => { };
29
- const proto = XMLHttpRequest.prototype;
30
- if (proto[PATCHED_FLAG])
31
- return () => { };
32
- const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
33
- const denylist = opts.denylist ?? [];
34
- const emit = (entry) => safeEmit(opts.onEntry, entry);
35
- const origOpen = proto.open;
36
- const origSetHeader = proto.setRequestHeader;
37
- const origSend = proto.send;
38
- const patchedOpen = function open(method, url, ...rest) {
39
- const meta = {
40
- id: generateId(),
41
- method,
42
- url: typeof url === 'string' ? url : url.toString(),
43
- headers: {},
44
- startedAt: 0,
45
- startedTs: 0,
46
- bodyCap,
47
- internal: false,
48
- skipped: denylist.some((re) => re.test(typeof url === 'string' ? url : url.toString())),
49
- reqEmitted: false,
50
- };
51
- this[META_KEY] = meta;
52
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
- return origOpen.call(this, method, url, ...rest);
54
- };
55
- const patchedSetHeader = function setRequestHeader(name, value) {
56
- const meta = this[META_KEY];
57
- if (meta) {
58
- if (name.toLowerCase() === INTERNAL_HEADER) {
59
- meta.internal = true;
60
- }
61
- else {
62
- meta.headers[name] = value;
63
- }
64
- }
65
- // Do NOT forward the internal sentinel to the server.
66
- if (name.toLowerCase() === INTERNAL_HEADER)
67
- return;
68
- return origSetHeader.call(this, name, value);
69
- };
70
- const patchedSend = function send(body) {
71
- const meta = this[META_KEY];
72
- if (!meta || meta.internal || meta.skipped) {
73
- return origSend.call(this, body ?? null);
74
- }
75
- meta.startedAt = performance.now();
76
- meta.startedTs = Date.now();
77
- const initiator = captureInitiator();
78
- // Emit req eagerly with headers; body added on second emit after
79
- // serialization (mirrors fetchPatch behavior).
80
- const reqRecord = {
81
- ts: meta.startedTs,
82
- id: meta.id,
83
- phase: 'req',
84
- method: meta.method,
85
- url: meta.url,
86
- requestHeaders: redactHeaders(meta.headers),
87
- initiator,
88
- };
89
- emit(reqRecord);
90
- meta.reqEmitted = true;
91
- const serialized = serializeBody(body, meta.bodyCap);
92
- if (serialized.body !== undefined || serialized.truncated) {
93
- emit({
94
- ...reqRecord,
95
- requestBody: serialized.body,
96
- requestBodyTruncated: serialized.truncated || undefined,
97
- });
98
- }
99
- this.addEventListener('loadend', () => {
100
- const status = this.status;
101
- const ct = safeGetResponseHeader(this, 'content-type') ?? '';
102
- const respHeaders = parseAllResponseHeaders(this);
103
- const isErr = status === 0;
104
- const baseRes = {
105
- ts: Date.now(),
106
- id: meta.id,
107
- phase: 'res',
108
- method: meta.method,
109
- url: meta.url,
110
- status: isErr ? undefined : status,
111
- responseHeaders: respHeaders,
112
- durationMs: performance.now() - meta.startedAt,
113
- };
114
- if (isErr) {
115
- emit({ ...baseRes, error: 'xhr error or aborted' });
116
- return;
117
- }
118
- if (isTextLike(ct)) {
119
- const text = safeReadResponseText(this);
120
- if (text === undefined) {
121
- emit(baseRes);
122
- return;
123
- }
124
- const capped = capText(text, meta.bodyCap);
125
- emit({
126
- ...baseRes,
127
- responseBody: /json/i.test(ct) ? safeParseJson(capped.body) : capped.body,
128
- responseBodyTruncated: capped.truncated || undefined,
129
- });
130
- }
131
- else {
132
- // Binary or unknown → don't pull bytes, just record size when available.
133
- const len = Number(safeGetResponseHeader(this, 'content-length') ?? '0') || 0;
134
- emit({
135
- ...baseRes,
136
- responseBody: len ? `[binary ${len}B]` : '[binary]',
137
- });
138
- }
139
- });
140
- return origSend.call(this, body ?? null);
141
- };
142
- proto.open = patchedOpen;
143
- proto.setRequestHeader = patchedSetHeader;
144
- proto.send = patchedSend;
145
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
- proto[PATCHED_FLAG] = true;
147
- return () => {
148
- // Only restore if we still own the patch — don't clobber a later patch.
149
- if (proto.open !== patchedOpen)
150
- return;
151
- proto.open = origOpen;
152
- proto.setRequestHeader = origSetHeader;
153
- proto.send = origSend;
154
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
- delete proto[PATCHED_FLAG];
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 redactHeaders(h) {
168
- const out = {};
169
- for (const [k, v] of Object.entries(h)) {
170
- out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
171
- }
172
- return out;
173
- }
174
- function serializeBody(body, cap) {
175
- if (body === undefined || body === null)
176
- return { truncated: false };
177
- if (typeof body === 'string')
178
- return capText(body, cap);
179
- if (typeof FormData !== 'undefined' && body instanceof FormData) {
180
- const obj = {};
181
- body.forEach((v, k) => {
182
- obj[k] = typeof v === 'string'
183
- ? v
184
- : `[File ${v.name} ${v.size}B]`;
185
- });
186
- return { body: obj, truncated: false };
187
- }
188
- if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
189
- return { body: body.toString(), truncated: false };
190
- }
191
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
192
- return { body: `[Blob ${body.size}B]`, truncated: false };
193
- }
194
- if (body instanceof ArrayBuffer) {
195
- return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
196
- }
197
- if (ArrayBuffer.isView(body)) {
198
- return {
199
- body: `[${body.constructor.name} ${body.byteLength}B]`,
200
- truncated: false,
201
- };
202
- }
203
- if (typeof Document !== 'undefined' && body instanceof Document) {
204
- return { body: '[Document]', truncated: false };
205
- }
206
- return { body: '[unknown body]', truncated: false };
207
- }
208
- function capText(text, cap) {
209
- if (text.length > cap)
210
- return { body: text.slice(0, cap), truncated: true };
211
- return { body: text, truncated: false };
212
- }
213
- function safeGetResponseHeader(xhr, name) {
214
- try {
215
- return xhr.getResponseHeader(name);
216
- }
217
- catch {
218
- return null;
219
- }
220
- }
221
- function parseAllResponseHeaders(xhr) {
222
- let raw;
223
- try {
224
- raw = xhr.getAllResponseHeaders();
225
- }
226
- catch {
227
- return undefined;
228
- }
229
- if (!raw)
230
- return undefined;
231
- const out = {};
232
- for (const line of raw.split('\r\n')) {
233
- const idx = line.indexOf(':');
234
- if (idx < 0)
235
- continue;
236
- const k = line.slice(0, idx).trim();
237
- const v = line.slice(idx + 1).trim();
238
- if (k)
239
- out[k] = v;
240
- }
241
- return Object.keys(out).length ? redactHeaders(out) : undefined;
242
- }
243
- function safeReadResponseText(xhr) {
244
- try {
245
- // responseType must be '' or 'text' to access responseText.
246
- if (xhr.responseType !== '' && xhr.responseType !== 'text')
247
- return undefined;
248
- return xhr.responseText;
249
- }
250
- catch {
251
- return undefined;
252
- }
253
- }
254
- function safeParseJson(text) {
255
- try {
256
- return JSON.parse(text);
257
- }
258
- catch {
259
- return text;
260
- }
261
- }
262
- function isTextLike(ct) {
263
- return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
264
- }
265
- function safeEmit(fn, entry) {
266
- try {
267
- fn(entry);
268
- }
269
- catch {
270
- /* swallow */
271
- }
272
- }
@@ -1,217 +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('stamps initiator.stack on req entries', async () => {
105
- setMockFetch(() => Promise.resolve(jsonResponse({ ok: true })));
106
- dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
107
- await window.fetch('http://x/');
108
- await new Promise((r) => setTimeout(r, 10));
109
- const req = entries.find((e) => e.phase === 'req')!;
110
- expect(req.initiator).toBeDefined();
111
- // Stack should be a non-empty string when V8 produced one.
112
- if (req.initiator?.stack !== undefined) {
113
- expect(typeof req.initiator.stack).toBe('string');
114
- expect(req.initiator.stack.length).toBeGreaterThan(0);
115
- }
116
- });
117
-
118
- it('captures and caps a large JSON response body', async () => {
119
- const huge = 'x'.repeat(2000);
120
- setMockFetch(() => Promise.resolve(jsonResponse({ s: huge })));
121
- dispose = installFetchPatch({
122
- onEntry: (e) => entries.push(e),
123
- bodyCap: 500,
124
- });
125
- await window.fetch('http://x/');
126
- await new Promise((r) => setTimeout(r, 10));
127
- const res = entries.find((e) => e.phase === 'res')!;
128
- expect(res.responseBodyTruncated).toBe(true);
129
- // body is the raw truncated text when JSON.parse fails
130
- expect(typeof res.responseBody).toBe('string');
131
- });
132
-
133
- it('redacts sensitive request headers', async () => {
134
- setMockFetch(() => Promise.resolve(new Response('ok')));
135
- dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
136
- await window.fetch('http://x/', {
137
- method: 'POST',
138
- headers: {
139
- Authorization: 'Bearer abc.def.ghi',
140
- 'X-Api-Key': 'sk-12345',
141
- 'content-type': 'text/plain',
142
- },
143
- body: 'hi',
144
- });
145
- await new Promise((r) => setTimeout(r, 10));
146
- const req = entries.find((e) => e.phase === 'req' && e.requestHeaders)!;
147
- expect(req.requestHeaders!.Authorization).toMatch(/^\[redacted \d+\]$/);
148
- expect(req.requestHeaders!['X-Api-Key']).toMatch(/^\[redacted \d+\]$/);
149
- // non-sensitive header is preserved
150
- expect(req.requestHeaders!['content-type']).toBe('text/plain');
151
- });
152
-
153
- it('emits res with error field when underlying fetch rejects', async () => {
154
- setMockFetch(() => Promise.reject(new Error('network down')));
155
- dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
156
- await expect(window.fetch('http://x/')).rejects.toThrow('network down');
157
- await new Promise((r) => setTimeout(r, 10));
158
- const res = entries.find((e) => e.phase === 'res')!;
159
- expect(res.error).toBe('network down');
160
- expect(res.status).toBeUndefined();
161
- });
162
-
163
- it('caps an SSE stream and cancels the cloned reader', async () => {
164
- const longChunk = 'data: ' + 'x'.repeat(2000) + '\n\n';
165
- setMockFetch(() =>
166
- Promise.resolve(
167
- new Response(sseStream([longChunk, longChunk, longChunk]), {
168
- status: 200,
169
- headers: { 'content-type': 'text/event-stream' },
170
- }),
171
- ),
172
- );
173
- dispose = installFetchPatch({
174
- onEntry: (e) => entries.push(e),
175
- bodyCap: 500,
176
- });
177
- const res = await window.fetch('http://x/');
178
- // business can still read its own stream
179
- await res.text();
180
- await new Promise((r) => setTimeout(r, 30));
181
- const captured = entries.find((e) => e.phase === 'res')!;
182
- expect(captured.responseBodyTruncated).toBe(true);
183
- expect((captured.responseBody as string).length).toBe(500);
184
- });
185
-
186
- it('skips internal traffic when __hfeInternal flag is set', async () => {
187
- setMockFetch(() => Promise.resolve(new Response('ok')));
188
- dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
189
- await window.fetch('http://x/', {
190
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
- ...({ __hfeInternal: true } as any),
192
- });
193
- await new Promise((r) => setTimeout(r, 10));
194
- expect(entries).toHaveLength(0);
195
- });
196
-
197
- it('skips denylisted URLs', async () => {
198
- setMockFetch(() => Promise.resolve(new Response('ok')));
199
- dispose = installFetchPatch({
200
- onEntry: (e) => entries.push(e),
201
- denylist: [/example/],
202
- });
203
- await window.fetch('http://example.com/__hfe__');
204
- await new Promise((r) => setTimeout(r, 10));
205
- expect(entries).toHaveLength(0);
206
- });
207
-
208
- it('does not let an onEntry throw crash business fetch', async () => {
209
- setMockFetch(() => Promise.resolve(new Response('ok')));
210
- dispose = installFetchPatch({
211
- onEntry: () => {
212
- throw new Error('boom');
213
- },
214
- });
215
- await expect(window.fetch('http://x/')).resolves.toBeInstanceOf(Response);
216
- });
217
- });