@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/dist/capture.d.ts CHANGED
@@ -1,8 +1,19 @@
1
1
  /**
2
- * Console + network + error capture. Monkey-patches in place once on init.
2
+ * CaptureStore thin adapter on top of `@harness-fe/sandbox`.
3
3
  *
4
- * Network capture covers fetch + XMLHttpRequest. Body capture is opt-in
5
- * per-request to keep memory bounded.
4
+ * Sandbox does the actual browser-API patching (fetch / xhr / ws / storage /
5
+ * navigation / console / errors / globals / indexeddb) and exposes a unified
6
+ * observer + interceptor surface. This module only:
7
+ * 1. Installs sandbox with the runtime's `onEvent` callback wired in.
8
+ * 2. Adapts each `SandboxEvent` into the harness-fe protocol shape
9
+ * (`NetworkEntry` / `WsEntry` / `StorageEntry` / `ConsoleEntry` /
10
+ * `ErrorEntry`) so the daemon's existing ingestion + tail tools keep
11
+ * working unchanged.
12
+ * 3. Mirrors events into bounded `RingBuffer`s so the runtime's
13
+ * `console.tail` / `network.tail` / `ws.tail` / `storage.tail` MCP tool
14
+ * handlers have something to read.
15
+ *
16
+ * Identity / interceptor / reentry safety all live in sandbox.
6
17
  */
7
18
  import { RingBuffer } from './buffer.js';
8
19
  export declare class CaptureStore {
@@ -63,20 +74,45 @@ export declare class CaptureStore {
63
74
  stack?: string | undefined;
64
75
  } | undefined;
65
76
  }>;
66
- private installed;
67
- private fetchDispose?;
68
- private xhrDispose?;
69
- private wsDispose?;
70
- private storageDispose?;
77
+ readonly navigation: RingBuffer<{
78
+ ts: number;
79
+ kind: "replace" | "push" | "pop" | "hash" | "assign";
80
+ url?: string | undefined;
81
+ state?: unknown;
82
+ replace?: boolean | undefined;
83
+ initiator?: {
84
+ stack?: string | undefined;
85
+ } | undefined;
86
+ }>;
87
+ readonly globals: RingBuffer<{
88
+ ts: number;
89
+ op: "set" | "get" | "delete";
90
+ key: string;
91
+ value?: unknown;
92
+ previousValue?: unknown;
93
+ initiator?: {
94
+ stack?: string | undefined;
95
+ } | undefined;
96
+ }>;
97
+ readonly indexeddb: RingBuffer<{
98
+ ts: number;
99
+ op: "open" | "clear" | "get" | "delete" | "put" | "add" | "getAll" | "cursor";
100
+ db?: string | undefined;
101
+ version?: number | undefined;
102
+ store?: string | undefined;
103
+ key?: unknown;
104
+ value?: unknown;
105
+ success?: boolean | undefined;
106
+ error?: string | undefined;
107
+ initiator?: {
108
+ stack?: string | undefined;
109
+ } | undefined;
110
+ }>;
111
+ private handle?;
71
112
  install(onEvent: (name: string, payload: unknown) => void, opts?: {
72
113
  daemonUrl?: string;
73
114
  }): void;
74
115
  dispose(): void;
75
- private installConsole;
76
- private installFetch;
77
- private installXhr;
78
- private installWs;
79
- private installStorage;
80
- private installErrors;
116
+ private adapt;
81
117
  }
82
118
  export declare function getCaptureStore(): CaptureStore;
package/dist/capture.js CHANGED
@@ -1,134 +1,107 @@
1
1
  /**
2
- * Console + network + error capture. Monkey-patches in place once on init.
2
+ * CaptureStore thin adapter on top of `@harness-fe/sandbox`.
3
3
  *
4
- * Network capture covers fetch + XMLHttpRequest. Body capture is opt-in
5
- * per-request to keep memory bounded.
4
+ * Sandbox does the actual browser-API patching (fetch / xhr / ws / storage /
5
+ * navigation / console / errors / globals / indexeddb) and exposes a unified
6
+ * observer + interceptor surface. This module only:
7
+ * 1. Installs sandbox with the runtime's `onEvent` callback wired in.
8
+ * 2. Adapts each `SandboxEvent` into the harness-fe protocol shape
9
+ * (`NetworkEntry` / `WsEntry` / `StorageEntry` / `ConsoleEntry` /
10
+ * `ErrorEntry`) so the daemon's existing ingestion + tail tools keep
11
+ * working unchanged.
12
+ * 3. Mirrors events into bounded `RingBuffer`s so the runtime's
13
+ * `console.tail` / `network.tail` / `ws.tail` / `storage.tail` MCP tool
14
+ * handlers have something to read.
15
+ *
16
+ * Identity / interceptor / reentry safety all live in sandbox.
6
17
  */
18
+ import { installSandbox, } from '@harness-fe/sandbox';
7
19
  import { RingBuffer } from './buffer.js';
8
- import { installFetchPatch } from './fetchPatch.js';
9
- import { installXhrPatch } from './xhrPatch.js';
10
- import { installWsPatch } from './wsPatch.js';
11
- import { installStoragePatch } from './storagePatch.js';
12
20
  const CONSOLE_CAP = 500;
13
21
  const NETWORK_CAP = 200;
14
22
  const ERROR_CAP = 200;
15
23
  const WS_CAP = 200;
16
24
  const STORAGE_CAP = 200;
25
+ const NAVIGATION_CAP = 100;
26
+ const GLOBALS_CAP = 200;
27
+ const INDEXEDDB_CAP = 200;
17
28
  export class CaptureStore {
18
29
  console = new RingBuffer(CONSOLE_CAP);
19
30
  network = new RingBuffer(NETWORK_CAP);
20
31
  errors = new RingBuffer(ERROR_CAP);
21
32
  ws = new RingBuffer(WS_CAP);
22
33
  storage = new RingBuffer(STORAGE_CAP);
23
- installed = false;
24
- fetchDispose;
25
- xhrDispose;
26
- wsDispose;
27
- storageDispose;
34
+ navigation = new RingBuffer(NAVIGATION_CAP);
35
+ globals = new RingBuffer(GLOBALS_CAP);
36
+ indexeddb = new RingBuffer(INDEXEDDB_CAP);
37
+ handle;
28
38
  install(onEvent, opts = {}) {
29
- if (this.installed)
39
+ if (this.handle)
30
40
  return;
31
- this.installed = true;
32
- this.installConsole(onEvent);
33
- this.installFetch(onEvent);
34
- this.installXhr(onEvent);
35
- this.installWs(onEvent, opts.daemonUrl);
36
- this.installStorage(onEvent);
37
- this.installErrors(onEvent);
41
+ const selfUrls = opts.daemonUrl ? [opts.daemonUrl] : undefined;
42
+ this.handle = installSandbox({
43
+ selfUrls,
44
+ onEvent: (e) => this.adapt(e, onEvent),
45
+ });
38
46
  }
39
47
  dispose() {
40
- this.fetchDispose?.();
41
- this.fetchDispose = undefined;
42
- this.xhrDispose?.();
43
- this.xhrDispose = undefined;
44
- this.wsDispose?.();
45
- this.wsDispose = undefined;
46
- this.storageDispose?.();
47
- this.storageDispose = undefined;
48
- this.installed = false;
48
+ this.handle?.dispose();
49
+ this.handle = undefined;
49
50
  }
50
- installConsole(onEvent) {
51
- const methods = ['log', 'info', 'warn', 'error', 'debug'];
52
- for (const level of methods) {
53
- const original = console[level].bind(console);
54
- console[level] = (...args) => {
55
- const entry = {
56
- ts: Date.now(),
57
- level,
58
- args: args.map(safeClone),
59
- };
60
- this.console.push(entry);
61
- onEvent('console', entry);
62
- original(...args);
63
- };
64
- }
65
- }
66
- installFetch(onEvent) {
67
- this.fetchDispose = installFetchPatch({
68
- onEntry: (entry) => {
51
+ adapt(e, onEvent) {
52
+ switch (e.source) {
53
+ case 'fetch':
54
+ case 'xhr': {
55
+ const entry = adaptFetchLike(e);
69
56
  this.network.push(entry);
70
57
  onEvent('network', entry);
71
- },
72
- });
73
- }
74
- installXhr(onEvent) {
75
- this.xhrDispose = installXhrPatch({
76
- onEntry: (entry) => {
77
- this.network.push(entry);
78
- onEvent('network', entry);
79
- },
80
- });
81
- }
82
- installWs(onEvent, daemonUrl) {
83
- // Add the daemon URL itself to the denylist so our own bridge
84
- // connection isn't intercepted (otherwise every event we send
85
- // would emit a `ws send` that loops back into the outbox).
86
- const extra = [];
87
- if (daemonUrl) {
88
- const escaped = daemonUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
89
- extra.push(new RegExp(`^${escaped}`));
90
- }
91
- this.wsDispose = installWsPatch({
92
- denylist: extra.length > 0 ? [/\/__hfe\//, /sockjs-node/, ...extra] : undefined,
93
- onEntry: (entry) => {
58
+ return;
59
+ }
60
+ case 'ws': {
61
+ const entry = adaptWs(e);
94
62
  this.ws.push(entry);
95
63
  onEvent('ws', entry);
96
- },
97
- });
98
- }
99
- installStorage(onEvent) {
100
- this.storageDispose = installStoragePatch({
101
- onEntry: (entry) => {
102
- this.storage.push(entry);
103
- onEvent('storage', entry);
104
- },
105
- });
106
- }
107
- installErrors(onEvent) {
108
- if (typeof window === 'undefined')
109
- return;
110
- window.addEventListener('error', (e) => {
111
- const entry = {
112
- ts: Date.now(),
113
- message: e.message,
114
- stack: e.error?.stack,
115
- source: e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : undefined,
116
- };
117
- this.errors.push(entry);
118
- onEvent('error', entry);
119
- });
120
- window.addEventListener('unhandledrejection', (e) => {
121
- const reason = e.reason;
122
- const message = reason instanceof Error ? reason.message : String(reason ?? 'unhandled rejection');
123
- const stack = reason instanceof Error ? reason.stack : undefined;
124
- const entry = {
125
- ts: Date.now(),
126
- message: `Unhandled: ${message}`,
127
- stack,
128
- };
129
- this.errors.push(entry);
130
- onEvent('error', entry);
131
- });
64
+ return;
65
+ }
66
+ case 'storage': {
67
+ const entry = adaptStorage(e);
68
+ if (entry) {
69
+ this.storage.push(entry);
70
+ onEvent('storage', entry);
71
+ }
72
+ return;
73
+ }
74
+ case 'console': {
75
+ const entry = adaptConsole(e);
76
+ this.console.push(entry);
77
+ onEvent('console', entry);
78
+ return;
79
+ }
80
+ case 'errors': {
81
+ const entry = adaptError(e);
82
+ this.errors.push(entry);
83
+ onEvent('error', entry);
84
+ return;
85
+ }
86
+ case 'navigation': {
87
+ const entry = adaptNavigation(e);
88
+ this.navigation.push(entry);
89
+ onEvent('navigation', entry);
90
+ return;
91
+ }
92
+ case 'globals': {
93
+ const entry = adaptGlobals(e);
94
+ this.globals.push(entry);
95
+ onEvent('globals', entry);
96
+ return;
97
+ }
98
+ case 'indexeddb': {
99
+ const entry = adaptIndexedDb(e);
100
+ this.indexeddb.push(entry);
101
+ onEvent('indexeddb', entry);
102
+ return;
103
+ }
104
+ }
132
105
  }
133
106
  }
134
107
  let captureStoreSingleton;
@@ -136,16 +109,123 @@ export function getCaptureStore() {
136
109
  captureStoreSingleton ??= new CaptureStore();
137
110
  return captureStoreSingleton;
138
111
  }
139
- function safeClone(value) {
140
- if (value === null)
141
- return null;
142
- if (typeof value === 'object') {
143
- try {
144
- return JSON.parse(JSON.stringify(value));
145
- }
146
- catch {
147
- return String(value);
148
- }
112
+ // ────────────────────────────────────────────────────────────────────
113
+ // SandboxEvent harness-fe protocol entry adapters
114
+ // ────────────────────────────────────────────────────────────────────
115
+ function adaptFetchLike(e) {
116
+ const d = e.data;
117
+ if (e.kind === 'req') {
118
+ const r = d;
119
+ return {
120
+ ts: e.ts,
121
+ id: r.id,
122
+ phase: 'req',
123
+ method: r.method,
124
+ url: r.url,
125
+ requestHeaders: r.headers,
126
+ requestBody: r.body,
127
+ requestBodyTruncated: r.bodyTruncated || undefined,
128
+ initiator: e.initiator,
129
+ };
149
130
  }
150
- return value;
131
+ const r = d;
132
+ return {
133
+ ts: e.ts,
134
+ id: r.id,
135
+ phase: 'res',
136
+ method: r.method,
137
+ url: r.url,
138
+ status: r.status,
139
+ durationMs: r.durationMs,
140
+ responseHeaders: r.headers,
141
+ responseBody: r.body,
142
+ responseBodyTruncated: r.bodyTruncated || undefined,
143
+ error: r.error,
144
+ initiator: e.initiator,
145
+ };
146
+ }
147
+ function adaptWs(e) {
148
+ const d = e.data;
149
+ return {
150
+ ts: e.ts,
151
+ id: d.id,
152
+ phase: d.phase,
153
+ url: d.url,
154
+ protocols: d.protocols,
155
+ payload: d.payload,
156
+ payloadTruncated: d.payloadTruncated,
157
+ code: d.code,
158
+ reason: d.reason,
159
+ wasClean: d.wasClean,
160
+ initiator: e.initiator,
161
+ };
162
+ }
163
+ function adaptStorage(e) {
164
+ const d = e.data;
165
+ // Sandbox emits a 'get' op; harness-fe protocol storage only models set/remove/clear.
166
+ if (d.op === 'get')
167
+ return null;
168
+ return {
169
+ ts: e.ts,
170
+ op: d.op,
171
+ which: d.which,
172
+ key: d.key,
173
+ value: d.value,
174
+ crossTab: d.crossTab,
175
+ initiator: e.initiator,
176
+ };
177
+ }
178
+ function adaptConsole(e) {
179
+ const d = e.data;
180
+ return {
181
+ ts: e.ts,
182
+ level: d.level,
183
+ args: d.args,
184
+ };
185
+ }
186
+ function adaptError(e) {
187
+ const d = e.data;
188
+ return {
189
+ ts: e.ts,
190
+ message: d.message,
191
+ stack: d.stack,
192
+ source: d.source,
193
+ };
194
+ }
195
+ function adaptNavigation(e) {
196
+ const d = e.data;
197
+ return {
198
+ ts: e.ts,
199
+ kind: d.kind,
200
+ url: d.url,
201
+ state: d.state,
202
+ replace: d.replace,
203
+ initiator: e.initiator,
204
+ };
205
+ }
206
+ function adaptGlobals(e) {
207
+ const d = e.data;
208
+ return {
209
+ ts: e.ts,
210
+ op: d.op,
211
+ key: d.key,
212
+ value: d.value,
213
+ previousValue: d.previousValue,
214
+ initiator: e.initiator,
215
+ };
216
+ }
217
+ function adaptIndexedDb(e) {
218
+ const d = e.data;
219
+ return {
220
+ ts: e.ts,
221
+ op: d.op,
222
+ db: d.db,
223
+ version: d.version,
224
+ store: d.store,
225
+ key: d.key,
226
+ value: d.value,
227
+ success: d.success,
228
+ error: d.error,
229
+ initiator: e.initiator,
230
+ };
151
231
  }
package/dist/client.d.ts CHANGED
@@ -28,6 +28,13 @@ export interface ClientOptions {
28
28
  * by visitorId). Propagated by HarnessScript via window.__HARNESS_FE__.userId.
29
29
  */
30
30
  userId?: string;
31
+ /**
32
+ * How often (in ms) rrweb should emit a fresh FullSnapshot baseline.
33
+ * Defaults to 30 minutes. Set to 0 to disable periodic baselines (the
34
+ * recorder still emits one at start() and one per ws reconnect).
35
+ * See {@link RrwebRecorderOptions.checkoutEveryNms} for the trade-off.
36
+ */
37
+ rrwebCheckoutEveryNms?: number;
31
38
  }
32
39
  export { tryInheritFromParent } from './parent-inherit.js';
33
40
  export type { ParentInheritance } from './parent-inherit.js';
package/dist/client.js CHANGED
@@ -90,7 +90,10 @@ export class RuntimeClient {
90
90
  }
91
91
  pageLoadSent = false;
92
92
  ctx = { capture: getCaptureStore() };
93
- recorder = new RrwebRecorder((chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk));
93
+ // Initialized in constructor (parameter property `opts` isn't readable at
94
+ // class-field-initializer time — field initializers run before parameter
95
+ // property assignment).
96
+ recorder;
94
97
  reconnectAttempts = 0;
95
98
  closed = false;
96
99
  static MAX_OUTBOX_FRAMES = 500;
@@ -109,6 +112,7 @@ export class RuntimeClient {
109
112
  const inheritedVisitor = tryInheritVisitorFromParent();
110
113
  this.visitorId = inheritedVisitor ?? getOrCreateVisitorId();
111
114
  publishVisitorIdToWindow(this.visitorId);
115
+ this.recorder = new RrwebRecorder((chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk), { checkoutEveryNms: opts.rrwebCheckoutEveryNms });
112
116
  }
113
117
  start() {
114
118
  const daemonUrl = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
package/dist/commands.js CHANGED
@@ -382,6 +382,39 @@ export const commandHandlers = {
382
382
  return JSON.stringify({ op: e.op, which: e.which, key: e.key, value: e.value });
383
383
  }) };
384
384
  },
385
+ [COMMAND.NAVIGATION_TAIL]: async (raw, ctx) => {
386
+ const args = raw;
387
+ const all = ctx.capture.navigation.tail(args.n ?? 20);
388
+ return { entries: filterTail(all, args, (e) => {
389
+ if (args.kind && e.kind !== args.kind)
390
+ return undefined;
391
+ return JSON.stringify({ kind: e.kind, url: e.url, replace: e.replace });
392
+ }) };
393
+ },
394
+ [COMMAND.GLOBALS_TAIL]: async (raw, ctx) => {
395
+ const args = raw;
396
+ const all = ctx.capture.globals.tail(args.n ?? 20);
397
+ return { entries: filterTail(all, args, (e) => {
398
+ if (args.op && e.op !== args.op)
399
+ return undefined;
400
+ if (args.key && e.key !== args.key)
401
+ return undefined;
402
+ return JSON.stringify({ op: e.op, key: e.key, value: e.value });
403
+ }) };
404
+ },
405
+ [COMMAND.INDEXEDDB_TAIL]: async (raw, ctx) => {
406
+ const args = raw;
407
+ const all = ctx.capture.indexeddb.tail(args.n ?? 20);
408
+ return { entries: filterTail(all, args, (e) => {
409
+ if (args.op && e.op !== args.op)
410
+ return undefined;
411
+ if (args.store && e.store !== args.store)
412
+ return undefined;
413
+ if (args.db && e.db !== args.db)
414
+ return undefined;
415
+ return JSON.stringify({ op: e.op, store: e.store, key: e.key });
416
+ }) };
417
+ },
385
418
  };
386
419
  /**
387
420
  * Apply caller-supplied filtering to a tail() result. `pickHaystack` returns
@@ -1,12 +1,27 @@
1
1
  import type { RrwebChunkPayload } from '@harness-fe/protocol';
2
2
  export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
3
+ export interface RrwebRecorderOptions {
4
+ /**
5
+ * Force rrweb to emit a fresh FullSnapshot every N milliseconds. Caps how
6
+ * stale the most recent baseline can be, so window replays mid-session
7
+ * don't have to roll forward from a baseline that's potentially hours old.
8
+ *
9
+ * Set to `0` (or a negative number) to disable periodic baselines and
10
+ * rely solely on the start() baseline + reconnect baselines. Useful for
11
+ * extremely bandwidth-constrained deployments.
12
+ *
13
+ * @default 30 * 60 * 1000 (30 minutes)
14
+ */
15
+ checkoutEveryNms?: number;
16
+ }
3
17
  export declare class RrwebRecorder {
4
18
  private readonly onChunk;
19
+ private readonly opts;
5
20
  private stopRecording?;
6
21
  private flushTimer?;
7
22
  private chunkSeq;
8
23
  private buffer;
9
- constructor(onChunk: (chunk: RrwebChunkPayload) => void);
24
+ constructor(onChunk: (chunk: RrwebChunkPayload) => void, opts?: RrwebRecorderOptions);
10
25
  start(): void;
11
26
  stop(): void;
12
27
  /**
package/dist/recording.js CHANGED
@@ -2,24 +2,38 @@ import { record } from 'rrweb';
2
2
  export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
3
3
  const FLUSH_MS = 5_000;
4
4
  const MAX_EVENTS = 200;
5
+ // Default periodic-baseline cadence. Long-running sessions otherwise rely on
6
+ // a single FullSnapshot at start() + one per ws reconnect, which makes
7
+ // mid-session window replays expensive (rrweb has to roll forward all
8
+ // incremental events back to the original baseline) and leaves a window of
9
+ // vulnerability if the original baseline is ever evicted from the outbox.
10
+ // 30 min is a deliberate middle ground: ~16 baselines per 8h session at
11
+ // ~500KB each ≈ 8MB extra storage, which is acceptable for a dev tool.
12
+ const DEFAULT_CHECKOUT_EVERY_MS = 30 * 60 * 1000;
5
13
  export class RrwebRecorder {
6
14
  onChunk;
15
+ opts;
7
16
  stopRecording;
8
17
  flushTimer;
9
18
  chunkSeq = 0;
10
19
  buffer = [];
11
- constructor(onChunk) {
20
+ constructor(onChunk, opts = {}) {
12
21
  this.onChunk = onChunk;
22
+ this.opts = opts;
13
23
  }
14
24
  start() {
15
25
  if (this.stopRecording)
16
26
  return;
27
+ const checkoutEveryNms = this.opts.checkoutEveryNms ?? DEFAULT_CHECKOUT_EVERY_MS;
28
+ // rrweb interprets `checkoutEveryNms` falsy / undefined as "off".
29
+ // Pass undefined when disabled so we get the native off-path.
17
30
  this.stopRecording = record({
18
31
  emit: (event) => this.push(event),
19
32
  inlineImages: false,
20
33
  recordCanvas: false,
21
34
  collectFonts: false,
22
35
  maskAllInputs: false,
36
+ checkoutEveryNms: checkoutEveryNms > 0 ? checkoutEveryNms : undefined,
23
37
  });
24
38
  this.flushTimer = window.setInterval(() => this.flush(), FLUSH_MS);
25
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-fe/runtime",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Browser-side SDK injected into the dev page. Connects to the MCP server via WebSocket and executes commands.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  "dependencies": {
31
31
  "@zumer/snapdom": "^2.12.0",
32
32
  "rrweb": "2.0.0-alpha.4",
33
- "@harness-fe/protocol": "3.1.0"
33
+ "@harness-fe/protocol": "3.2.0",
34
+ "@harness-fe/sandbox": "^3.2.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "happy-dom": "^20.9.0",