@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.
@@ -1,191 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
- import { installXhrPatch } from './xhrPatch.js';
4
- import type { NetworkEntry } from '@harness-fe/protocol';
5
-
6
- let entries: NetworkEntry[];
7
- let dispose: () => void;
8
-
9
- beforeEach(() => {
10
- entries = [];
11
- });
12
-
13
- afterEach(() => {
14
- dispose?.();
15
- });
16
-
17
- /** Drive a single XHR using a synthetic loadend (no network in happy-dom). */
18
- function driveXhr(opts: {
19
- method?: string;
20
- url?: string;
21
- headers?: Record<string, string>;
22
- body?: XMLHttpRequestBodyInit | null;
23
- status?: number;
24
- responseText?: string;
25
- responseHeaders?: string;
26
- }): XMLHttpRequest {
27
- const xhr = new XMLHttpRequest();
28
- xhr.open(opts.method ?? 'GET', opts.url ?? 'http://x/');
29
- for (const [k, v] of Object.entries(opts.headers ?? {})) {
30
- xhr.setRequestHeader(k, v);
31
- }
32
- // Stub response-shape methods before send so the loadend handler reads them.
33
- Object.defineProperty(xhr, 'status', { value: opts.status ?? 200, configurable: true });
34
- Object.defineProperty(xhr, 'responseText', {
35
- value: opts.responseText ?? '',
36
- configurable: true,
37
- });
38
- Object.defineProperty(xhr, 'responseType', { value: '', configurable: true });
39
- const headerLines = opts.responseHeaders ?? 'content-type: application/json';
40
- xhr.getResponseHeader = (name: string) => {
41
- const lower = name.toLowerCase();
42
- for (const line of headerLines.split('\r\n')) {
43
- const idx = line.indexOf(':');
44
- if (idx < 0) continue;
45
- if (line.slice(0, idx).trim().toLowerCase() === lower) {
46
- return line.slice(idx + 1).trim();
47
- }
48
- }
49
- return null;
50
- };
51
- xhr.getAllResponseHeaders = () => headerLines;
52
- xhr.send(opts.body ?? null);
53
- // Fire loadend synthetically.
54
- xhr.dispatchEvent(new Event('loadend'));
55
- return xhr;
56
- }
57
-
58
- describe('installXhrPatch — identity', () => {
59
- it('preserves `instanceof XMLHttpRequest` after install', () => {
60
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
61
- const xhr = new XMLHttpRequest();
62
- expect(xhr).toBeInstanceOf(XMLHttpRequest);
63
- });
64
-
65
- it('dispose restores original prototype methods', () => {
66
- const origOpen = XMLHttpRequest.prototype.open;
67
- const origSend = XMLHttpRequest.prototype.send;
68
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
69
- expect(XMLHttpRequest.prototype.open).not.toBe(origOpen);
70
- dispose();
71
- dispose = () => {};
72
- expect(XMLHttpRequest.prototype.open).toBe(origOpen);
73
- expect(XMLHttpRequest.prototype.send).toBe(origSend);
74
- });
75
-
76
- it('re-install while patched is a no-op', () => {
77
- const d1 = installXhrPatch({ onEntry: (e) => entries.push(e) });
78
- const patchedOpen = XMLHttpRequest.prototype.open;
79
- const d2 = installXhrPatch({ onEntry: (e) => entries.push(e) });
80
- expect(XMLHttpRequest.prototype.open).toBe(patchedOpen);
81
- d2();
82
- d1();
83
- });
84
- });
85
-
86
- describe('installXhrPatch — emission', () => {
87
- it('emits req then res for a JSON GET', () => {
88
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
89
- driveXhr({ method: 'GET', responseText: '{"ok":true}' });
90
- const phases = entries.map((e) => e.phase);
91
- expect(phases).toContain('req');
92
- expect(phases).toContain('res');
93
- const res = entries.find((e) => e.phase === 'res')!;
94
- expect(res.status).toBe(200);
95
- expect(res.responseBody).toEqual({ ok: true });
96
- });
97
-
98
- it('serializes string body and emits with req record', () => {
99
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
100
- driveXhr({ method: 'POST', body: '{"x":1}', responseText: 'ok' });
101
- const reqWithBody = entries.find((e) => e.phase === 'req' && e.requestBody !== undefined)!;
102
- expect(reqWithBody.requestBody).toBe('{"x":1}');
103
- });
104
-
105
- it('caps response body at bodyCap', () => {
106
- const long = 'a'.repeat(2000);
107
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e), bodyCap: 500 });
108
- driveXhr({
109
- responseText: long,
110
- responseHeaders: 'content-type: text/plain',
111
- });
112
- const res = entries.find((e) => e.phase === 'res')!;
113
- expect(res.responseBodyTruncated).toBe(true);
114
- expect((res.responseBody as string).length).toBe(500);
115
- });
116
-
117
- it('redacts sensitive request headers', () => {
118
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
119
- driveXhr({
120
- method: 'POST',
121
- headers: {
122
- Authorization: 'Bearer xyz',
123
- 'x-api-key': 'sk-123',
124
- 'content-type': 'application/json',
125
- },
126
- body: '{}',
127
- responseText: '{}',
128
- });
129
- const req = entries.find((e) => e.phase === 'req')!;
130
- expect(req.requestHeaders!.Authorization).toMatch(/^\[redacted \d+\]$/);
131
- expect(req.requestHeaders!['x-api-key']).toMatch(/^\[redacted \d+\]$/);
132
- expect(req.requestHeaders!['content-type']).toBe('application/json');
133
- });
134
-
135
- it('emits res with error field when status is 0', () => {
136
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
137
- driveXhr({ status: 0 });
138
- const res = entries.find((e) => e.phase === 'res')!;
139
- expect(res.error).toBeDefined();
140
- expect(res.status).toBeUndefined();
141
- });
142
-
143
- it('skips capture entirely when x-hfe-internal header is set', () => {
144
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
145
- driveXhr({
146
- method: 'GET',
147
- headers: { 'x-hfe-internal': '1' },
148
- responseText: '{}',
149
- });
150
- expect(entries).toHaveLength(0);
151
- });
152
-
153
- it('records binary response only as a size placeholder', () => {
154
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
155
- driveXhr({
156
- responseHeaders: 'content-type: application/octet-stream\r\ncontent-length: 1234',
157
- responseText: '',
158
- });
159
- const res = entries.find((e) => e.phase === 'res')!;
160
- expect(res.responseBody).toBe('[binary 1234B]');
161
- });
162
-
163
- it('keeps native loadend listeners firing', () => {
164
- dispose = installXhrPatch({ onEntry: (e) => entries.push(e) });
165
- let userCalled = false;
166
- const xhr = new XMLHttpRequest();
167
- xhr.addEventListener('loadend', () => {
168
- userCalled = true;
169
- });
170
- xhr.open('GET', 'http://x/');
171
- Object.defineProperty(xhr, 'status', { value: 200, configurable: true });
172
- Object.defineProperty(xhr, 'responseText', { value: 'ok', configurable: true });
173
- Object.defineProperty(xhr, 'responseType', { value: '', configurable: true });
174
- xhr.getAllResponseHeaders = () => 'content-type: text/plain';
175
- xhr.getResponseHeader = () => 'text/plain';
176
- xhr.send(null);
177
- xhr.dispatchEvent(new Event('loadend'));
178
- expect(userCalled).toBe(true);
179
- });
180
-
181
- it('does not let an onEntry throw crash xhr.send', () => {
182
- dispose = installXhrPatch({
183
- onEntry: () => {
184
- throw new Error('boom');
185
- },
186
- });
187
- expect(() =>
188
- driveXhr({ responseText: 'ok', responseHeaders: 'content-type: text/plain' }),
189
- ).not.toThrow();
190
- });
191
- });
package/src/xhrPatch.ts DELETED
@@ -1,317 +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
-
21
- import type { NetworkEntry } from '@harness-fe/protocol';
22
- import { captureInitiator } from './initiator.js';
23
-
24
- const DEFAULT_BODY_CAP = 256 * 1024;
25
- const PATCHED_FLAG = '__hfeXhrPatched';
26
- const META_KEY = Symbol.for('@harness-fe/xhr-meta');
27
- const INTERNAL_HEADER = 'x-hfe-internal';
28
- const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
29
-
30
- export interface XhrPatchOptions {
31
- onEntry: (entry: NetworkEntry) => void;
32
- bodyCap?: number;
33
- denylist?: RegExp[];
34
- }
35
-
36
- interface XhrMeta {
37
- id: string;
38
- method: string;
39
- url: string;
40
- headers: Record<string, string>;
41
- startedAt: number;
42
- startedTs: number;
43
- bodyCap: number;
44
- internal: boolean;
45
- skipped: boolean;
46
- reqEmitted: boolean;
47
- }
48
-
49
- interface PatchedXhr extends XMLHttpRequest {
50
- [META_KEY]?: XhrMeta;
51
- }
52
-
53
- export function installXhrPatch(opts: XhrPatchOptions): () => void {
54
- if (typeof XMLHttpRequest === 'undefined') return () => {};
55
- const proto = XMLHttpRequest.prototype as XMLHttpRequest & Record<string, unknown>;
56
- if ((proto as Record<string, unknown>)[PATCHED_FLAG]) return () => {};
57
-
58
- const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
59
- const denylist = opts.denylist ?? [];
60
- const emit = (entry: NetworkEntry): void => safeEmit(opts.onEntry, entry);
61
-
62
- const origOpen = proto.open;
63
- const origSetHeader = proto.setRequestHeader;
64
- const origSend = proto.send;
65
-
66
- const patchedOpen = function open(
67
- this: PatchedXhr,
68
- method: string,
69
- url: string | URL,
70
- ...rest: unknown[]
71
- ): void {
72
- const meta: XhrMeta = {
73
- id: generateId(),
74
- method,
75
- url: typeof url === 'string' ? url : url.toString(),
76
- headers: {},
77
- startedAt: 0,
78
- startedTs: 0,
79
- bodyCap,
80
- internal: false,
81
- skipped: denylist.some((re) => re.test(typeof url === 'string' ? url : url.toString())),
82
- reqEmitted: false,
83
- };
84
- this[META_KEY] = meta;
85
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
- return (origOpen as any).call(this, method, url, ...rest);
87
- } as typeof proto.open;
88
-
89
- const patchedSetHeader = function setRequestHeader(
90
- this: PatchedXhr,
91
- name: string,
92
- value: string,
93
- ): void {
94
- const meta = this[META_KEY];
95
- if (meta) {
96
- if (name.toLowerCase() === INTERNAL_HEADER) {
97
- meta.internal = true;
98
- } else {
99
- meta.headers[name] = value;
100
- }
101
- }
102
- // Do NOT forward the internal sentinel to the server.
103
- if (name.toLowerCase() === INTERNAL_HEADER) return;
104
- return origSetHeader.call(this, name, value);
105
- } as typeof proto.setRequestHeader;
106
-
107
- const patchedSend = function send(
108
- this: PatchedXhr,
109
- body?: Document | XMLHttpRequestBodyInit | null,
110
- ): void {
111
- const meta = this[META_KEY];
112
- if (!meta || meta.internal || meta.skipped) {
113
- return origSend.call(this, body ?? null);
114
- }
115
- meta.startedAt = performance.now();
116
- meta.startedTs = Date.now();
117
- const initiator = captureInitiator();
118
-
119
- // Emit req eagerly with headers; body added on second emit after
120
- // serialization (mirrors fetchPatch behavior).
121
- const reqRecord: NetworkEntry = {
122
- ts: meta.startedTs,
123
- id: meta.id,
124
- phase: 'req',
125
- method: meta.method,
126
- url: meta.url,
127
- requestHeaders: redactHeaders(meta.headers),
128
- initiator,
129
- };
130
- emit(reqRecord);
131
- meta.reqEmitted = true;
132
-
133
- const serialized = serializeBody(body, meta.bodyCap);
134
- if (serialized.body !== undefined || serialized.truncated) {
135
- emit({
136
- ...reqRecord,
137
- requestBody: serialized.body,
138
- requestBodyTruncated: serialized.truncated || undefined,
139
- });
140
- }
141
-
142
- this.addEventListener('loadend', () => {
143
- const status = this.status;
144
- const ct = safeGetResponseHeader(this, 'content-type') ?? '';
145
- const respHeaders = parseAllResponseHeaders(this);
146
- const isErr = status === 0;
147
- const baseRes: NetworkEntry = {
148
- ts: Date.now(),
149
- id: meta.id,
150
- phase: 'res',
151
- method: meta.method,
152
- url: meta.url,
153
- status: isErr ? undefined : status,
154
- responseHeaders: respHeaders,
155
- durationMs: performance.now() - meta.startedAt,
156
- };
157
- if (isErr) {
158
- emit({ ...baseRes, error: 'xhr error or aborted' });
159
- return;
160
- }
161
- if (isTextLike(ct)) {
162
- const text = safeReadResponseText(this);
163
- if (text === undefined) {
164
- emit(baseRes);
165
- return;
166
- }
167
- const capped = capText(text, meta.bodyCap);
168
- emit({
169
- ...baseRes,
170
- responseBody: /json/i.test(ct) ? safeParseJson(capped.body) : capped.body,
171
- responseBodyTruncated: capped.truncated || undefined,
172
- });
173
- } else {
174
- // Binary or unknown → don't pull bytes, just record size when available.
175
- const len = Number(safeGetResponseHeader(this, 'content-length') ?? '0') || 0;
176
- emit({
177
- ...baseRes,
178
- responseBody: len ? `[binary ${len}B]` : '[binary]',
179
- });
180
- }
181
- });
182
-
183
- return origSend.call(this, body ?? null);
184
- } as typeof proto.send;
185
-
186
- proto.open = patchedOpen;
187
- proto.setRequestHeader = patchedSetHeader;
188
- proto.send = patchedSend;
189
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
190
- (proto as any)[PATCHED_FLAG] = true;
191
-
192
- return () => {
193
- // Only restore if we still own the patch — don't clobber a later patch.
194
- if (proto.open !== patchedOpen) return;
195
- proto.open = origOpen;
196
- proto.setRequestHeader = origSetHeader;
197
- proto.send = origSend;
198
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
- delete (proto as any)[PATCHED_FLAG];
200
- };
201
- }
202
-
203
- // ─── helpers ────────────────────────────────────────────────────────────────
204
-
205
- function generateId(): string {
206
- try {
207
- return crypto.randomUUID();
208
- } catch {
209
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
210
- }
211
- }
212
-
213
- function redactHeaders(h: Record<string, string>): Record<string, string> {
214
- const out: Record<string, string> = {};
215
- for (const [k, v] of Object.entries(h)) {
216
- out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
217
- }
218
- return out;
219
- }
220
-
221
- function serializeBody(
222
- body: Document | XMLHttpRequestBodyInit | null | undefined,
223
- cap: number,
224
- ): { body?: unknown; truncated: boolean } {
225
- if (body === undefined || body === null) return { truncated: false };
226
- if (typeof body === 'string') return capText(body, cap);
227
- if (typeof FormData !== 'undefined' && body instanceof FormData) {
228
- const obj: Record<string, unknown> = {};
229
- body.forEach((v, k) => {
230
- obj[k] = typeof v === 'string'
231
- ? v
232
- : `[File ${(v as File).name} ${(v as File).size}B]`;
233
- });
234
- return { body: obj, truncated: false };
235
- }
236
- if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
237
- return { body: body.toString(), truncated: false };
238
- }
239
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
240
- return { body: `[Blob ${body.size}B]`, truncated: false };
241
- }
242
- if (body instanceof ArrayBuffer) {
243
- return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
244
- }
245
- if (ArrayBuffer.isView(body)) {
246
- return {
247
- body: `[${(body.constructor as { name: string }).name} ${body.byteLength}B]`,
248
- truncated: false,
249
- };
250
- }
251
- if (typeof Document !== 'undefined' && body instanceof Document) {
252
- return { body: '[Document]', truncated: false };
253
- }
254
- return { body: '[unknown body]', truncated: false };
255
- }
256
-
257
- function capText(text: string, cap: number): { body: string; truncated: boolean } {
258
- if (text.length > cap) return { body: text.slice(0, cap), truncated: true };
259
- return { body: text, truncated: false };
260
- }
261
-
262
- function safeGetResponseHeader(xhr: XMLHttpRequest, name: string): string | null {
263
- try {
264
- return xhr.getResponseHeader(name);
265
- } catch {
266
- return null;
267
- }
268
- }
269
-
270
- function parseAllResponseHeaders(xhr: XMLHttpRequest): Record<string, string> | undefined {
271
- let raw: string;
272
- try {
273
- raw = xhr.getAllResponseHeaders();
274
- } catch {
275
- return undefined;
276
- }
277
- if (!raw) return undefined;
278
- const out: Record<string, string> = {};
279
- for (const line of raw.split('\r\n')) {
280
- const idx = line.indexOf(':');
281
- if (idx < 0) continue;
282
- const k = line.slice(0, idx).trim();
283
- const v = line.slice(idx + 1).trim();
284
- if (k) out[k] = v;
285
- }
286
- return Object.keys(out).length ? redactHeaders(out) : undefined;
287
- }
288
-
289
- function safeReadResponseText(xhr: XMLHttpRequest): string | undefined {
290
- try {
291
- // responseType must be '' or 'text' to access responseText.
292
- if (xhr.responseType !== '' && xhr.responseType !== 'text') return undefined;
293
- return xhr.responseText;
294
- } catch {
295
- return undefined;
296
- }
297
- }
298
-
299
- function safeParseJson(text: string): unknown {
300
- try {
301
- return JSON.parse(text);
302
- } catch {
303
- return text;
304
- }
305
- }
306
-
307
- function isTextLike(ct: string): boolean {
308
- return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
309
- }
310
-
311
- function safeEmit(fn: (entry: NetworkEntry) => void, entry: NetworkEntry): void {
312
- try {
313
- fn(entry);
314
- } catch {
315
- /* swallow */
316
- }
317
- }