@harness-fe/runtime 3.0.1 → 3.1.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 +36 -1
- package/dist/capture.js +40 -1
- package/dist/client.js +2 -1
- package/dist/commands.js +145 -3
- package/dist/fetchPatch.js +3 -0
- package/dist/initiator.d.ts +20 -0
- package/dist/initiator.js +34 -0
- package/dist/storagePatch.d.ts +23 -0
- package/dist/storagePatch.js +190 -0
- package/dist/wsPatch.d.ts +26 -0
- package/dist/wsPatch.js +172 -0
- package/dist/xhrPatch.js +3 -0
- package/package.json +2 -2
- package/src/capture.ts +47 -1
- package/src/client.ts +5 -1
- package/src/commands.ts +163 -6
- package/src/commandsFilter.test.ts +167 -0
- package/src/commandsNetwork.e2e.test.ts +146 -0
- package/src/fetchPatch.test.ts +14 -0
- package/src/fetchPatch.ts +3 -0
- package/src/initiator.ts +40 -0
- package/src/runtimeClient.e2e.test.ts +246 -0
- package/src/storagePatch.test.ts +137 -0
- package/src/storagePatch.ts +213 -0
- package/src/wsPatch.test.ts +150 -0
- package/src/wsPatch.ts +198 -0
- package/src/xhrPatch.ts +3 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage monkey-patch — captures localStorage / sessionStorage / cookie
|
|
3
|
+
* mutations so agents can answer "who deleted my token?".
|
|
4
|
+
*
|
|
5
|
+
* Safety contract (mirrors fetchPatch / wsPatch):
|
|
6
|
+
* 1. Identity-preserving: replacements use Object.defineProperty so
|
|
7
|
+
* `localStorage.setItem` keeps its prototype membership. Cookie wrapping
|
|
8
|
+
* uses a `document` getter/setter pair on the prototype.
|
|
9
|
+
* 2. Error-isolated: capture failures swallowed; never propagate to callers.
|
|
10
|
+
* 3. No timing or value change: every wrapper calls the original synchronously
|
|
11
|
+
* and returns its result.
|
|
12
|
+
* 4. crossTab events captured via the native `storage` event (no initiator —
|
|
13
|
+
* the mutation happened in another tab so the stack is meaningless here).
|
|
14
|
+
*
|
|
15
|
+
* Idempotent. Returns a dispose function.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { StorageEntry } from '@harness-fe/protocol';
|
|
19
|
+
import { captureInitiator } from './initiator.js';
|
|
20
|
+
|
|
21
|
+
const PATCHED_FLAG = '__hfeStoragePatched';
|
|
22
|
+
|
|
23
|
+
export interface StoragePatchOptions {
|
|
24
|
+
onEntry: (entry: StorageEntry) => void;
|
|
25
|
+
/** Per-value byte cap. Default 4 KB — captures small tokens, drops giant blobs. */
|
|
26
|
+
valueCap?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_VALUE_CAP = 4 * 1024;
|
|
30
|
+
|
|
31
|
+
export function installStoragePatch(opts: StoragePatchOptions): () => void {
|
|
32
|
+
if (typeof window === 'undefined') return () => {};
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
if ((window as any)[PATCHED_FLAG]) return () => {};
|
|
35
|
+
|
|
36
|
+
const valueCap = opts.valueCap ?? DEFAULT_VALUE_CAP;
|
|
37
|
+
const emit = (entry: StorageEntry): void => {
|
|
38
|
+
try {
|
|
39
|
+
opts.onEntry(entry);
|
|
40
|
+
} catch {
|
|
41
|
+
/* swallow */
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const disposers: Array<() => void> = [];
|
|
46
|
+
|
|
47
|
+
// Patch each storage instance directly — own properties shadow the
|
|
48
|
+
// prototype regardless of how the engine implements them. This works
|
|
49
|
+
// uniformly across real browsers, happy-dom, jsdom, and Electron.
|
|
50
|
+
try {
|
|
51
|
+
disposers.push(patchStorageInstance(window.localStorage, 'local', valueCap, emit));
|
|
52
|
+
} catch { /* localStorage may be inaccessible (private mode, etc.) */ }
|
|
53
|
+
try {
|
|
54
|
+
disposers.push(patchStorageInstance(window.sessionStorage, 'session', valueCap, emit));
|
|
55
|
+
} catch { /* ignore */ }
|
|
56
|
+
|
|
57
|
+
if (typeof document !== 'undefined') {
|
|
58
|
+
const cookieDispose = patchCookie(valueCap, emit);
|
|
59
|
+
if (cookieDispose) disposers.push(cookieDispose);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Cross-tab storage events.
|
|
63
|
+
const onStorageEvent = (ev: StorageEvent): void => {
|
|
64
|
+
const which: StorageEntry['which'] = ev.storageArea === window.sessionStorage ? 'session' : 'local';
|
|
65
|
+
const op: StorageEntry['op'] = ev.key === null ? 'clear' : ev.newValue === null ? 'remove' : 'set';
|
|
66
|
+
emit({
|
|
67
|
+
ts: Date.now(),
|
|
68
|
+
op,
|
|
69
|
+
which,
|
|
70
|
+
key: ev.key ?? undefined,
|
|
71
|
+
value: ev.newValue !== null ? clip(ev.newValue, valueCap) : undefined,
|
|
72
|
+
crossTab: true,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
window.addEventListener('storage', onStorageEvent);
|
|
76
|
+
disposers.push(() => window.removeEventListener('storage', onStorageEvent));
|
|
77
|
+
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
(window as any)[PATCHED_FLAG] = true;
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
for (const d of disposers) {
|
|
83
|
+
try {
|
|
84
|
+
d();
|
|
85
|
+
} catch {
|
|
86
|
+
/* ignore */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
|
+
delete (window as any)[PATCHED_FLAG];
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Patch Storage.prototype.setItem / removeItem / clear. Both localStorage and
|
|
96
|
+
* sessionStorage share the same prototype, so one patch covers both — we
|
|
97
|
+
* disambiguate at call time via `this === window.sessionStorage`.
|
|
98
|
+
*/
|
|
99
|
+
function patchStorageInstance(
|
|
100
|
+
storage: Storage,
|
|
101
|
+
kind: StorageEntry['which'],
|
|
102
|
+
valueCap: number,
|
|
103
|
+
emit: (entry: StorageEntry) => void,
|
|
104
|
+
): () => void {
|
|
105
|
+
const origSet = storage.setItem.bind(storage);
|
|
106
|
+
const origRemove = storage.removeItem.bind(storage);
|
|
107
|
+
const origClear = storage.clear.bind(storage);
|
|
108
|
+
|
|
109
|
+
Object.defineProperty(storage, 'setItem', {
|
|
110
|
+
configurable: true, writable: true,
|
|
111
|
+
value: (key: string, value: string) => {
|
|
112
|
+
emit({ ts: Date.now(), op: 'set', which: kind, key, value: clip(value, valueCap), initiator: captureInitiator() });
|
|
113
|
+
origSet(key, value);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
Object.defineProperty(storage, 'removeItem', {
|
|
117
|
+
configurable: true, writable: true,
|
|
118
|
+
value: (key: string) => {
|
|
119
|
+
emit({ ts: Date.now(), op: 'remove', which: kind, key, initiator: captureInitiator() });
|
|
120
|
+
origRemove(key);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
Object.defineProperty(storage, 'clear', {
|
|
124
|
+
configurable: true, writable: true,
|
|
125
|
+
value: () => {
|
|
126
|
+
emit({ ts: Date.now(), op: 'clear', which: kind, initiator: captureInitiator() });
|
|
127
|
+
origClear();
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
return () => {
|
|
131
|
+
// Restore by replacing the own properties with thin shims that
|
|
132
|
+
// forward to the captured originals. `delete` doesn't reliably
|
|
133
|
+
// expose the prototype method in every engine (happy-dom in
|
|
134
|
+
// particular), so this is the safer reset.
|
|
135
|
+
try {
|
|
136
|
+
Object.defineProperty(storage, 'setItem', {
|
|
137
|
+
configurable: true, writable: true,
|
|
138
|
+
value: (k: string, v: string) => origSet(k, v),
|
|
139
|
+
});
|
|
140
|
+
Object.defineProperty(storage, 'removeItem', {
|
|
141
|
+
configurable: true, writable: true,
|
|
142
|
+
value: (k: string) => origRemove(k),
|
|
143
|
+
});
|
|
144
|
+
Object.defineProperty(storage, 'clear', {
|
|
145
|
+
configurable: true, writable: true,
|
|
146
|
+
value: () => origClear(),
|
|
147
|
+
});
|
|
148
|
+
} catch { /* ignore */ }
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function patchCookie(
|
|
153
|
+
valueCap: number,
|
|
154
|
+
emit: (entry: StorageEntry) => void,
|
|
155
|
+
): (() => void) | undefined {
|
|
156
|
+
const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
|
|
157
|
+
if (!descriptor || !descriptor.set || !descriptor.get) return undefined;
|
|
158
|
+
const origSet = descriptor.set;
|
|
159
|
+
const origGet = descriptor.get;
|
|
160
|
+
|
|
161
|
+
Object.defineProperty(Document.prototype, 'cookie', {
|
|
162
|
+
configurable: true,
|
|
163
|
+
get(this: Document) {
|
|
164
|
+
return origGet.call(this);
|
|
165
|
+
},
|
|
166
|
+
set(this: Document, val: string) {
|
|
167
|
+
const initiator = captureInitiator();
|
|
168
|
+
const { key, value, removed } = parseCookieAssignment(val);
|
|
169
|
+
emit({
|
|
170
|
+
ts: Date.now(),
|
|
171
|
+
op: removed ? 'remove' : 'set',
|
|
172
|
+
which: 'cookie',
|
|
173
|
+
key,
|
|
174
|
+
value: removed ? undefined : value !== undefined ? clip(value, valueCap) : undefined,
|
|
175
|
+
initiator,
|
|
176
|
+
});
|
|
177
|
+
return origSet.call(this, val);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return () => {
|
|
182
|
+
Object.defineProperty(Document.prototype, 'cookie', descriptor);
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Cookie writes look like "key=value; Path=/; Expires=...; Max-Age=0".
|
|
188
|
+
* Treat Max-Age=0 or any past Expires as removal. Anything else is set.
|
|
189
|
+
*/
|
|
190
|
+
function parseCookieAssignment(raw: string): { key?: string; value?: string; removed: boolean } {
|
|
191
|
+
const parts = raw.split(';');
|
|
192
|
+
const head = (parts[0] ?? '').trim();
|
|
193
|
+
const eq = head.indexOf('=');
|
|
194
|
+
const key = eq >= 0 ? head.slice(0, eq) : head;
|
|
195
|
+
const value = eq >= 0 ? head.slice(eq + 1) : undefined;
|
|
196
|
+
let removed = false;
|
|
197
|
+
for (let i = 1; i < parts.length; i++) {
|
|
198
|
+
const seg = parts[i].trim();
|
|
199
|
+
const lower = seg.toLowerCase();
|
|
200
|
+
if (lower === 'max-age=0' || lower === 'max-age=-1') removed = true;
|
|
201
|
+
if (lower.startsWith('expires=')) {
|
|
202
|
+
const date = new Date(seg.slice('expires='.length).trim());
|
|
203
|
+
if (!Number.isNaN(date.getTime()) && date.getTime() <= Date.now()) {
|
|
204
|
+
removed = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { key: key || undefined, value, removed };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function clip(s: string, cap: number): string {
|
|
212
|
+
return s.length <= cap ? s : `${s.slice(0, cap)}…[+${s.length - cap}B]`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { installWsPatch } from './wsPatch.js';
|
|
4
|
+
import type { WsEntry } from '@harness-fe/protocol';
|
|
5
|
+
|
|
6
|
+
let entries: WsEntry[];
|
|
7
|
+
let dispose: () => void;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* happy-dom's WebSocket attempts real network on construction. We replace it
|
|
11
|
+
* with a controllable fake so the patch can be exercised in isolation.
|
|
12
|
+
*/
|
|
13
|
+
class FakeWebSocket extends EventTarget {
|
|
14
|
+
static readonly CONNECTING = 0;
|
|
15
|
+
static readonly OPEN = 1;
|
|
16
|
+
static readonly CLOSING = 2;
|
|
17
|
+
static readonly CLOSED = 3;
|
|
18
|
+
readonly url: string;
|
|
19
|
+
readonly protocols?: string | string[];
|
|
20
|
+
readyState = FakeWebSocket.CONNECTING;
|
|
21
|
+
sent: Array<unknown> = [];
|
|
22
|
+
|
|
23
|
+
constructor(url: string | URL, protocols?: string | string[]) {
|
|
24
|
+
super();
|
|
25
|
+
this.url = typeof url === 'string' ? url : url.toString();
|
|
26
|
+
this.protocols = protocols;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
send(data: unknown): void {
|
|
30
|
+
this.sent.push(data);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
close(_code?: number, _reason?: string): void {
|
|
34
|
+
this.readyState = FakeWebSocket.CLOSED;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Test helpers — dispatch synthetic events.
|
|
38
|
+
fireMessage(data: unknown): void {
|
|
39
|
+
// happy-dom MessageEvent ctor accepts a `data` init.
|
|
40
|
+
this.dispatchEvent(new MessageEvent('message', { data: data as string }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fireClose(code = 1000, reason = '', wasClean = true): void {
|
|
44
|
+
this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean }));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let originalWs: typeof WebSocket;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
entries = [];
|
|
52
|
+
originalWs = window.WebSocket;
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
(window as any).WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
dispose?.();
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
(window as any).WebSocket = originalWs;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('installWsPatch', () => {
|
|
64
|
+
it('emits open on construction with url + protocols', () => {
|
|
65
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e) });
|
|
66
|
+
new window.WebSocket('wss://example.test/ws', ['v1', 'v2']);
|
|
67
|
+
const open = entries.find((e) => e.phase === 'open')!;
|
|
68
|
+
expect(open).toBeDefined();
|
|
69
|
+
expect(open.url).toBe('wss://example.test/ws');
|
|
70
|
+
expect(open.protocols).toEqual(['v1', 'v2']);
|
|
71
|
+
expect(open.initiator).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('captures send with parsed JSON and initiator stack', () => {
|
|
75
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e) });
|
|
76
|
+
const ws = new window.WebSocket('wss://x/');
|
|
77
|
+
ws.send(JSON.stringify({ kind: 'ping', n: 1 }));
|
|
78
|
+
const send = entries.find((e) => e.phase === 'send')!;
|
|
79
|
+
expect(send).toBeDefined();
|
|
80
|
+
expect(send.payload).toEqual({ kind: 'ping', n: 1 });
|
|
81
|
+
expect(send.initiator).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('captures recv frames, parses JSON, keeps raw text otherwise', () => {
|
|
85
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e) });
|
|
86
|
+
const ws = new window.WebSocket('wss://x/') as unknown as FakeWebSocket;
|
|
87
|
+
ws.fireMessage(JSON.stringify({ notifyType: 'kick' }));
|
|
88
|
+
ws.fireMessage('hello');
|
|
89
|
+
const recvs = entries.filter((e) => e.phase === 'recv');
|
|
90
|
+
expect(recvs).toHaveLength(2);
|
|
91
|
+
expect(recvs[0].payload).toEqual({ notifyType: 'kick' });
|
|
92
|
+
expect(recvs[1].payload).toBe('hello');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('captures close with code + reason', () => {
|
|
96
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e) });
|
|
97
|
+
const ws = new window.WebSocket('wss://x/') as unknown as FakeWebSocket;
|
|
98
|
+
ws.fireClose(4001, 'kicked', false);
|
|
99
|
+
const close = entries.find((e) => e.phase === 'close')!;
|
|
100
|
+
expect(close.code).toBe(4001);
|
|
101
|
+
expect(close.reason).toBe('kicked');
|
|
102
|
+
expect(close.wasClean).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('open / send / recv / close share the same id', () => {
|
|
106
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e) });
|
|
107
|
+
const ws = new window.WebSocket('wss://x/') as unknown as FakeWebSocket;
|
|
108
|
+
ws.send('hi');
|
|
109
|
+
ws.fireMessage('there');
|
|
110
|
+
ws.fireClose();
|
|
111
|
+
const ids = new Set(entries.map((e) => e.id));
|
|
112
|
+
expect(ids.size).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('truncates text payload above bodyCap', () => {
|
|
116
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e), bodyCap: 10 });
|
|
117
|
+
const ws = new window.WebSocket('wss://x/');
|
|
118
|
+
ws.send('x'.repeat(50));
|
|
119
|
+
const send = entries.find((e) => e.phase === 'send')!;
|
|
120
|
+
expect(send.payloadTruncated).toBe(true);
|
|
121
|
+
expect((send.payload as string).length).toBeLessThanOrEqual(10);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('skips denylisted URLs (no entries emitted)', () => {
|
|
125
|
+
dispose = installWsPatch({
|
|
126
|
+
onEntry: (e) => entries.push(e),
|
|
127
|
+
denylist: [/test\.skip/],
|
|
128
|
+
});
|
|
129
|
+
new window.WebSocket('ws://test.skip/x');
|
|
130
|
+
expect(entries).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('records binary payloads as size markers', () => {
|
|
134
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e) });
|
|
135
|
+
const ws = new window.WebSocket('wss://x/');
|
|
136
|
+
ws.send(new Uint8Array(32));
|
|
137
|
+
const send = entries.find((e) => e.phase === 'send')!;
|
|
138
|
+
expect(typeof send.payload).toBe('string');
|
|
139
|
+
expect(send.payload as string).toContain('[binary');
|
|
140
|
+
expect(send.payload as string).toContain('32');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('dispose restores window.WebSocket', () => {
|
|
144
|
+
const before = window.WebSocket;
|
|
145
|
+
dispose = installWsPatch({ onEntry: (e) => entries.push(e) });
|
|
146
|
+
expect(window.WebSocket).not.toBe(before);
|
|
147
|
+
dispose();
|
|
148
|
+
expect(window.WebSocket).toBe(before);
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/wsPatch.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
|
|
20
|
+
import type { WsEntry } from '@harness-fe/protocol';
|
|
21
|
+
import { captureInitiator } from './initiator.js';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
24
|
+
const PATCHED_FLAG = '__hfeWsPatched';
|
|
25
|
+
|
|
26
|
+
export interface WsPatchOptions {
|
|
27
|
+
onEntry: (entry: WsEntry) => void;
|
|
28
|
+
bodyCap?: number;
|
|
29
|
+
/** URL patterns to skip entirely. Default skips daemon traffic. */
|
|
30
|
+
denylist?: RegExp[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_DENYLIST: RegExp[] = [/\/__hfe\//, /sockjs-node/];
|
|
34
|
+
|
|
35
|
+
export function installWsPatch(opts: WsPatchOptions): () => void {
|
|
36
|
+
if (typeof window === 'undefined' || typeof window.WebSocket !== 'function') {
|
|
37
|
+
return () => {};
|
|
38
|
+
}
|
|
39
|
+
const OriginalWS = window.WebSocket;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
if ((OriginalWS as any)[PATCHED_FLAG]) return () => {};
|
|
42
|
+
|
|
43
|
+
const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
|
|
44
|
+
const denylist = opts.denylist ?? DEFAULT_DENYLIST;
|
|
45
|
+
const emit = (entry: WsEntry): void => {
|
|
46
|
+
try {
|
|
47
|
+
opts.onEntry(entry);
|
|
48
|
+
} catch {
|
|
49
|
+
/* swallow */
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const Patched = function PatchedWebSocket(
|
|
54
|
+
this: WebSocket,
|
|
55
|
+
url: string | URL,
|
|
56
|
+
protocols?: string | string[],
|
|
57
|
+
): WebSocket {
|
|
58
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
59
|
+
const ws = protocols !== undefined
|
|
60
|
+
? new OriginalWS(url, protocols)
|
|
61
|
+
: new OriginalWS(url);
|
|
62
|
+
|
|
63
|
+
if (denylist.some((re) => re.test(urlStr))) return ws;
|
|
64
|
+
|
|
65
|
+
const id = generateId();
|
|
66
|
+
const protoList = Array.isArray(protocols)
|
|
67
|
+
? protocols
|
|
68
|
+
: typeof protocols === 'string'
|
|
69
|
+
? [protocols]
|
|
70
|
+
: undefined;
|
|
71
|
+
const openInitiator = captureInitiator();
|
|
72
|
+
|
|
73
|
+
emit({
|
|
74
|
+
ts: Date.now(),
|
|
75
|
+
id,
|
|
76
|
+
phase: 'open',
|
|
77
|
+
url: urlStr,
|
|
78
|
+
protocols: protoList,
|
|
79
|
+
initiator: openInitiator,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
83
|
+
const { payload, truncated } = serializeFrame(ev.data, bodyCap);
|
|
84
|
+
emit({
|
|
85
|
+
ts: Date.now(),
|
|
86
|
+
id,
|
|
87
|
+
phase: 'recv',
|
|
88
|
+
url: urlStr,
|
|
89
|
+
payload,
|
|
90
|
+
payloadTruncated: truncated || undefined,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ws.addEventListener('close', (ev: CloseEvent) => {
|
|
95
|
+
emit({
|
|
96
|
+
ts: Date.now(),
|
|
97
|
+
id,
|
|
98
|
+
phase: 'close',
|
|
99
|
+
url: urlStr,
|
|
100
|
+
code: ev.code,
|
|
101
|
+
reason: ev.reason || undefined,
|
|
102
|
+
wasClean: ev.wasClean,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Wrap `send` on this instance so we record outgoing payloads + caller.
|
|
107
|
+
const origSend = ws.send.bind(ws);
|
|
108
|
+
ws.send = function patchedSend(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
|
109
|
+
const initiator = captureInitiator();
|
|
110
|
+
const { payload, truncated } = serializeFrame(data, bodyCap);
|
|
111
|
+
emit({
|
|
112
|
+
ts: Date.now(),
|
|
113
|
+
id,
|
|
114
|
+
phase: 'send',
|
|
115
|
+
url: urlStr,
|
|
116
|
+
payload,
|
|
117
|
+
payloadTruncated: truncated || undefined,
|
|
118
|
+
initiator,
|
|
119
|
+
});
|
|
120
|
+
return origSend(data);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return ws;
|
|
124
|
+
} as unknown as typeof WebSocket;
|
|
125
|
+
|
|
126
|
+
// Preserve constructor surface so library detection still works.
|
|
127
|
+
Patched.prototype = OriginalWS.prototype;
|
|
128
|
+
for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const) {
|
|
129
|
+
try {
|
|
130
|
+
Object.defineProperty(Patched, key, {
|
|
131
|
+
value: OriginalWS[key],
|
|
132
|
+
writable: false,
|
|
133
|
+
configurable: true,
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
/* readonly already — ignore */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
Object.defineProperty(Patched, 'name', { value: 'WebSocket' });
|
|
141
|
+
} catch {
|
|
142
|
+
/* ignore */
|
|
143
|
+
}
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
|
+
(Patched as any)[PATCHED_FLAG] = true;
|
|
146
|
+
|
|
147
|
+
window.WebSocket = Patched;
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
if (window.WebSocket === Patched) {
|
|
151
|
+
window.WebSocket = OriginalWS;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function generateId(): string {
|
|
157
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
158
|
+
return crypto.randomUUID();
|
|
159
|
+
}
|
|
160
|
+
return `ws_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function serializeFrame(
|
|
164
|
+
data: unknown,
|
|
165
|
+
cap: number,
|
|
166
|
+
): { payload?: unknown; truncated: boolean } {
|
|
167
|
+
if (typeof data === 'string') {
|
|
168
|
+
if (data.length <= cap) {
|
|
169
|
+
// Try JSON for structured payloads.
|
|
170
|
+
const parsed = tryJson(data);
|
|
171
|
+
return { payload: parsed !== undefined ? parsed : data, truncated: false };
|
|
172
|
+
}
|
|
173
|
+
return { payload: data.slice(0, cap), truncated: true };
|
|
174
|
+
}
|
|
175
|
+
if (data instanceof ArrayBuffer) {
|
|
176
|
+
return { payload: `[binary ArrayBuffer ${data.byteLength}B]`, truncated: false };
|
|
177
|
+
}
|
|
178
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
179
|
+
return { payload: `[binary Blob ${data.size}B]`, truncated: false };
|
|
180
|
+
}
|
|
181
|
+
if (ArrayBuffer.isView(data)) {
|
|
182
|
+
const view = data as ArrayBufferView;
|
|
183
|
+
return { payload: `[binary ${view.constructor.name} ${view.byteLength}B]`, truncated: false };
|
|
184
|
+
}
|
|
185
|
+
return { payload: undefined, truncated: false };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function tryJson(s: string): unknown {
|
|
189
|
+
const trimmed = s.trim();
|
|
190
|
+
if (!trimmed) return undefined;
|
|
191
|
+
const first = trimmed[0];
|
|
192
|
+
if (first !== '{' && first !== '[' && first !== '"') return undefined;
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(trimmed);
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/xhrPatch.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { NetworkEntry } from '@harness-fe/protocol';
|
|
22
|
+
import { captureInitiator } from './initiator.js';
|
|
22
23
|
|
|
23
24
|
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
24
25
|
const PATCHED_FLAG = '__hfeXhrPatched';
|
|
@@ -113,6 +114,7 @@ export function installXhrPatch(opts: XhrPatchOptions): () => void {
|
|
|
113
114
|
}
|
|
114
115
|
meta.startedAt = performance.now();
|
|
115
116
|
meta.startedTs = Date.now();
|
|
117
|
+
const initiator = captureInitiator();
|
|
116
118
|
|
|
117
119
|
// Emit req eagerly with headers; body added on second emit after
|
|
118
120
|
// serialization (mirrors fetchPatch behavior).
|
|
@@ -123,6 +125,7 @@ export function installXhrPatch(opts: XhrPatchOptions): () => void {
|
|
|
123
125
|
method: meta.method,
|
|
124
126
|
url: meta.url,
|
|
125
127
|
requestHeaders: redactHeaders(meta.headers),
|
|
128
|
+
initiator,
|
|
126
129
|
};
|
|
127
130
|
emit(reqRecord);
|
|
128
131
|
meta.reqEmitted = true;
|