@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/src/fetchPatch.ts DELETED
@@ -1,374 +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
- import { captureInitiator } from './initiator.js';
28
-
29
- const DEFAULT_BODY_CAP = 256 * 1024;
30
- const INTERNAL_FLAG = '__hfeInternal';
31
- const PATCHED_FLAG = '__hfePatched';
32
- const DEFAULT_DENYLIST: RegExp[] = [/\/__hfe\//, /sockjs-node/, /\.hot-update\./];
33
- const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
34
-
35
- export interface FetchPatchOptions {
36
- /** Called once for each emitted record (request and response are separate calls). */
37
- onEntry: (entry: NetworkEntry) => void;
38
- /** Per-body byte cap. Default 256 KB. */
39
- bodyCap?: number;
40
- /** URL patterns to skip capture entirely. */
41
- denylist?: RegExp[];
42
- }
43
-
44
- /**
45
- * Install the fetch patch. Returns a dispose function that restores the
46
- * original window.fetch. Safe to call multiple times (subsequent calls
47
- * are no-ops while a patch is active).
48
- */
49
- export function installFetchPatch(opts: FetchPatchOptions): () => void {
50
- if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
51
- return () => {};
52
- }
53
- const original = window.fetch;
54
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
- if ((original as any)[PATCHED_FLAG]) return () => {};
56
-
57
- const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
58
- const denylist = opts.denylist ?? DEFAULT_DENYLIST;
59
- const emit = (entry: NetworkEntry): void => safeEmit(opts.onEntry, entry);
60
-
61
- // Named function so .name === 'fetch'.
62
- const patched = function fetch(
63
- this: typeof globalThis,
64
- input: RequestInfo | URL,
65
- init?: RequestInit,
66
- ): Promise<Response> {
67
- // Self-traffic short-circuit — internal requests bypass capture.
68
- if (init && (init as Record<string, unknown>)[INTERNAL_FLAG]) {
69
- return original.call(this, input as RequestInfo, init);
70
- }
71
-
72
- const meta = extractRequestMeta(input, init);
73
- if (denylist.some((re) => re.test(meta.url))) {
74
- return original.call(this, input as RequestInfo, init);
75
- }
76
-
77
- const id = generateId();
78
- const startedAt = performance.now();
79
- const startedTs = Date.now();
80
- const initiator = captureInitiator();
81
-
82
- // Emit request record eagerly (req body is read async — second emit
83
- // updates the record once body is serialized; consumers join by id).
84
- const reqRecord: NetworkEntry = {
85
- ts: startedTs,
86
- id,
87
- phase: 'req',
88
- method: meta.method,
89
- url: meta.url,
90
- requestHeaders: meta.headers,
91
- initiator,
92
- };
93
- emit(reqRecord);
94
- cloneRequestBody(input, init, bodyCap).then(
95
- ({ body, truncated }) => {
96
- if (body === undefined && !truncated) return;
97
- emit({
98
- ...reqRecord,
99
- requestBody: body,
100
- requestBodyTruncated: truncated || undefined,
101
- });
102
- },
103
- () => {
104
- /* serialization error — ignore, req already emitted */
105
- },
106
- );
107
-
108
- const promise = original.call(this, input as RequestInfo, init);
109
-
110
- promise.then(
111
- (response) => {
112
- let cloned: Response | undefined;
113
- try {
114
- cloned = response.clone();
115
- } catch {
116
- emit({
117
- ts: Date.now(),
118
- id,
119
- phase: 'res',
120
- method: meta.method,
121
- url: meta.url,
122
- status: response.status,
123
- responseHeaders: headersToObject(response.headers),
124
- durationMs: performance.now() - startedAt,
125
- });
126
- return;
127
- }
128
- const finalize = (body: unknown, truncated: boolean): void => {
129
- emit({
130
- ts: Date.now(),
131
- id,
132
- phase: 'res',
133
- method: meta.method,
134
- url: meta.url,
135
- status: response.status,
136
- responseHeaders: headersToObject(response.headers),
137
- responseBody: body,
138
- responseBodyTruncated: truncated || undefined,
139
- durationMs: performance.now() - startedAt,
140
- });
141
- };
142
-
143
- const ct = response.headers.get('content-type') ?? '';
144
- if (isSSE(ct)) {
145
- pumpSSE(cloned, bodyCap, finalize);
146
- } else if (isTextLike(ct)) {
147
- readTextWithCap(cloned, bodyCap).then(
148
- ({ body, truncated }) =>
149
- finalize(maybeParseJson(body, ct), truncated),
150
- () => finalize(undefined, false),
151
- );
152
- } else {
153
- // Binary or unknown: only record size, do not pull bytes.
154
- cloned.arrayBuffer().then(
155
- (buf) => finalize(`[binary ${buf.byteLength}B]`, false),
156
- () => finalize(undefined, false),
157
- );
158
- }
159
- },
160
- (err: unknown) => {
161
- emit({
162
- ts: Date.now(),
163
- id,
164
- phase: 'res',
165
- method: meta.method,
166
- url: meta.url,
167
- durationMs: performance.now() - startedAt,
168
- error: err instanceof Error ? err.message : String(err),
169
- });
170
- },
171
- );
172
-
173
- return promise;
174
- };
175
-
176
- // Preserve fingerprint — library detection commonly inspects these.
177
- try {
178
- Object.defineProperty(patched, 'name', { value: 'fetch' });
179
- Object.defineProperty(patched, 'length', { value: original.length });
180
- Object.defineProperty(patched, 'toString', {
181
- value: () => original.toString(),
182
- });
183
- } catch {
184
- /* sealed property — safe to skip */
185
- }
186
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
- (patched as any)[PATCHED_FLAG] = true;
188
-
189
- window.fetch = patched as typeof fetch;
190
- return () => {
191
- if (window.fetch === (patched as typeof fetch)) {
192
- window.fetch = original;
193
- }
194
- };
195
- }
196
-
197
- // ─── helpers ────────────────────────────────────────────────────────────────
198
-
199
- function generateId(): string {
200
- try {
201
- return crypto.randomUUID();
202
- } catch {
203
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
204
- }
205
- }
206
-
207
- interface RequestMeta {
208
- url: string;
209
- method: string;
210
- headers?: Record<string, string>;
211
- }
212
-
213
- function extractRequestMeta(input: RequestInfo | URL, init?: RequestInit): RequestMeta {
214
- let url: string;
215
- let method = 'GET';
216
- let headers: Record<string, string> | undefined;
217
-
218
- if (typeof input === 'string') {
219
- url = input;
220
- } else if (input instanceof URL) {
221
- url = input.toString();
222
- } else {
223
- url = input.url;
224
- method = input.method;
225
- headers = headersToObject(input.headers);
226
- }
227
- if (init?.method) method = init.method;
228
- if (init?.headers) {
229
- headers = { ...(headers ?? {}), ...(headersToObject(init.headers) ?? {}) };
230
- }
231
- return { url, method, headers };
232
- }
233
-
234
- function headersToObject(
235
- h: HeadersInit | Headers | undefined,
236
- ): Record<string, string> | undefined {
237
- if (!h) return undefined;
238
- const out: Record<string, string> = {};
239
- if (h instanceof Headers) {
240
- h.forEach((v, k) => {
241
- out[k] = v;
242
- });
243
- } else if (Array.isArray(h)) {
244
- for (const [k, v] of h) out[k] = v;
245
- } else {
246
- Object.assign(out, h);
247
- }
248
- return redactHeaders(out);
249
- }
250
-
251
- function redactHeaders(h: Record<string, string>): Record<string, string> {
252
- const out: Record<string, string> = {};
253
- for (const [k, v] of Object.entries(h)) {
254
- out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
255
- }
256
- return out;
257
- }
258
-
259
- async function cloneRequestBody(
260
- input: RequestInfo | URL,
261
- init: RequestInit | undefined,
262
- cap: number,
263
- ): Promise<{ body?: unknown; truncated: boolean }> {
264
- if (init?.body !== undefined && init.body !== null) {
265
- return serializeBodyInit(init.body, cap);
266
- }
267
- if (input instanceof Request && input.body) {
268
- try {
269
- const text = await input.clone().text();
270
- return capText(text, cap);
271
- } catch {
272
- return { truncated: false };
273
- }
274
- }
275
- return { truncated: false };
276
- }
277
-
278
- function serializeBodyInit(
279
- body: BodyInit,
280
- cap: number,
281
- ): { body?: unknown; truncated: boolean } {
282
- if (typeof body === 'string') return capText(body, cap);
283
- if (typeof FormData !== 'undefined' && body instanceof FormData) {
284
- const obj: Record<string, unknown> = {};
285
- body.forEach((v, k) => {
286
- obj[k] = typeof v === 'string'
287
- ? v
288
- : `[File ${(v as File).name} ${(v as File).size}B]`;
289
- });
290
- return { body: obj, truncated: false };
291
- }
292
- if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
293
- return { body: body.toString(), truncated: false };
294
- }
295
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
296
- return { body: `[Blob ${body.size}B]`, truncated: false };
297
- }
298
- if (body instanceof ArrayBuffer) {
299
- return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
300
- }
301
- if (ArrayBuffer.isView(body)) {
302
- return { body: `[${(body.constructor as { name: string }).name} ${body.byteLength}B]`, truncated: false };
303
- }
304
- return { body: '[unknown body]', truncated: false };
305
- }
306
-
307
- function capText(text: string, cap: number): { body: string; truncated: boolean } {
308
- if (text.length > cap) return { body: text.slice(0, cap), truncated: true };
309
- return { body: text, truncated: false };
310
- }
311
-
312
- async function readTextWithCap(
313
- res: Response,
314
- cap: number,
315
- ): Promise<{ body: string; truncated: boolean }> {
316
- const text = await res.text();
317
- return capText(text, cap);
318
- }
319
-
320
- function maybeParseJson(text: string, contentType: string): unknown {
321
- if (!/json/i.test(contentType)) return text;
322
- try {
323
- return JSON.parse(text);
324
- } catch {
325
- return text;
326
- }
327
- }
328
-
329
- function isSSE(ct: string): boolean {
330
- return /text\/event-stream/i.test(ct);
331
- }
332
-
333
- function isTextLike(ct: string): boolean {
334
- return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
335
- }
336
-
337
- function pumpSSE(
338
- res: Response,
339
- cap: number,
340
- done: (body: string, truncated: boolean) => void,
341
- ): void {
342
- if (!res.body || typeof res.body.getReader !== 'function') {
343
- return done('', false);
344
- }
345
- const reader = res.body.getReader();
346
- const dec = new TextDecoder();
347
- let total = '';
348
- let truncated = false;
349
- const step = (): void => {
350
- reader.read().then(
351
- ({ done: end, value }) => {
352
- if (end) return done(total, truncated);
353
- total += dec.decode(value, { stream: true });
354
- if (total.length > cap) {
355
- total = total.slice(0, cap);
356
- truncated = true;
357
- reader.cancel().catch(() => {});
358
- return done(total, truncated);
359
- }
360
- step();
361
- },
362
- () => done(total, truncated),
363
- );
364
- };
365
- step();
366
- }
367
-
368
- function safeEmit(fn: (entry: NetworkEntry) => void, entry: NetworkEntry): void {
369
- try {
370
- fn(entry);
371
- } catch {
372
- /* swallow — capture must not break business */
373
- }
374
- }
package/src/initiator.ts DELETED
@@ -1,40 +0,0 @@
1
- /**
2
- * Initiator stack capture — answers "who issued this network/storage call?".
3
- *
4
- * Called synchronously from inside a patched API (fetch / xhr / ws / storage).
5
- * `new Error().stack` walks the JS call stack from the V8 perspective:
6
- * - frame 0: this helper
7
- * - frame 1: the patched wrapper
8
- * - frame 2+: caller code
9
- *
10
- * We trim the first 2 frames so the returned `stack` starts at the business
11
- * code that triggered the call. Best-effort: shapes vary across engines, so
12
- * if the format is unexpected we return the raw stack.
13
- *
14
- * Cost: ~0.2–0.5 ms per call on a modern V8. Safe to leave on in development;
15
- * gated behind NODE_ENV elsewhere so production is unaffected.
16
- */
17
-
18
- const FRAMES_TO_TRIM = 2;
19
-
20
- export interface Initiator {
21
- stack?: string;
22
- }
23
-
24
- export function captureInitiator(): Initiator {
25
- const err = new Error();
26
- const raw = err.stack;
27
- if (!raw) return {};
28
-
29
- const lines = raw.split('\n');
30
- if (lines.length <= FRAMES_TO_TRIM + 1) return { stack: raw };
31
-
32
- // Preserve the "Error" header line + caller frames. Drop the frames
33
- // representing the helper and the patched wrapper.
34
- const header = lines[0].startsWith('Error') ? lines[0] : '';
35
- const callerFrames = lines.slice(FRAMES_TO_TRIM + 1);
36
- const trimmed = header
37
- ? [header, ...callerFrames].join('\n')
38
- : callerFrames.join('\n');
39
- return { stack: trimmed };
40
- }
@@ -1,137 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
- import { installStoragePatch } from './storagePatch.js';
4
- import type { StorageEntry } from '@harness-fe/protocol';
5
-
6
- let entries: StorageEntry[];
7
- let dispose: () => void;
8
-
9
- beforeEach(() => {
10
- entries = [];
11
- try {
12
- window.localStorage.clear();
13
- window.sessionStorage.clear();
14
- } catch {
15
- /* ignore */
16
- }
17
- });
18
-
19
- afterEach(() => {
20
- dispose?.();
21
- });
22
-
23
- describe('installStoragePatch', () => {
24
- it('captures localStorage.setItem with initiator stack', () => {
25
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
26
- localStorage.setItem('token', 'abc123');
27
- const entry = entries.find((e) => e.op === 'set' && e.which === 'local')!;
28
- expect(entry).toBeDefined();
29
- expect(entry.key).toBe('token');
30
- expect(entry.value).toBe('abc123');
31
- expect(entry.initiator).toBeDefined();
32
- // setItem still actually wrote.
33
- expect(localStorage.getItem('token')).toBe('abc123');
34
- });
35
-
36
- it('captures localStorage.removeItem', () => {
37
- localStorage.setItem('token', 'x');
38
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
39
- localStorage.removeItem('token');
40
- const entry = entries.find((e) => e.op === 'remove')!;
41
- expect(entry).toBeDefined();
42
- expect(entry.key).toBe('token');
43
- expect(entry.which).toBe('local');
44
- expect(localStorage.getItem('token')).toBeNull();
45
- });
46
-
47
- it('captures localStorage.clear', () => {
48
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
49
- localStorage.setItem('a', '1');
50
- entries.length = 0;
51
- localStorage.clear();
52
- const entry = entries.find((e) => e.op === 'clear')!;
53
- expect(entry).toBeDefined();
54
- expect(entry.which).toBe('local');
55
- });
56
-
57
- it('disambiguates sessionStorage vs localStorage when they are distinct instances', () => {
58
- // happy-dom's sessionStorage and localStorage may share an underlying
59
- // instance, in which case the patch dispatches once with kind='local'.
60
- // We only assert the disambiguation in environments where the two are
61
- // genuinely distinct objects.
62
- if (window.sessionStorage === window.localStorage) {
63
- return;
64
- }
65
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
66
- sessionStorage.setItem('s', '1');
67
- localStorage.setItem('l', '1');
68
- const session = entries.find((e) => e.which === 'session');
69
- const local = entries.find((e) => e.which === 'local');
70
- expect(session?.key).toBe('s');
71
- expect(local?.key).toBe('l');
72
- });
73
-
74
- it('clips oversized values', () => {
75
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e), valueCap: 5 });
76
- localStorage.setItem('big', 'x'.repeat(50));
77
- const entry = entries.find((e) => e.key === 'big')!;
78
- expect(entry.value?.startsWith('xxxxx')).toBe(true);
79
- expect(entry.value?.includes('+45B')).toBe(true);
80
- });
81
-
82
- it('captures crossTab event without initiator', () => {
83
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
84
- // Simulate cross-tab event by dispatching a StorageEvent manually.
85
- const ev = new StorageEvent('storage', {
86
- key: 'token',
87
- newValue: null,
88
- oldValue: 'x',
89
- storageArea: window.localStorage,
90
- });
91
- window.dispatchEvent(ev);
92
- const entry = entries.find((e) => e.crossTab)!;
93
- expect(entry).toBeDefined();
94
- expect(entry.op).toBe('remove');
95
- expect(entry.which).toBe('local');
96
- expect(entry.initiator).toBeUndefined();
97
- });
98
-
99
- it('captures cookie set / remove via Max-Age=0 when document.cookie is descriptor-backed', () => {
100
- // happy-dom may not expose document.cookie via Document.prototype with
101
- // a property descriptor. We skip when the descriptor isn't writable
102
- // since the patch path is provably unreachable in that environment.
103
- const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
104
- if (!desc?.set || !desc?.get) return;
105
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
106
- document.cookie = 'sid=abc; Path=/';
107
- document.cookie = 'sid=; Max-Age=0; Path=/';
108
- const sets = entries.filter((e) => e.which === 'cookie' && e.op === 'set');
109
- const removes = entries.filter((e) => e.which === 'cookie' && e.op === 'remove');
110
- expect(sets.length).toBeGreaterThanOrEqual(1);
111
- expect(removes.length).toBeGreaterThanOrEqual(1);
112
- expect(sets[0].key).toBe('sid');
113
- expect(removes[0].key).toBe('sid');
114
- });
115
-
116
- it('parses cookie removal via past Expires', () => {
117
- const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
118
- if (!desc?.set || !desc?.get) return;
119
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
120
- document.cookie = 'sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/';
121
- const removes = entries.filter((e) => e.which === 'cookie' && e.op === 'remove');
122
- expect(removes.length).toBeGreaterThanOrEqual(1);
123
- });
124
-
125
- it('dispose stops capture and leaves storage operations working', () => {
126
- dispose = installStoragePatch({ onEntry: (e) => entries.push(e) });
127
- localStorage.setItem('foo', 'bar');
128
- expect(entries.length).toBeGreaterThan(0);
129
- const before = entries.length;
130
- dispose();
131
- // After dispose, further mutations should not push new entries.
132
- localStorage.setItem('foo2', 'bar2');
133
- expect(entries.length).toBe(before);
134
- // But the write itself still happened.
135
- expect(localStorage.getItem('foo2')).toBe('bar2');
136
- });
137
- });