@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,314 +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
- import { captureInitiator } from './initiator.js';
26
- const DEFAULT_BODY_CAP = 256 * 1024;
27
- const INTERNAL_FLAG = '__hfeInternal';
28
- const PATCHED_FLAG = '__hfePatched';
29
- const DEFAULT_DENYLIST = [/\/__hfe\//, /sockjs-node/, /\.hot-update\./];
30
- const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
31
- /**
32
- * Install the fetch patch. Returns a dispose function that restores the
33
- * original window.fetch. Safe to call multiple times (subsequent calls
34
- * are no-ops while a patch is active).
35
- */
36
- export function installFetchPatch(opts) {
37
- if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
38
- return () => { };
39
- }
40
- const original = window.fetch;
41
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
- if (original[PATCHED_FLAG])
43
- return () => { };
44
- const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
45
- const denylist = opts.denylist ?? DEFAULT_DENYLIST;
46
- const emit = (entry) => safeEmit(opts.onEntry, entry);
47
- // Named function so .name === 'fetch'.
48
- const patched = function fetch(input, init) {
49
- // Self-traffic short-circuit — internal requests bypass capture.
50
- if (init && init[INTERNAL_FLAG]) {
51
- return original.call(this, input, init);
52
- }
53
- const meta = extractRequestMeta(input, init);
54
- if (denylist.some((re) => re.test(meta.url))) {
55
- return original.call(this, input, init);
56
- }
57
- const id = generateId();
58
- const startedAt = performance.now();
59
- const startedTs = Date.now();
60
- const initiator = captureInitiator();
61
- // Emit request record eagerly (req body is read async — second emit
62
- // updates the record once body is serialized; consumers join by id).
63
- const reqRecord = {
64
- ts: startedTs,
65
- id,
66
- phase: 'req',
67
- method: meta.method,
68
- url: meta.url,
69
- requestHeaders: meta.headers,
70
- initiator,
71
- };
72
- emit(reqRecord);
73
- cloneRequestBody(input, init, bodyCap).then(({ body, truncated }) => {
74
- if (body === undefined && !truncated)
75
- return;
76
- emit({
77
- ...reqRecord,
78
- requestBody: body,
79
- requestBodyTruncated: truncated || undefined,
80
- });
81
- }, () => {
82
- /* serialization error — ignore, req already emitted */
83
- });
84
- const promise = original.call(this, input, init);
85
- promise.then((response) => {
86
- let cloned;
87
- try {
88
- cloned = response.clone();
89
- }
90
- catch {
91
- emit({
92
- ts: Date.now(),
93
- id,
94
- phase: 'res',
95
- method: meta.method,
96
- url: meta.url,
97
- status: response.status,
98
- responseHeaders: headersToObject(response.headers),
99
- durationMs: performance.now() - startedAt,
100
- });
101
- return;
102
- }
103
- const finalize = (body, truncated) => {
104
- emit({
105
- ts: Date.now(),
106
- id,
107
- phase: 'res',
108
- method: meta.method,
109
- url: meta.url,
110
- status: response.status,
111
- responseHeaders: headersToObject(response.headers),
112
- responseBody: body,
113
- responseBodyTruncated: truncated || undefined,
114
- durationMs: performance.now() - startedAt,
115
- });
116
- };
117
- const ct = response.headers.get('content-type') ?? '';
118
- if (isSSE(ct)) {
119
- pumpSSE(cloned, bodyCap, finalize);
120
- }
121
- else if (isTextLike(ct)) {
122
- readTextWithCap(cloned, bodyCap).then(({ body, truncated }) => finalize(maybeParseJson(body, ct), truncated), () => finalize(undefined, false));
123
- }
124
- else {
125
- // Binary or unknown: only record size, do not pull bytes.
126
- cloned.arrayBuffer().then((buf) => finalize(`[binary ${buf.byteLength}B]`, false), () => finalize(undefined, false));
127
- }
128
- }, (err) => {
129
- emit({
130
- ts: Date.now(),
131
- id,
132
- phase: 'res',
133
- method: meta.method,
134
- url: meta.url,
135
- durationMs: performance.now() - startedAt,
136
- error: err instanceof Error ? err.message : String(err),
137
- });
138
- });
139
- return promise;
140
- };
141
- // Preserve fingerprint — library detection commonly inspects these.
142
- try {
143
- Object.defineProperty(patched, 'name', { value: 'fetch' });
144
- Object.defineProperty(patched, 'length', { value: original.length });
145
- Object.defineProperty(patched, 'toString', {
146
- value: () => original.toString(),
147
- });
148
- }
149
- catch {
150
- /* sealed property — safe to skip */
151
- }
152
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
- patched[PATCHED_FLAG] = true;
154
- window.fetch = patched;
155
- return () => {
156
- if (window.fetch === patched) {
157
- window.fetch = original;
158
- }
159
- };
160
- }
161
- // ─── helpers ────────────────────────────────────────────────────────────────
162
- function generateId() {
163
- try {
164
- return crypto.randomUUID();
165
- }
166
- catch {
167
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
168
- }
169
- }
170
- function extractRequestMeta(input, init) {
171
- let url;
172
- let method = 'GET';
173
- let headers;
174
- if (typeof input === 'string') {
175
- url = input;
176
- }
177
- else if (input instanceof URL) {
178
- url = input.toString();
179
- }
180
- else {
181
- url = input.url;
182
- method = input.method;
183
- headers = headersToObject(input.headers);
184
- }
185
- if (init?.method)
186
- method = init.method;
187
- if (init?.headers) {
188
- headers = { ...(headers ?? {}), ...(headersToObject(init.headers) ?? {}) };
189
- }
190
- return { url, method, headers };
191
- }
192
- function headersToObject(h) {
193
- if (!h)
194
- return undefined;
195
- const out = {};
196
- if (h instanceof Headers) {
197
- h.forEach((v, k) => {
198
- out[k] = v;
199
- });
200
- }
201
- else if (Array.isArray(h)) {
202
- for (const [k, v] of h)
203
- out[k] = v;
204
- }
205
- else {
206
- Object.assign(out, h);
207
- }
208
- return redactHeaders(out);
209
- }
210
- function redactHeaders(h) {
211
- const out = {};
212
- for (const [k, v] of Object.entries(h)) {
213
- out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
214
- }
215
- return out;
216
- }
217
- async function cloneRequestBody(input, init, cap) {
218
- if (init?.body !== undefined && init.body !== null) {
219
- return serializeBodyInit(init.body, cap);
220
- }
221
- if (input instanceof Request && input.body) {
222
- try {
223
- const text = await input.clone().text();
224
- return capText(text, cap);
225
- }
226
- catch {
227
- return { truncated: false };
228
- }
229
- }
230
- return { truncated: false };
231
- }
232
- function serializeBodyInit(body, cap) {
233
- if (typeof body === 'string')
234
- return capText(body, cap);
235
- if (typeof FormData !== 'undefined' && body instanceof FormData) {
236
- const obj = {};
237
- body.forEach((v, k) => {
238
- obj[k] = typeof v === 'string'
239
- ? v
240
- : `[File ${v.name} ${v.size}B]`;
241
- });
242
- return { body: obj, truncated: false };
243
- }
244
- if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
245
- return { body: body.toString(), truncated: false };
246
- }
247
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
248
- return { body: `[Blob ${body.size}B]`, truncated: false };
249
- }
250
- if (body instanceof ArrayBuffer) {
251
- return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
252
- }
253
- if (ArrayBuffer.isView(body)) {
254
- return { body: `[${body.constructor.name} ${body.byteLength}B]`, truncated: false };
255
- }
256
- return { body: '[unknown body]', truncated: false };
257
- }
258
- function capText(text, cap) {
259
- if (text.length > cap)
260
- return { body: text.slice(0, cap), truncated: true };
261
- return { body: text, truncated: false };
262
- }
263
- async function readTextWithCap(res, cap) {
264
- const text = await res.text();
265
- return capText(text, cap);
266
- }
267
- function maybeParseJson(text, contentType) {
268
- if (!/json/i.test(contentType))
269
- return text;
270
- try {
271
- return JSON.parse(text);
272
- }
273
- catch {
274
- return text;
275
- }
276
- }
277
- function isSSE(ct) {
278
- return /text\/event-stream/i.test(ct);
279
- }
280
- function isTextLike(ct) {
281
- return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
282
- }
283
- function pumpSSE(res, cap, done) {
284
- if (!res.body || typeof res.body.getReader !== 'function') {
285
- return done('', false);
286
- }
287
- const reader = res.body.getReader();
288
- const dec = new TextDecoder();
289
- let total = '';
290
- let truncated = false;
291
- const step = () => {
292
- reader.read().then(({ done: end, value }) => {
293
- if (end)
294
- return done(total, truncated);
295
- total += dec.decode(value, { stream: true });
296
- if (total.length > cap) {
297
- total = total.slice(0, cap);
298
- truncated = true;
299
- reader.cancel().catch(() => { });
300
- return done(total, truncated);
301
- }
302
- step();
303
- }, () => done(total, truncated));
304
- };
305
- step();
306
- }
307
- function safeEmit(fn, entry) {
308
- try {
309
- fn(entry);
310
- }
311
- catch {
312
- /* swallow — capture must not break business */
313
- }
314
- }
@@ -1,20 +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
- export interface Initiator {
18
- stack?: string;
19
- }
20
- export declare function captureInitiator(): Initiator;
package/dist/initiator.js DELETED
@@ -1,34 +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
- const FRAMES_TO_TRIM = 2;
18
- export function captureInitiator() {
19
- const err = new Error();
20
- const raw = err.stack;
21
- if (!raw)
22
- return {};
23
- const lines = raw.split('\n');
24
- if (lines.length <= FRAMES_TO_TRIM + 1)
25
- return { stack: raw };
26
- // Preserve the "Error" header line + caller frames. Drop the frames
27
- // representing the helper and the patched wrapper.
28
- const header = lines[0].startsWith('Error') ? lines[0] : '';
29
- const callerFrames = lines.slice(FRAMES_TO_TRIM + 1);
30
- const trimmed = header
31
- ? [header, ...callerFrames].join('\n')
32
- : callerFrames.join('\n');
33
- return { stack: trimmed };
34
- }
@@ -1,23 +0,0 @@
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
- import type { StorageEntry } from '@harness-fe/protocol';
18
- export interface StoragePatchOptions {
19
- onEntry: (entry: StorageEntry) => void;
20
- /** Per-value byte cap. Default 4 KB — captures small tokens, drops giant blobs. */
21
- valueCap?: number;
22
- }
23
- export declare function installStoragePatch(opts: StoragePatchOptions): () => void;
@@ -1,190 +0,0 @@
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
- import { captureInitiator } from './initiator.js';
18
- const PATCHED_FLAG = '__hfeStoragePatched';
19
- const DEFAULT_VALUE_CAP = 4 * 1024;
20
- export function installStoragePatch(opts) {
21
- if (typeof window === 'undefined')
22
- return () => { };
23
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
- if (window[PATCHED_FLAG])
25
- return () => { };
26
- const valueCap = opts.valueCap ?? DEFAULT_VALUE_CAP;
27
- const emit = (entry) => {
28
- try {
29
- opts.onEntry(entry);
30
- }
31
- catch {
32
- /* swallow */
33
- }
34
- };
35
- const disposers = [];
36
- // Patch each storage instance directly — own properties shadow the
37
- // prototype regardless of how the engine implements them. This works
38
- // uniformly across real browsers, happy-dom, jsdom, and Electron.
39
- try {
40
- disposers.push(patchStorageInstance(window.localStorage, 'local', valueCap, emit));
41
- }
42
- catch { /* localStorage may be inaccessible (private mode, etc.) */ }
43
- try {
44
- disposers.push(patchStorageInstance(window.sessionStorage, 'session', valueCap, emit));
45
- }
46
- catch { /* ignore */ }
47
- if (typeof document !== 'undefined') {
48
- const cookieDispose = patchCookie(valueCap, emit);
49
- if (cookieDispose)
50
- disposers.push(cookieDispose);
51
- }
52
- // Cross-tab storage events.
53
- const onStorageEvent = (ev) => {
54
- const which = ev.storageArea === window.sessionStorage ? 'session' : 'local';
55
- const op = ev.key === null ? 'clear' : ev.newValue === null ? 'remove' : 'set';
56
- emit({
57
- ts: Date.now(),
58
- op,
59
- which,
60
- key: ev.key ?? undefined,
61
- value: ev.newValue !== null ? clip(ev.newValue, valueCap) : undefined,
62
- crossTab: true,
63
- });
64
- };
65
- window.addEventListener('storage', onStorageEvent);
66
- disposers.push(() => window.removeEventListener('storage', onStorageEvent));
67
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
- window[PATCHED_FLAG] = true;
69
- return () => {
70
- for (const d of disposers) {
71
- try {
72
- d();
73
- }
74
- catch {
75
- /* ignore */
76
- }
77
- }
78
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
- delete window[PATCHED_FLAG];
80
- };
81
- }
82
- /**
83
- * Patch Storage.prototype.setItem / removeItem / clear. Both localStorage and
84
- * sessionStorage share the same prototype, so one patch covers both — we
85
- * disambiguate at call time via `this === window.sessionStorage`.
86
- */
87
- function patchStorageInstance(storage, kind, valueCap, emit) {
88
- const origSet = storage.setItem.bind(storage);
89
- const origRemove = storage.removeItem.bind(storage);
90
- const origClear = storage.clear.bind(storage);
91
- Object.defineProperty(storage, 'setItem', {
92
- configurable: true, writable: true,
93
- value: (key, value) => {
94
- emit({ ts: Date.now(), op: 'set', which: kind, key, value: clip(value, valueCap), initiator: captureInitiator() });
95
- origSet(key, value);
96
- },
97
- });
98
- Object.defineProperty(storage, 'removeItem', {
99
- configurable: true, writable: true,
100
- value: (key) => {
101
- emit({ ts: Date.now(), op: 'remove', which: kind, key, initiator: captureInitiator() });
102
- origRemove(key);
103
- },
104
- });
105
- Object.defineProperty(storage, 'clear', {
106
- configurable: true, writable: true,
107
- value: () => {
108
- emit({ ts: Date.now(), op: 'clear', which: kind, initiator: captureInitiator() });
109
- origClear();
110
- },
111
- });
112
- return () => {
113
- // Restore by replacing the own properties with thin shims that
114
- // forward to the captured originals. `delete` doesn't reliably
115
- // expose the prototype method in every engine (happy-dom in
116
- // particular), so this is the safer reset.
117
- try {
118
- Object.defineProperty(storage, 'setItem', {
119
- configurable: true, writable: true,
120
- value: (k, v) => origSet(k, v),
121
- });
122
- Object.defineProperty(storage, 'removeItem', {
123
- configurable: true, writable: true,
124
- value: (k) => origRemove(k),
125
- });
126
- Object.defineProperty(storage, 'clear', {
127
- configurable: true, writable: true,
128
- value: () => origClear(),
129
- });
130
- }
131
- catch { /* ignore */ }
132
- };
133
- }
134
- function patchCookie(valueCap, emit) {
135
- const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
136
- if (!descriptor || !descriptor.set || !descriptor.get)
137
- return undefined;
138
- const origSet = descriptor.set;
139
- const origGet = descriptor.get;
140
- Object.defineProperty(Document.prototype, 'cookie', {
141
- configurable: true,
142
- get() {
143
- return origGet.call(this);
144
- },
145
- set(val) {
146
- const initiator = captureInitiator();
147
- const { key, value, removed } = parseCookieAssignment(val);
148
- emit({
149
- ts: Date.now(),
150
- op: removed ? 'remove' : 'set',
151
- which: 'cookie',
152
- key,
153
- value: removed ? undefined : value !== undefined ? clip(value, valueCap) : undefined,
154
- initiator,
155
- });
156
- return origSet.call(this, val);
157
- },
158
- });
159
- return () => {
160
- Object.defineProperty(Document.prototype, 'cookie', descriptor);
161
- };
162
- }
163
- /**
164
- * Cookie writes look like "key=value; Path=/; Expires=...; Max-Age=0".
165
- * Treat Max-Age=0 or any past Expires as removal. Anything else is set.
166
- */
167
- function parseCookieAssignment(raw) {
168
- const parts = raw.split(';');
169
- const head = (parts[0] ?? '').trim();
170
- const eq = head.indexOf('=');
171
- const key = eq >= 0 ? head.slice(0, eq) : head;
172
- const value = eq >= 0 ? head.slice(eq + 1) : undefined;
173
- let removed = false;
174
- for (let i = 1; i < parts.length; i++) {
175
- const seg = parts[i].trim();
176
- const lower = seg.toLowerCase();
177
- if (lower === 'max-age=0' || lower === 'max-age=-1')
178
- removed = true;
179
- if (lower.startsWith('expires=')) {
180
- const date = new Date(seg.slice('expires='.length).trim());
181
- if (!Number.isNaN(date.getTime()) && date.getTime() <= Date.now()) {
182
- removed = true;
183
- }
184
- }
185
- }
186
- return { key: key || undefined, value, removed };
187
- }
188
- function clip(s, cap) {
189
- return s.length <= cap ? s : `${s.slice(0, cap)}…[+${s.length - cap}B]`;
190
- }
package/dist/wsPatch.d.ts DELETED
@@ -1,26 +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 type { WsEntry } from '@harness-fe/protocol';
20
- export interface WsPatchOptions {
21
- onEntry: (entry: WsEntry) => void;
22
- bodyCap?: number;
23
- /** URL patterns to skip entirely. Default skips daemon traffic. */
24
- denylist?: RegExp[];
25
- }
26
- export declare function installWsPatch(opts: WsPatchOptions): () => void;