@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.
@@ -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
- }