@harness-fe/mcp-server 3.0.1

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
package/src/bridge.ts ADDED
@@ -0,0 +1,1804 @@
1
+ /**
2
+ * WS bridge — accepts connections from vite-plugin and runtime-client.
3
+ *
4
+ * Protocol: see @harness-fe/protocol.
5
+ *
6
+ * Responsibilities:
7
+ * - Handshake: `hello` frame → register peer in SessionRouter, reply `hello.ack`
8
+ * - sendCommand(): forward a CommandFrame to the target tab, return a
9
+ * Promise that resolves when the matching ResponseFrame arrives
10
+ * - onEvent(): broadcast event frames to subscribers (mcp tools / future
11
+ * recorder)
12
+ */
13
+
14
+ import { WebSocket, WebSocketServer } from 'ws';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
17
+ import { networkInterfaces } from 'node:os';
18
+ import {
19
+ DEFAULT_LOGIN_PATH,
20
+ handleLoginPost,
21
+ isAuthorized,
22
+ sendUnauthorized,
23
+ type AuthOptions,
24
+ } from './auth.js';
25
+ import { join as joinPath } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
28
+ import {
29
+ DEFAULT_WS_PORT,
30
+ EVENT_NAME,
31
+ PROTOCOL_VERSION,
32
+ isLoopbackHost,
33
+ pageLoadPayloadSchema,
34
+ rrwebChunkPayloadSchema,
35
+ taskSubmitPayloadSchema,
36
+ type CommandFrame,
37
+ type EventFrame,
38
+ type Frame,
39
+ type HelloAckFrame,
40
+ type HttpBatch,
41
+ type McpCallFrame,
42
+ type McpReturnFrame,
43
+ type QueryFrame,
44
+ type QueryResponseFrame,
45
+ type TabInfo,
46
+ type Task,
47
+ type TaskAttachment,
48
+ type TaskStatus,
49
+ frameSchema,
50
+ } from '@harness-fe/protocol';
51
+ import { SessionRouter, type PeerSession } from './sessionRouter.js';
52
+ import { createReplayHandler } from './replayViewer.js';
53
+ import { createDashboardApiHandler } from './dashboardApi.js';
54
+ import { createDashboardSpaHandler } from './dashboardSpa.js';
55
+ import { createEventsHandler } from './eventsHandler.js';
56
+ import {
57
+ JsonlStore,
58
+ JsonTaskStore,
59
+ JsonMemoryStore,
60
+ sanitizeId as sanitizeStoreId,
61
+ type IStore,
62
+ type ITaskStore,
63
+ type IMemoryStore,
64
+ type RetentionPolicy,
65
+ } from './store/index.js';
66
+
67
+ /**
68
+ * Surface used by the stdio MCP layer. Same shape whether the underlying
69
+ * implementation is an in-process `Bridge` (leader) or a `RemoteBridge`
70
+ * proxying over WS to another daemon (follower).
71
+ *
72
+ * All methods are async so the same call site works in both modes.
73
+ */
74
+ export interface IBridge {
75
+ sendCommand(
76
+ command: string,
77
+ args: unknown,
78
+ opts?: SendCommandOptions,
79
+ ): Promise<unknown>;
80
+ listTabs(): Promise<TabInfo[]>;
81
+ listTasks(filter?: { status?: TaskStatus | 'all'; limit?: number }): Promise<Task[]>;
82
+ claimTask(id: string): Promise<Task | undefined>;
83
+ resolveTask(id: string, note?: string): Promise<Task | undefined>;
84
+ getMemoryStore(): IMemoryStore;
85
+ /**
86
+ * Base URL (e.g. http://127.0.0.1:47729) where the replay viewer is reachable.
87
+ * Returns undefined when the bridge does not serve HTTP (e.g. follower mode).
88
+ */
89
+ getViewerBaseUrl(): string | undefined;
90
+ /**
91
+ * The configured auth token, or undefined if auth is disabled. Used to
92
+ * compose URLs that the user (or agent) can hit without an extra
93
+ * authentication step.
94
+ */
95
+ getAuthToken(): string | undefined;
96
+ /**
97
+ * Read an attachment PNG for a task. Returns base64-encoded PNG or null.
98
+ * The task must exist in the in-memory map so we can look up its projectId.
99
+ */
100
+ getTaskAttachmentData(taskId: string, attachmentId: string): Promise<string | null>;
101
+ }
102
+
103
+ export interface SendCommandOptions {
104
+ tabId?: string;
105
+ timeoutMs?: number;
106
+ target?: 'runtime-client' | 'vite-plugin';
107
+ projectId?: string;
108
+ }
109
+
110
+ const COMMAND_TIMEOUT_MS = 30_000;
111
+ const TASK_QUEUE_CAP = 200;
112
+
113
+ /**
114
+ * Default data directory for all persistence stores, keyed by the port the
115
+ * daemon listens on. Identity of a daemon = its listening address; same
116
+ * port → same on-disk store; different ports → independent stores.
117
+ *
118
+ * This lets users opt into isolation simply by configuring a different
119
+ * `--port` in their `mcp.json`, and lets multiple IDEs targeting the same
120
+ * port automatically share state through the existing leader/follower
121
+ * mechanism in `cli.ts`. No cwd / project-root detection involved.
122
+ */
123
+ export function defaultDataDir(port: number): string {
124
+ return joinPath(homedir(), '.harness', 'daemons', String(port), 'data');
125
+ }
126
+
127
+ interface PendingCommand {
128
+ resolve(payload: unknown): void;
129
+ reject(err: Error): void;
130
+ timer: NodeJS.Timeout;
131
+ }
132
+
133
+ export interface BridgeOptions {
134
+ port?: number;
135
+ /** Bind address. Default 127.0.0.1 (no remote exposure). */
136
+ host?: string;
137
+ /**
138
+ * Token-based auth applied to every HTTP route and WS upgrade. Empty
139
+ * token disables auth (only valid when bound to a loopback host).
140
+ */
141
+ auth?: AuthOptions;
142
+ /**
143
+ * Override the host used when building outbound URLs (dashboard links,
144
+ * replay viewer URLs). When omitted and `host` is `0.0.0.0` / `::`, the
145
+ * first non-internal IPv4 address from the OS network interfaces is
146
+ * used so other LAN devices can follow the links. For loopback binds the
147
+ * literal `host` is reused.
148
+ */
149
+ publicHost?: string;
150
+ /**
151
+ * Store instance for JSONL persistence. If omitted, a default JsonlStore
152
+ * is created at `dataDir` (or `defaultDataDir(port)` when `dataDir` is
153
+ * also omitted). Pass null to disable persistence.
154
+ */
155
+ store?: IStore | null;
156
+ /**
157
+ * Task store instance for JSON task persistence. If omitted, a default
158
+ * JsonTaskStore is created at `dataDir`. Pass null to disable task
159
+ * persistence (useful in tests).
160
+ */
161
+ taskStore?: ITaskStore | null;
162
+ /**
163
+ * Memory store instance for agent memory persistence. If omitted, a default
164
+ * JsonMemoryStore is created at `dataDir`. Pass null to disable memory
165
+ * persistence (useful in tests).
166
+ */
167
+ memoryStore?: IMemoryStore | null;
168
+ /**
169
+ * Root data directory for task attachment binaries. Defaults to the same
170
+ * `~/.harness/data` directory used by the stores. Override in tests.
171
+ */
172
+ attachmentsDataDir?: string;
173
+ /**
174
+ * Root data directory for the default stores (when `store` / `taskStore`
175
+ * / `memoryStore` are not supplied). When omitted, computed from `port`
176
+ * via `defaultDataDir(port)`. Set explicitly to point all stores at a
177
+ * non-default location (useful for tests or migration).
178
+ */
179
+ dataDir?: string;
180
+ /**
181
+ * Optional friendly label surfaced in the startup banner and (later) the
182
+ * dashboard title. Purely cosmetic — has no effect on data isolation,
183
+ * routing, or auth. Identity is always the listening port.
184
+ */
185
+ label?: string;
186
+ /**
187
+ * Automatic retention policy enforcement.
188
+ *
189
+ * Without this, manual `session.purge` MCP calls are the only thing that
190
+ * trims the on-disk store — so a long-running daemon will eventually fill
191
+ * the user's disk. Default: run `store.purge()` once shortly after start
192
+ * and every hour thereafter. Set `enabled: false` for tests / one-shot runs.
193
+ */
194
+ autoPurge?: {
195
+ enabled?: boolean; // default true
196
+ /** Period between purges in ms. Default 1 hour. */
197
+ intervalMs?: number;
198
+ /** Override the retention policy. Default uses store's built-in defaults. */
199
+ policy?: RetentionPolicy;
200
+ /** Skip the startup purge (still runs the periodic timer). Default false. */
201
+ skipInitial?: boolean;
202
+ };
203
+ }
204
+
205
+ export type EventListener = (event: EventFrame, session: PeerSession) => void;
206
+
207
+ export class Bridge implements IBridge {
208
+ readonly router = new SessionRouter();
209
+ readonly store: IStore | null;
210
+ readonly taskStore: ITaskStore | null;
211
+ readonly memoryStore: IMemoryStore;
212
+ private wss?: WebSocketServer;
213
+ private httpServer?: HttpServer;
214
+ /**
215
+ * Optional HTTP handler invoked for non-WebSocket requests. Set via
216
+ * `setHttpHandler()`. Allows higher layers (e.g. replay viewer) to serve
217
+ * routes on the same port as the WS bridge without coupling Bridge to them.
218
+ */
219
+ private httpHandler?: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
220
+ private sockets = new Map<string, WebSocket>();
221
+ private pending = new Map<string, PendingCommand>();
222
+ private eventListeners = new Set<EventListener>();
223
+ private tasks = new Map<string, Task>();
224
+ private opts: Required<Omit<BridgeOptions, 'store' | 'taskStore' | 'memoryStore' | 'autoPurge' | 'attachmentsDataDir' | 'auth' | 'publicHost' | 'dataDir' | 'label'>>;
225
+ private auth: AuthOptions;
226
+ private publicHostOverride: string | undefined;
227
+ private readonly attachDataDir: string;
228
+ private autoPurgeOpts: Required<NonNullable<BridgeOptions['autoPurge']>>;
229
+ /** Set by start() when auto-purge is enabled; cleared by stop(). */
230
+ private autoPurgeTimer?: NodeJS.Timeout;
231
+ /**
232
+ * Map from connectionId → buildId (for build-plugin connections)
233
+ * or sessionId (for runtime-client connections).
234
+ */
235
+ private connToStoreId = new Map<string, string>();
236
+ /** Connections that already logged a "no store session" warning. */
237
+ private warnedNoSession = new Set<string>();
238
+ /**
239
+ * Grace period timers: projectId → timer handle.
240
+ * When a build plugin disconnects, a 30-second timer is started.
241
+ * If the same project reconnects within that window, the timer is cancelled.
242
+ */
243
+ private graceTimers = new Map<string, NodeJS.Timeout>();
244
+ /**
245
+ * Pending build end info: projectId → { buildId, closedAt }.
246
+ * Tracks builds waiting for the grace period to expire.
247
+ */
248
+ private pendingEndBuild = new Map<string, { buildId: string; closedAt: number }>();
249
+ /**
250
+ * Dashboard SPA subscribers — connections that sent `hello` with
251
+ * role: 'dashboard-client'. Receive `dashboard.update` frames whenever
252
+ * session state changes; never receive commands and never send events.
253
+ */
254
+ private dashboardSubscribers = new Set<WebSocket>();
255
+ /** Debounce per-session 'session.update' broadcasts so chatty rrweb chunks don't spam subscribers. */
256
+ private dashboardDebounceTimers = new Map<string, NodeJS.Timeout>();
257
+
258
+ /** Optional friendly label (HARNESS_FE_LABEL). Cosmetic only. */
259
+ readonly label: string | undefined;
260
+
261
+ constructor(opts: BridgeOptions = {}) {
262
+ const port = opts.port ?? DEFAULT_WS_PORT;
263
+ const dataDir = opts.dataDir ?? defaultDataDir(port);
264
+ this.label = opts.label;
265
+ this.store = opts.store === null ? null : (opts.store ?? new JsonlStore(dataDir));
266
+ this.taskStore = opts.taskStore === null ? null : (opts.taskStore ?? new JsonTaskStore(dataDir));
267
+ this.memoryStore = opts.memoryStore === null
268
+ ? new JsonMemoryStore(dataDir)
269
+ : (opts.memoryStore ?? new JsonMemoryStore(dataDir));
270
+ this.attachDataDir = opts.attachmentsDataDir ?? dataDir;
271
+ this.opts = {
272
+ port: opts.port ?? DEFAULT_WS_PORT,
273
+ host: opts.host ?? '127.0.0.1',
274
+ };
275
+ this.auth = opts.auth ?? {};
276
+ this.publicHostOverride = opts.publicHost;
277
+ // Default auto-purge ON. CI / tests pass `enabled: false` (or set
278
+ // env HARNESS_FE_PURGE_DISABLED=1) to opt out.
279
+ const envDisabled = process.env.HARNESS_FE_PURGE_DISABLED === '1';
280
+ this.autoPurgeOpts = {
281
+ enabled: opts.autoPurge?.enabled ?? !envDisabled,
282
+ intervalMs: opts.autoPurge?.intervalMs ?? 60 * 60 * 1000,
283
+ policy: opts.autoPurge?.policy ?? {},
284
+ skipInitial: opts.autoPurge?.skipInitial ?? false,
285
+ };
286
+ this.loadTasks();
287
+
288
+ // Auto-install dashboard + replay viewer + events HTTP handlers.
289
+ {
290
+ const events = createEventsHandler(this);
291
+ if (this.store) {
292
+ const store = this.store;
293
+ const replay = createReplayHandler(store);
294
+ const dashboardApi = createDashboardApiHandler(
295
+ store,
296
+ () => this.getViewerBaseUrl(),
297
+ ({ sessionId, projectId }) => this.notifyDashboard({ kind: 'export.new', sessionId, projectId }),
298
+ );
299
+ const dashboardSpa = createDashboardSpaHandler();
300
+ this.setHttpHandler(async (req, res) => {
301
+ if (replay(req, res)) return;
302
+ // dashboardApi handles /api/* (must come before SPA so a
303
+ // future SPA route doesn't accidentally shadow it).
304
+ if (await dashboardApi(req, res)) return;
305
+ // dashboardSpa owns /dashboard/* plus the legacy /
306
+ // and /sessions/:id redirects into the SPA.
307
+ if (dashboardSpa(req, res)) return;
308
+ if (await events(req, res)) return;
309
+ res.statusCode = 404;
310
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
311
+ res.end('Not Found');
312
+ });
313
+ } else {
314
+ this.setHttpHandler(async (req, res) => {
315
+ if (await events(req, res)) return;
316
+ res.statusCode = 404;
317
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
318
+ res.end('Not Found');
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Returns the memory store instance for use by mcp.ts and other callers.
326
+ */
327
+ getMemoryStore(): IMemoryStore {
328
+ return this.memoryStore;
329
+ }
330
+
331
+ private loadTasks(): void {
332
+ // Tasks are loaded lazily per-project when a project connects.
333
+ // See loadTasksForProject() which is called in handleFrame on hello.
334
+ }
335
+
336
+ private persistTasks(projectId?: string): void {
337
+ if (!this.taskStore) return;
338
+ if (projectId) {
339
+ // Save only the tasks for the given project
340
+ const projectTasks = Array.from(this.tasks.values()).filter(
341
+ (t) => t.projectId === projectId,
342
+ );
343
+ this.taskStore.saveTasks(projectId, projectTasks);
344
+ } else {
345
+ // Group all tasks by projectId and save each group
346
+ const byProject = new Map<string, Task[]>();
347
+ for (const task of this.tasks.values()) {
348
+ const pid = task.projectId;
349
+ if (!byProject.has(pid)) byProject.set(pid, []);
350
+ byProject.get(pid)!.push(task);
351
+ }
352
+ for (const [pid, projectTasks] of byProject) {
353
+ this.taskStore.saveTasks(pid, projectTasks);
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Load tasks for a specific project from the task store into the in-memory map.
360
+ * Called when a project connects so its tasks are available immediately.
361
+ */
362
+ private loadTasksForProject(projectId: string): void {
363
+ if (!this.taskStore) return;
364
+ const projectTasks = this.taskStore.loadTasks(projectId);
365
+ for (const task of projectTasks) {
366
+ if (task && typeof task.id === 'string') {
367
+ this.tasks.set(task.id, task);
368
+ }
369
+ }
370
+ }
371
+
372
+ private taskDedupKey(tabId: string, payload: { question: string; selector: { css?: string; comp?: string; loc?: string } }): string {
373
+ const sel = payload.selector;
374
+ const selKey = sel.loc ?? sel.comp ?? sel.css ?? '';
375
+ return `${tabId}::${selKey}::${payload.question.trim()}`;
376
+ }
377
+
378
+ /**
379
+ * Register an HTTP request handler that runs for non-WebSocket requests on
380
+ * the same port. Only one handler is supported; later calls replace prior
381
+ * ones. WS upgrades bypass this handler.
382
+ */
383
+ setHttpHandler(handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>): void {
384
+ this.httpHandler = handler;
385
+ }
386
+
387
+ /**
388
+ * Insert a handler that runs *before* the main HTTP handler. Return `true`
389
+ * if the request was consumed (no further processing); return `false` to
390
+ * fall through to the existing handler. Allows mcpHttp.ts to mount on
391
+ * `/mcp` without owning the whole HTTP surface.
392
+ */
393
+ prependHttpHandler(
394
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean,
395
+ ): void {
396
+ const existing = this.httpHandler;
397
+ this.httpHandler = async (req, res) => {
398
+ const handled = await handler(req, res);
399
+ if (handled) return;
400
+ if (existing) await existing(req, res);
401
+ };
402
+ }
403
+
404
+ async start(): Promise<void> {
405
+ await new Promise<void>((resolve, reject) => {
406
+ const loginPath = this.auth.loginPath ?? DEFAULT_LOGIN_PATH;
407
+ const httpServer = createServer((req, res) => {
408
+ // POST {loginPath} handles token submission from the login form.
409
+ // It runs *before* the auth check because that's how the user
410
+ // gets authorised in the first place.
411
+ if (req.method === 'POST' && req.url && pathnameOf(req.url) === loginPath) {
412
+ handleLoginPost(req, res, this.auth).catch((err) => {
413
+ if (!res.headersSent) {
414
+ res.statusCode = 500;
415
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
416
+ res.end(`auth error: ${err instanceof Error ? err.message : String(err)}`);
417
+ }
418
+ });
419
+ return;
420
+ }
421
+ if (!isAuthorized(req, this.auth)) {
422
+ sendUnauthorized(req, res, this.auth);
423
+ return;
424
+ }
425
+ if (this.httpHandler) {
426
+ Promise.resolve(this.httpHandler(req, res)).catch((err) => {
427
+ if (!res.headersSent) {
428
+ res.statusCode = 500;
429
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
430
+ res.end(`Internal error: ${err instanceof Error ? err.message : String(err)}`);
431
+ } else {
432
+ try { res.end(); } catch { /* swallow */ }
433
+ }
434
+ });
435
+ return;
436
+ }
437
+ res.statusCode = 404;
438
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
439
+ res.end('Not Found');
440
+ });
441
+
442
+ const wss = new WebSocketServer({ noServer: true });
443
+ wss.on('connection', (ws) => this.onConnection(ws));
444
+
445
+ httpServer.on('upgrade', (req, socket, head) => {
446
+ if (!isAuthorized(req, this.auth)) {
447
+ // Spec-compliant 401 on the upgrade reply so client sees a
448
+ // proper status rather than a half-open socket.
449
+ socket.write(
450
+ 'HTTP/1.1 401 Unauthorized\r\n' +
451
+ 'WWW-Authenticate: Bearer realm="harness-fe"\r\n' +
452
+ 'Content-Length: 0\r\n' +
453
+ 'Connection: close\r\n\r\n',
454
+ );
455
+ socket.destroy();
456
+ return;
457
+ }
458
+ wss.handleUpgrade(req, socket, head, (ws) => {
459
+ wss.emit('connection', ws, req);
460
+ });
461
+ });
462
+
463
+ httpServer.once('error', reject);
464
+ httpServer.listen(this.opts.port, this.opts.host, () => {
465
+ this.httpServer = httpServer;
466
+ this.wss = wss;
467
+ httpServer.off('error', reject);
468
+ resolve();
469
+ });
470
+ });
471
+
472
+ // Schedule auto-purge after the listen socket is up. Skipped when:
473
+ // - no store configured (in-memory only)
474
+ // - explicitly disabled via opts / env
475
+ if (this.store && this.autoPurgeOpts.enabled) {
476
+ if (!this.autoPurgeOpts.skipInitial) {
477
+ this.runAutoPurge('startup');
478
+ }
479
+ const timer = setInterval(
480
+ () => this.runAutoPurge('periodic'),
481
+ this.autoPurgeOpts.intervalMs,
482
+ );
483
+ // unref so the timer never holds the Node process alive on its own.
484
+ timer.unref();
485
+ this.autoPurgeTimer = timer;
486
+ }
487
+ }
488
+
489
+ async stop(): Promise<void> {
490
+ if (this.autoPurgeTimer) {
491
+ clearInterval(this.autoPurgeTimer);
492
+ this.autoPurgeTimer = undefined;
493
+ }
494
+ for (const ws of this.sockets.values()) {
495
+ try {
496
+ ws.close();
497
+ } catch {
498
+ /* swallow */
499
+ }
500
+ }
501
+ this.sockets.clear();
502
+ await new Promise<void>((resolve) => {
503
+ if (!this.wss) return resolve();
504
+ this.wss.close(() => resolve());
505
+ });
506
+ await new Promise<void>((resolve) => {
507
+ if (!this.httpServer) return resolve();
508
+ this.httpServer.close(() => resolve());
509
+ });
510
+ }
511
+
512
+ /**
513
+ * Run `store.purge()` defensively. Errors are logged but never bubble out
514
+ * — the daemon must continue serving even if disk is full or files are
515
+ * locked.
516
+ */
517
+ private runAutoPurge(trigger: 'startup' | 'periodic'): void {
518
+ if (!this.store) return;
519
+ try {
520
+ const result = this.store.purge(this.autoPurgeOpts.policy);
521
+ const removed =
522
+ result.sessionsDeleted +
523
+ result.recordingsDeleted +
524
+ result.exportsDeleted +
525
+ (result.buildsDeleted ?? 0);
526
+ if (removed > 0 || result.bytesFreed > 0) {
527
+ const mb = (result.bytesFreed / 1024 / 1024).toFixed(2);
528
+ process.stderr.write(
529
+ `[harness-fe] auto-purge (${trigger}): freed ${mb} MB · ` +
530
+ `${result.sessionsDeleted} sessions, ` +
531
+ `${result.recordingsDeleted} rrweb chunks, ` +
532
+ `${result.buildsDeleted ?? 0} builds, ` +
533
+ `${result.exportsDeleted} exports\n`,
534
+ );
535
+ }
536
+ } catch (err) {
537
+ process.stderr.write(
538
+ `[harness-fe] auto-purge failed (${trigger}): ${
539
+ err instanceof Error ? err.message : String(err)
540
+ }\n`,
541
+ );
542
+ }
543
+ }
544
+
545
+ /** Expose the bound port (useful when port:0 was passed for tests). */
546
+ getBoundPort(): number | undefined {
547
+ if (!this.httpServer) return undefined;
548
+ const addr = this.httpServer.address();
549
+ if (addr && typeof addr === 'object') return addr.port;
550
+ return undefined;
551
+ }
552
+
553
+ getViewerBaseUrl(): string | undefined {
554
+ const port = this.getBoundPort() ?? this.opts.port;
555
+ if (!port) return undefined;
556
+ return `http://${this.getPublicHost()}:${port}`;
557
+ }
558
+
559
+ getAuthToken(): string | undefined {
560
+ return this.auth.token;
561
+ }
562
+
563
+ /**
564
+ * Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
565
+ *
566
+ * `kind: 'session.update'` is debounced per-sessionId (200ms) so chatty
567
+ * rrweb chunk appends don't spam every subscriber. Other kinds fire
568
+ * immediately because they represent rare state transitions (new
569
+ * session, session closed, export created).
570
+ */
571
+ notifyDashboard(payload: {
572
+ kind: 'session.new' | 'session.update' | 'session.closed' | 'project.update' | 'export.new';
573
+ sessionId?: string;
574
+ projectId?: string;
575
+ }): void {
576
+ if (this.dashboardSubscribers.size === 0) return;
577
+ const debounceKey = payload.kind === 'session.update'
578
+ ? `${payload.kind}:${payload.sessionId ?? ''}`
579
+ : undefined;
580
+ if (debounceKey) {
581
+ const existing = this.dashboardDebounceTimers.get(debounceKey);
582
+ if (existing) clearTimeout(existing);
583
+ const timer = setTimeout(() => {
584
+ this.dashboardDebounceTimers.delete(debounceKey);
585
+ this.flushDashboardUpdate(payload);
586
+ }, 200);
587
+ this.dashboardDebounceTimers.set(debounceKey, timer);
588
+ return;
589
+ }
590
+ this.flushDashboardUpdate(payload);
591
+ }
592
+
593
+ private flushDashboardUpdate(payload: {
594
+ kind: 'session.new' | 'session.update' | 'session.closed' | 'project.update' | 'export.new';
595
+ sessionId?: string;
596
+ projectId?: string;
597
+ }): void {
598
+ const frame = {
599
+ type: 'dashboard.update' as const,
600
+ id: randomUUID(),
601
+ kind: payload.kind,
602
+ sessionId: payload.sessionId,
603
+ projectId: payload.projectId,
604
+ ts: Date.now(),
605
+ };
606
+ const json = JSON.stringify(frame);
607
+ for (const ws of this.dashboardSubscribers) {
608
+ try {
609
+ if (ws.readyState === ws.OPEN) ws.send(json);
610
+ } catch {
611
+ // Failed sends will be cleaned up on next close event.
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Host string used when handing out URLs that other machines need to
618
+ * reach. Loopback binds keep the literal address; wildcard binds
619
+ * (0.0.0.0 / ::) prefer the first non-internal IPv4. Explicit
620
+ * `publicHost` always wins.
621
+ */
622
+ private getPublicHost(): string {
623
+ if (this.publicHostOverride) return this.publicHostOverride;
624
+ const h = this.opts.host;
625
+ if (h === '0.0.0.0' || h === '::' || h === '::0') {
626
+ return firstNonInternalIpv4() ?? '127.0.0.1';
627
+ }
628
+ return h;
629
+ }
630
+
631
+ onEvent(listener: EventListener): () => void {
632
+ this.eventListeners.add(listener);
633
+ return () => this.eventListeners.delete(listener);
634
+ }
635
+
636
+ /**
637
+ * Handle an HTTP-batch POST /events request (Edge Runtime path).
638
+ *
639
+ * Stateless: each call is a self-contained hello+events sequence.
640
+ * The hello is used to register the peer (or look up the existing session)
641
+ * and the events are persisted to the session timeline — same paths as the
642
+ * WS handler.
643
+ */
644
+ handleHttpBatch(
645
+ hello: HttpBatch['hello'],
646
+ events: HttpBatch['events'],
647
+ ): void {
648
+ const projectId = hello.projectId;
649
+ const sessionId = hello.sessionId ?? `server-orphans:${sanitizeStoreId(projectId)}`;
650
+
651
+ // Persist to store if available
652
+ if (this.store) {
653
+ // Upsert project metadata
654
+ if (hello.displayName !== undefined) {
655
+ try {
656
+ this.store.upsertProject(projectId, {
657
+ displayName: hello.displayName,
658
+ });
659
+ } catch {
660
+ // ignore cycle / validation errors
661
+ }
662
+ }
663
+
664
+ // Ensure session exists — if sessionId was provided by caller the
665
+ // runtime-client typically already created it; we use upsertSession
666
+ // so a server-only session (no browser client) also gets bootstrapped.
667
+ this.store.upsertSession(sessionId, {
668
+ tabId: 'http-batch',
669
+ startedAt: Date.now(),
670
+ participants: [{ projectId, buildId: hello.buildId, joinedAt: Date.now() }],
671
+ });
672
+
673
+ // Persist each event
674
+ for (const ev of events) {
675
+ const evName: string = typeof ev.name === 'string' ? ev.name : 'unknown';
676
+ // app.log events get the canonical short type code 'app-log'
677
+ const evType: string = evName === 'app.log' ? 'app-log' : evName;
678
+ this.store.appendEvent(sessionId, {
679
+ ts: typeof ev.ts === 'number' ? ev.ts : Date.now(),
680
+ t: evType,
681
+ projectId,
682
+ buildId: ev.buildId ?? hello.buildId,
683
+ d: ev.payload,
684
+ });
685
+ }
686
+ }
687
+
688
+ // Fire event listeners so MCP tools can observe HTTP-batch events in real time
689
+ for (const ev of events) {
690
+ const evName: string = typeof ev.name === 'string' ? ev.name : 'unknown';
691
+ const fullFrame: import('@harness-fe/protocol').EventFrame = {
692
+ type: 'event',
693
+ id: ev.id ?? randomUUID(),
694
+ name: evName,
695
+ ts: typeof ev.ts === 'number' ? ev.ts : Date.now(),
696
+ projectId,
697
+ sessionId,
698
+ buildId: ev.buildId ?? hello.buildId,
699
+ payload: ev.payload,
700
+ };
701
+ // Use a synthetic PeerSession so listeners have consistent shape
702
+ const syntheticPeer: import('./sessionRouter.js').PeerSession = {
703
+ connectionId: `http:${sessionId}`,
704
+ role: 'node-runtime',
705
+ projectId,
706
+ tabId: undefined,
707
+ sessionId,
708
+ visitorId: undefined,
709
+ userId: hello.userId,
710
+ page: undefined,
711
+ lastActive: Date.now(),
712
+ };
713
+ for (const listener of this.eventListeners) {
714
+ try {
715
+ listener(fullFrame, syntheticPeer);
716
+ } catch {
717
+ /* swallow */
718
+ }
719
+ }
720
+ }
721
+
722
+ process.stderr.write(
723
+ `[harness-fe] http-batch: project=${projectId}` +
724
+ ` session=${sessionId.slice(0, 8)} events=${events.length}\n`,
725
+ );
726
+ }
727
+
728
+ async listTabs(): Promise<TabInfo[]> {
729
+ return this.router.listTabs();
730
+ }
731
+
732
+ async listTasks(
733
+ filter: { status?: TaskStatus | 'all'; limit?: number } = {},
734
+ ): Promise<Task[]> {
735
+ const status = filter.status ?? 'pending';
736
+ const limit = filter.limit ?? 50;
737
+ const all = Array.from(this.tasks.values());
738
+ const filtered = status === 'all' ? all : all.filter((t) => t.status === status);
739
+ filtered.sort((a, b) => b.createdAt - a.createdAt);
740
+ return filtered.slice(0, limit);
741
+ }
742
+
743
+ async claimTask(id: string): Promise<Task | undefined> {
744
+ const task = this.tasks.get(id);
745
+ if (!task) return undefined;
746
+ task.status = 'claimed';
747
+ task.claimedAt = Date.now();
748
+ this.persistTasks();
749
+ // Persist status change to store
750
+ this.persistTaskEvent(task, 'task:claim');
751
+ return task;
752
+ }
753
+
754
+ async getTaskAttachmentData(taskId: string, attachmentId: string): Promise<string | null> {
755
+ const task = this.tasks.get(taskId);
756
+ if (!task) return null;
757
+ return this.readTaskAttachment(task.projectId, taskId, attachmentId);
758
+ }
759
+
760
+ async resolveTask(id: string, note?: string): Promise<Task | undefined> {
761
+ const task = this.tasks.get(id);
762
+ if (!task) return undefined;
763
+ task.status = 'resolved';
764
+ task.resolvedAt = Date.now();
765
+ if (note !== undefined) task.note = note;
766
+ this.persistTasks();
767
+ // Persist status change to store
768
+ this.persistTaskEvent(task, 'task:resolve');
769
+ return task;
770
+ }
771
+
772
+ private persistTaskEvent(task: Task, eventType: string): void {
773
+ if (!this.store) return;
774
+ // Find the most recent session for this task's project
775
+ const sessions = this.store.listSessions({ projectId: task.projectId, limit: 1 });
776
+ const sessionId = sessions[0]?.id;
777
+ if (!sessionId) return;
778
+ this.store.appendEvent(sessionId, {
779
+ ts: Date.now(),
780
+ t: eventType,
781
+ tab: task.tabId,
782
+ load: task.sessionId,
783
+ d: { id: task.id, status: task.status, question: task.question, note: task.note },
784
+ });
785
+ }
786
+
787
+ private recordTask(frame: EventFrame, peer: PeerSession): void {
788
+ const parsed = taskSubmitPayloadSchema.safeParse(frame.payload);
789
+ if (!parsed.success) return;
790
+ const tabId = peer.tabId ?? frame.tabId ?? 'unknown';
791
+ // Dedup: collapse a fresh submit onto an existing pending task with
792
+ // identical tab + selector + question. Refresh its timestamp and
793
+ // overwrite the captured element snapshot, but keep the same id so
794
+ // claim/resolve flows don't fork.
795
+ const dedupKey = this.taskDedupKey(tabId, parsed.data);
796
+ for (const existing of this.tasks.values()) {
797
+ if (existing.status !== 'pending') continue;
798
+ if (this.taskDedupKey(existing.tabId, existing) !== dedupKey) continue;
799
+ existing.createdAt = frame.ts ?? Date.now();
800
+ existing.element = parsed.data.element;
801
+ existing.url = parsed.data.url;
802
+ this.persistTasks();
803
+ return;
804
+ }
805
+ const id = randomUUID().slice(0, 10);
806
+ const projectId = peer.projectId ?? frame.projectId ?? 'unknown';
807
+
808
+ // Process attachments: decode base64, write to disk, store pointer.
809
+ let persistedAttachments: TaskAttachment[] | undefined;
810
+ if (parsed.data.attachments && parsed.data.attachments.length > 0) {
811
+ persistedAttachments = this.writeTaskAttachments(projectId, id, parsed.data.attachments);
812
+ }
813
+
814
+ const task: Task = {
815
+ id,
816
+ tabId,
817
+ sessionId: peer.sessionId,
818
+ visitorId: peer.visitorId,
819
+ userId: peer.userId,
820
+ projectId,
821
+ url: parsed.data.url,
822
+ status: 'pending',
823
+ question: parsed.data.question,
824
+ selector: parsed.data.selector,
825
+ element: parsed.data.element,
826
+ createdAt: frame.ts ?? Date.now(),
827
+ attachments: persistedAttachments,
828
+ };
829
+ this.tasks.set(id, task);
830
+ if (this.tasks.size > TASK_QUEUE_CAP) {
831
+ // FIFO eviction by insertion order.
832
+ const oldest = this.tasks.keys().next().value;
833
+ if (oldest !== undefined) this.tasks.delete(oldest);
834
+ }
835
+ this.persistTasks();
836
+ }
837
+
838
+ /**
839
+ * Write attachment data to disk and return persisted pointer objects.
840
+ * Drops attachments if the total decoded size exceeds 4 MB.
841
+ */
842
+ private writeTaskAttachments(projectId: string, taskId: string, attachments: TaskAttachment[]): TaskAttachment[] {
843
+ const MAX_BYTES = 4 * 1024 * 1024;
844
+ const result: TaskAttachment[] = [];
845
+
846
+ // Calculate total bytes first
847
+ let totalBytes = 0;
848
+ const buffers: Buffer[] = [];
849
+ for (const att of attachments) {
850
+ if (!att.data) continue;
851
+ try {
852
+ const buf = Buffer.from(att.data, 'base64');
853
+ totalBytes += buf.length;
854
+ buffers.push(buf);
855
+ } catch {
856
+ buffers.push(Buffer.alloc(0));
857
+ }
858
+ }
859
+
860
+ if (totalBytes > MAX_BYTES) {
861
+ process.stderr.write(
862
+ `[harness-fe] task ${taskId}: attachments total ${(totalBytes / 1024 / 1024).toFixed(2)} MB exceeds 4 MB limit — dropping attachments\n`,
863
+ );
864
+ return [];
865
+ }
866
+
867
+ const attachDir = joinPath(this.attachDataDir, 'projects', sanitizeStoreId(projectId), 'task-attachments', taskId);
868
+ try {
869
+ mkdirSync(attachDir, { recursive: true });
870
+ } catch {
871
+ return [];
872
+ }
873
+
874
+ let bufIdx = 0;
875
+ for (const att of attachments) {
876
+ if (!att.data) {
877
+ bufIdx++;
878
+ continue;
879
+ }
880
+ const buf = buffers[bufIdx++];
881
+ if (!buf || buf.length === 0) continue;
882
+ const filePath = joinPath(attachDir, `${att.id}.png`);
883
+ try {
884
+ writeFileSync(filePath, buf);
885
+ const relPath = `task-attachments/${taskId}/${att.id}.png`;
886
+ result.push({
887
+ id: att.id,
888
+ kind: att.kind,
889
+ width: att.width,
890
+ height: att.height,
891
+ path: relPath,
892
+ // data is intentionally omitted — tasks.json stays small
893
+ });
894
+ } catch (err) {
895
+ process.stderr.write(
896
+ `[harness-fe] failed to write attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}\n`,
897
+ );
898
+ }
899
+ }
900
+ return result;
901
+ }
902
+
903
+ /**
904
+ * Read an attachment from disk for a given task.
905
+ * Returns the base64 data if found, null otherwise.
906
+ */
907
+ readTaskAttachment(projectId: string, taskId: string, attachmentId: string): string | null {
908
+ const filePath = joinPath(
909
+ this.attachDataDir,
910
+ 'projects',
911
+ sanitizeStoreId(projectId),
912
+ 'task-attachments',
913
+ taskId,
914
+ `${attachmentId}.png`,
915
+ );
916
+ if (!existsSync(filePath)) return null;
917
+ try {
918
+ const buf = readFileSync(filePath);
919
+ return buf.toString('base64');
920
+ } catch {
921
+ return null;
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Send a command to a specific tab and await its response.
927
+ * `tabId` falls back to the most-recent active tab if omitted.
928
+ */
929
+ async sendCommand(
930
+ command: string,
931
+ args: unknown,
932
+ opts: SendCommandOptions = {},
933
+ ): Promise<unknown> {
934
+ const target = opts.target ?? 'runtime-client';
935
+ const session =
936
+ target === 'vite-plugin'
937
+ ? this.router.findVitePlugin(opts.projectId)
938
+ : this.router.findTab(opts.tabId);
939
+ if (!session) {
940
+ throw new Error(
941
+ target === 'vite-plugin'
942
+ ? 'bridge: no vite-plugin connected. Start the dev server first.'
943
+ : opts.tabId
944
+ ? `bridge: no runtime-client connected for tabId="${opts.tabId}"`
945
+ : 'bridge: no runtime-client connected. Open the dev page first.',
946
+ );
947
+ }
948
+ const socket = this.sockets.get(session.connectionId);
949
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
950
+ throw new Error('bridge: target socket is not open');
951
+ }
952
+
953
+ const id = randomUUID();
954
+ const cmdTs = Date.now();
955
+ const frame: CommandFrame = {
956
+ type: 'command',
957
+ id,
958
+ tabId: session.tabId,
959
+ command,
960
+ args,
961
+ };
962
+
963
+ // Persist command to store — runtime-client connections store a sessionId
964
+ const storeId = this.connToStoreId.get(session.connectionId);
965
+ // For runtime-client, storeId is the sessionId; for plugins storeId is the buildId.
966
+ // Commands are sent to runtime-clients, so storeId here is always a sessionId.
967
+ const storeSessionId = (session.role === 'runtime-client') ? storeId : undefined;
968
+ if (this.store && storeSessionId) {
969
+ this.store.appendEvent(storeSessionId, {
970
+ ts: cmdTs, t: 'cmd', tab: session.tabId,
971
+ d: { id, command, args, target },
972
+ });
973
+ }
974
+
975
+ const timeoutMs = opts.timeoutMs ?? COMMAND_TIMEOUT_MS;
976
+ return new Promise((resolve, reject) => {
977
+ const timer = setTimeout(() => {
978
+ this.pending.delete(id);
979
+ // Persist timeout as failed response
980
+ if (this.store && storeSessionId) {
981
+ this.store.appendEvent(storeSessionId, {
982
+ ts: Date.now(), t: 'resp', tab: session.tabId,
983
+ d: { id, ok: false, error: `timeout after ${timeoutMs}ms`, durationMs: timeoutMs },
984
+ });
985
+ }
986
+ reject(new Error(`bridge: command "${command}" timed out after ${timeoutMs}ms`));
987
+ }, timeoutMs);
988
+ this.pending.set(id, {
989
+ resolve: (result) => {
990
+ // Persist successful response (strip screenshot dataUrl to save space)
991
+ if (this.store && storeSessionId) {
992
+ const safeResult = stripLargePayloads(result);
993
+ this.store.appendEvent(storeSessionId, {
994
+ ts: Date.now(), t: 'resp', tab: session.tabId,
995
+ d: { id, ok: true, result: safeResult, durationMs: Date.now() - cmdTs },
996
+ });
997
+ }
998
+ resolve(result);
999
+ },
1000
+ reject: (err) => {
1001
+ // Persist error response
1002
+ if (this.store && storeSessionId) {
1003
+ this.store.appendEvent(storeSessionId, {
1004
+ ts: Date.now(), t: 'resp', tab: session.tabId,
1005
+ d: { id, ok: false, error: err.message, durationMs: Date.now() - cmdTs },
1006
+ });
1007
+ }
1008
+ reject(err);
1009
+ },
1010
+ timer,
1011
+ });
1012
+ try {
1013
+ socket.send(JSON.stringify(frame));
1014
+ } catch (err) {
1015
+ clearTimeout(timer);
1016
+ this.pending.delete(id);
1017
+ reject(err as Error);
1018
+ }
1019
+ });
1020
+ }
1021
+
1022
+ /**
1023
+ * Returns true if there is an active build for the given projectId.
1024
+ * Checks both in-memory grace period builds and the store.
1025
+ */
1026
+ private hasActiveBuild(projectId: string): boolean {
1027
+ // Check if there's a build in the grace period (still considered active)
1028
+ if (this.pendingEndBuild.has(projectId)) return true;
1029
+ // Check if any connection currently maps to a build for this project
1030
+ for (const [connId] of this.connToStoreId) {
1031
+ const peer = this.router.getByConnectionId(connId);
1032
+ if (peer?.projectId === projectId && (peer.role === 'vite-plugin' || peer.role === 'webpack-plugin')) {
1033
+ return true;
1034
+ }
1035
+ }
1036
+ return false;
1037
+ }
1038
+
1039
+ private onConnection(ws: WebSocket): void {
1040
+ const connectionId = randomUUID();
1041
+ this.sockets.set(connectionId, ws);
1042
+
1043
+ ws.on('message', (raw) => {
1044
+ let parsed: unknown;
1045
+ try {
1046
+ parsed = JSON.parse(raw.toString());
1047
+ } catch {
1048
+ return; // ignore non-JSON
1049
+ }
1050
+ const frame = frameSchema.safeParse(parsed);
1051
+ if (!frame.success) return;
1052
+ this.handleFrame(connectionId, ws, frame.data);
1053
+ });
1054
+
1055
+ ws.on('close', () => {
1056
+ this.sockets.delete(connectionId);
1057
+ this.warnedNoSession.delete(connectionId);
1058
+ // Dashboard subscribers don't have a router/session — just drop them.
1059
+ this.dashboardSubscribers.delete(ws);
1060
+ // Close store session/tab if applicable
1061
+ const storeId = this.connToStoreId.get(connectionId);
1062
+ if (storeId && this.store) {
1063
+ const peer = this.router.getByConnectionId(connectionId);
1064
+ if (peer?.role === 'runtime-client' && peer.tabId) {
1065
+ // Close the session and tab for this runtime-client.
1066
+ // storeId is the sessionId for runtime-clients.
1067
+ this.store.closeSession(storeId);
1068
+ this.store.closeTab(peer.tabId);
1069
+ this.connToStoreId.delete(connectionId);
1070
+ this.notifyDashboard({
1071
+ kind: 'session.closed',
1072
+ sessionId: storeId,
1073
+ projectId: peer.projectId,
1074
+ });
1075
+ } else if (peer?.role === 'vite-plugin' || peer?.role === 'webpack-plugin') {
1076
+ // storeId is the buildId for build-plugins.
1077
+ // Start grace period instead of closing build immediately.
1078
+ const projectId = peer.projectId;
1079
+ if (projectId) {
1080
+ const closedAt = Date.now();
1081
+ this.pendingEndBuild.set(projectId, { buildId: storeId, closedAt });
1082
+ const timer = setTimeout(() => {
1083
+ this.graceTimers.delete(projectId);
1084
+ const pending = this.pendingEndBuild.get(projectId);
1085
+ if (pending && pending.buildId === storeId) {
1086
+ this.pendingEndBuild.delete(projectId);
1087
+ this.store?.closeBuild(storeId, pending.closedAt);
1088
+ }
1089
+ }, 30_000);
1090
+ this.graceTimers.set(projectId, timer);
1091
+ } else {
1092
+ // No projectId — close build immediately
1093
+ this.store.closeBuild(storeId);
1094
+ }
1095
+ this.connToStoreId.delete(connectionId);
1096
+ }
1097
+ }
1098
+ this.router.unregister(connectionId);
1099
+ });
1100
+
1101
+ ws.on('error', () => {
1102
+ /* swallow; close will follow */
1103
+ });
1104
+ }
1105
+
1106
+ private handleFrame(connectionId: string, ws: WebSocket, frame: Frame): void {
1107
+ switch (frame.type) {
1108
+ case 'hello': {
1109
+ // Dashboard-client is a read-only subscriber — it never sends
1110
+ // commands or events. Skip the entire router/session-setup
1111
+ // path; just register for broadcast and ack.
1112
+ if (frame.role === 'dashboard-client') {
1113
+ this.dashboardSubscribers.add(ws);
1114
+ const ack: HelloAckFrame = {
1115
+ type: 'hello.ack',
1116
+ id: frame.id,
1117
+ serverVersion: PROTOCOL_VERSION,
1118
+ };
1119
+ try { ws.send(JSON.stringify(ack)); } catch { /* swallow */ }
1120
+ return;
1121
+ }
1122
+
1123
+ // Runtime-client MUST carry a sessionId so every emitted event is
1124
+ // attributable to a specific page load. Reject explicitly so
1125
+ // misconfigured clients surface during development.
1126
+ if (frame.role === 'runtime-client' && !frame.sessionId) {
1127
+ console.warn(
1128
+ '[harness-fe] rejecting runtime-client hello — missing sessionId',
1129
+ { projectId: frame.projectId, tabId: frame.tabId },
1130
+ );
1131
+ const errorAck: HelloAckFrame = {
1132
+ type: 'hello.ack',
1133
+ id: frame.id,
1134
+ serverVersion: PROTOCOL_VERSION,
1135
+ error: 'runtime-client hello missing sessionId',
1136
+ };
1137
+ ws.send(JSON.stringify(errorAck));
1138
+ return;
1139
+ }
1140
+
1141
+ // NOTE: runtime-client is allowed to bootstrap a project on its
1142
+ // own (no plugin required). This is the standard mode for the
1143
+ // @harness-fe/next + jsxImportSource integration and for any
1144
+ // production / staging deployment where the bundler plugin is
1145
+ // absent. The runtime-client branch below opens its own store
1146
+ // session if one does not already exist for this project.
1147
+
1148
+ const session = this.router.register({
1149
+ role: frame.role,
1150
+ projectId: frame.projectId,
1151
+ tabId: frame.tabId,
1152
+ sessionId: frame.sessionId,
1153
+ visitorId: frame.visitorId,
1154
+ userId: frame.userId,
1155
+ connectionId,
1156
+ page: frame.page,
1157
+ });
1158
+ // Persist to store
1159
+ if (this.store) {
1160
+ // Project tree: record parentProjectId / displayName / tags
1161
+ // the moment we learn about them via any hello frame.
1162
+ if (
1163
+ frame.parentProjectId !== undefined ||
1164
+ frame.displayName !== undefined
1165
+ ) {
1166
+ try {
1167
+ this.store.upsertProject(frame.projectId, {
1168
+ parentProjectId: frame.parentProjectId,
1169
+ displayName: frame.displayName,
1170
+ });
1171
+ } catch (err) {
1172
+ // Cycle detection or other validation failure —
1173
+ // log and continue; the peer still gets registered.
1174
+ console.warn(
1175
+ '[harness-fe] upsertProject failed:',
1176
+ err instanceof Error ? err.message : err,
1177
+ );
1178
+ }
1179
+ }
1180
+ // Build artifact: record buildId metadata on first sight (runtime-client only;
1181
+ // plugin openBuild() already handles the build-plugin case).
1182
+ if (frame.buildId && frame.role === 'runtime-client') {
1183
+ this.store.upsertBuild(frame.projectId, frame.buildId, {
1184
+ bundler: undefined,
1185
+ });
1186
+ }
1187
+ // Visitor metadata (0.5+) — write once per hello. The
1188
+ // runtime sends visitorId+env on every connect; we count
1189
+ // sessions only on runtime-client hellos to avoid
1190
+ // double-counting plugin reconnects.
1191
+ if (frame.visitorId && frame.role === 'runtime-client') {
1192
+ try {
1193
+ this.store.upsertVisitor(frame.visitorId, {
1194
+ userId: frame.userId,
1195
+ incrementSession: true,
1196
+ addTabId: frame.tabId,
1197
+ addProjectId: frame.projectId,
1198
+ lastEnv: frame.env,
1199
+ });
1200
+ } catch (err) {
1201
+ console.warn(
1202
+ '[harness-fe] upsertVisitor failed:',
1203
+ err instanceof Error ? err.message : err,
1204
+ );
1205
+ }
1206
+ }
1207
+
1208
+ if (frame.role === 'vite-plugin' || frame.role === 'webpack-plugin') {
1209
+ const projectId = frame.projectId;
1210
+ // Check if there's a pending grace period for this project
1211
+ const pendingTimer = projectId ? this.graceTimers.get(projectId) : undefined;
1212
+ const pendingBuild = projectId ? this.pendingEndBuild.get(projectId) : undefined;
1213
+ if (pendingTimer !== undefined && pendingBuild !== undefined && projectId) {
1214
+ // Reconnect within grace period — cancel timer and reuse existing build
1215
+ clearTimeout(pendingTimer);
1216
+ this.graceTimers.delete(projectId);
1217
+ this.pendingEndBuild.delete(projectId);
1218
+ this.connToStoreId.set(connectionId, pendingBuild.buildId);
1219
+ } else {
1220
+ // Open a new build for this dev-server start
1221
+ const buildId = this.store.openBuild(frame.projectId, {
1222
+ bundler: frame.role === 'vite-plugin' ? 'vite' : 'webpack',
1223
+ });
1224
+ this.connToStoreId.set(connectionId, buildId);
1225
+ }
1226
+ } else if (frame.role === 'runtime-client' && frame.tabId) {
1227
+ // Runtime-client: upsert the pageload session identified by frame.sessionId.
1228
+ // frame.sessionId is the shared sessionId (shared across same-origin iframes).
1229
+ const sessionId = frame.sessionId ?? randomUUID();
1230
+ this.store.upsertTab(frame.tabId, {
1231
+ connectedAt: Date.now(),
1232
+ userAgent: frame.page?.userAgent,
1233
+ });
1234
+ // Build participants list: use frame.buildId if the plugin already told us about it
1235
+ const participants: Array<{ projectId: string; buildId?: string; joinedAt: number }> = [
1236
+ { projectId: frame.projectId, buildId: frame.buildId, joinedAt: Date.now() },
1237
+ ];
1238
+ const sessionExisted = this.store.getSession(sessionId) !== undefined;
1239
+ this.store.upsertSession(sessionId, {
1240
+ tabId: frame.tabId,
1241
+ startedAt: Date.now(),
1242
+ url: frame.page?.url,
1243
+ title: frame.page?.title,
1244
+ referrer: undefined,
1245
+ userAgent: frame.page?.userAgent,
1246
+ participants,
1247
+ });
1248
+ this.connToStoreId.set(connectionId, sessionId);
1249
+ this.notifyDashboard({
1250
+ kind: sessionExisted ? 'session.update' : 'session.new',
1251
+ sessionId,
1252
+ projectId: frame.projectId,
1253
+ });
1254
+ } else if (frame.role === 'node-runtime') {
1255
+ // Node SDK: server-side events are linked to the per-request sessionId
1256
+ // when present (the session was already created by the browser runtime-client).
1257
+ // Process-level events without a sessionId use a per-project orphan bucket.
1258
+ const sessionId = frame.sessionId
1259
+ ?? `server-orphans:${sanitizeStoreId(frame.projectId)}`;
1260
+ if (!frame.sessionId) {
1261
+ // Ensure the orphan bucket session exists. We use a synthetic
1262
+ // tabId so upsertSession's required field is satisfied.
1263
+ this.store.upsertSession(sessionId, {
1264
+ tabId: 'server-orphans',
1265
+ startedAt: Date.now(),
1266
+ participants: [{ projectId: frame.projectId, joinedAt: Date.now() }],
1267
+ });
1268
+ }
1269
+ // For the shared-session case, the runtime-client already created it;
1270
+ // no upsert needed — we just route events there via connToStoreId.
1271
+ this.connToStoreId.set(connectionId, sessionId);
1272
+ }
1273
+ }
1274
+ // If store is null but taskStore is available, load tasks for build plugins
1275
+ // and node-runtime (so MCP tools can serve tasks from both kinds of peers).
1276
+ if (!this.store && this.taskStore && (
1277
+ frame.role === 'vite-plugin' ||
1278
+ frame.role === 'webpack-plugin' ||
1279
+ frame.role === 'node-runtime'
1280
+ )) {
1281
+ const projectId = frame.projectId;
1282
+ if (projectId) this.loadTasksForProject(projectId);
1283
+ }
1284
+ const ack: HelloAckFrame = {
1285
+ type: 'hello.ack',
1286
+ id: frame.id,
1287
+ tabId: session.tabId,
1288
+ serverVersion: PROTOCOL_VERSION,
1289
+ };
1290
+ ws.send(JSON.stringify(ack));
1291
+ // One concise line per accepted peer. Visibility for "is the
1292
+ // runtime actually talking to me?" without needing wireshark.
1293
+ process.stderr.write(
1294
+ `[harness-fe] peer connected: role=${frame.role} project=${frame.projectId}` +
1295
+ `${frame.tabId ? ` tab=${frame.tabId.slice(0, 8)}` : ''}` +
1296
+ `${frame.sessionId ? ` load=${frame.sessionId.slice(0, 8)}` : ''}\n`,
1297
+ );
1298
+ break;
1299
+ }
1300
+ case 'response': {
1301
+ const pending = this.pending.get(frame.id);
1302
+ if (!pending) return; // late response or unknown; drop
1303
+ clearTimeout(pending.timer);
1304
+ this.pending.delete(frame.id);
1305
+ if (frame.ok) pending.resolve(frame.result);
1306
+ else
1307
+ pending.reject(
1308
+ new Error(frame.error?.message ?? 'unknown bridge error'),
1309
+ );
1310
+ break;
1311
+ }
1312
+ case 'event': {
1313
+ this.router.touch(connectionId);
1314
+ const peer = this.router.getByConnectionId(connectionId);
1315
+ if (!peer) return;
1316
+ if (frame.name === EVENT_NAME.TASK_SUBMIT) {
1317
+ this.recordTask(frame, peer);
1318
+ }
1319
+ // Persist to store
1320
+ if (this.store) {
1321
+ const storeId = this.connToStoreId.get(connectionId);
1322
+ // For runtime-clients storeId is the sessionId.
1323
+ // For build plugins storeId is the buildId — events from plugins
1324
+ // are appended to the most recent session for that project.
1325
+ let storeSessionId: string | undefined;
1326
+ if (peer.role === 'runtime-client' || peer.role === 'node-runtime') {
1327
+ // For these roles, storeId IS the sessionId (or the orphan bucket id).
1328
+ storeSessionId = storeId;
1329
+ } else if (storeId) {
1330
+ // Build plugin: find most recent session for this project
1331
+ const sessions = this.store.listSessions({ projectId: peer.projectId, limit: 1 });
1332
+ storeSessionId = sessions[0]?.id;
1333
+ }
1334
+
1335
+ if (!storeSessionId) {
1336
+ // Should not happen after the hello-time bootstrap above.
1337
+ // Warn once per connection so silent data loss surfaces.
1338
+ if (!this.warnedNoSession.has(connectionId)) {
1339
+ this.warnedNoSession.add(connectionId);
1340
+ console.warn(
1341
+ '[harness-fe] dropping event — no store session for connection',
1342
+ { projectId: peer.projectId, role: peer.role, eventName: frame.name },
1343
+ );
1344
+ }
1345
+ }
1346
+ if (storeSessionId) {
1347
+ const tabId = frame.tabId ?? peer.tabId;
1348
+ // Row-level stamps for multi-project / multi-visitor mixed timelines.
1349
+ // Prefer the frame's own values (set by the runtime per-event)
1350
+ // and fall back to the registered peer's identity.
1351
+ const projectId = peer.projectId;
1352
+ const buildId = (peer.role === 'vite-plugin' || peer.role === 'webpack-plugin')
1353
+ ? storeId
1354
+ : (frame.buildId ?? undefined);
1355
+ const visitorId = frame.visitorId ?? peer.visitorId;
1356
+
1357
+ if (frame.name === EVENT_NAME.PAGE_LOAD && tabId) {
1358
+ const parsed = pageLoadPayloadSchema.safeParse(frame.payload);
1359
+ const ts = frame.ts ?? Date.now();
1360
+ const page = parsed.success ? parsed.data.page : undefined;
1361
+ const viewport = parsed.success ? parsed.data.viewport : undefined;
1362
+ const storageData = parsed.success ? parsed.data.storage : undefined;
1363
+ // Update session meta with page info
1364
+ this.store.upsertSession(storeSessionId, {
1365
+ tabId: tabId,
1366
+ startedAt: ts,
1367
+ url: page?.url ?? peer.page?.url,
1368
+ title: page?.title ?? peer.page?.title,
1369
+ referrer: page?.referrer,
1370
+ userAgent: page?.userAgent ?? peer.page?.userAgent,
1371
+ initial: {
1372
+ viewport,
1373
+ storageKeys: storageData
1374
+ ? {
1375
+ local: storageData.local ? Object.keys(storageData.local).length : 0,
1376
+ session: storageData.session ? Object.keys(storageData.session).length : 0,
1377
+ cookie: storageData.cookie ? storageData.cookie.length : 0,
1378
+ }
1379
+ : undefined,
1380
+ storageTruncated: storageData?.truncated,
1381
+ },
1382
+ });
1383
+ this.store.appendEvent(storeSessionId, {
1384
+ ts, t: 'load', tab: tabId,
1385
+ projectId, buildId, visitorId,
1386
+ d: frame.payload,
1387
+ });
1388
+ } else if (frame.name === EVENT_NAME.RRWEB && tabId) {
1389
+ const parsed = rrwebChunkPayloadSchema.safeParse(frame.payload);
1390
+ if (parsed.success) {
1391
+ // v0.4.0: each session has one recording.jsonl — no tabId/loadId needed
1392
+ this.store.appendRecording(storeSessionId, parsed.data);
1393
+ this.notifyDashboard({
1394
+ kind: 'session.update',
1395
+ sessionId: storeSessionId,
1396
+ projectId,
1397
+ });
1398
+ this.store.appendEvent(storeSessionId, {
1399
+ ts: frame.ts ?? Date.now(),
1400
+ t: 'rrweb',
1401
+ tab: tabId,
1402
+ projectId,
1403
+ buildId,
1404
+ visitorId,
1405
+ d: {
1406
+ chunkId: parsed.data.chunkId,
1407
+ startTs: parsed.data.startTs,
1408
+ endTs: parsed.data.endTs,
1409
+ eventCount: parsed.data.eventCount,
1410
+ },
1411
+ });
1412
+ }
1413
+ } else {
1414
+ // app.log events from @harness-fe/log get the canonical
1415
+ // short type code 'app-log' (consistent with 'server-log',
1416
+ // 'server-err', 'server-action') rather than the raw frame
1417
+ // name 'app.log' with a dot.
1418
+ const eventType: string = frame.name === 'app.log' ? 'app-log' : frame.name as string;
1419
+ this.store.appendEvent(storeSessionId, {
1420
+ ts: frame.ts ?? Date.now(),
1421
+ t: eventType,
1422
+ tab: tabId,
1423
+ projectId,
1424
+ buildId,
1425
+ visitorId,
1426
+ d: frame.payload,
1427
+ });
1428
+ }
1429
+ const marker = deriveRecordingMarker(frame, tabId);
1430
+ if (marker) {
1431
+ this.store.appendEvent(storeSessionId, {
1432
+ ts: frame.ts ?? Date.now(),
1433
+ t: 'rrweb:marker',
1434
+ tab: tabId,
1435
+ projectId,
1436
+ buildId,
1437
+ d: marker,
1438
+ });
1439
+ }
1440
+ }
1441
+ }
1442
+ for (const listener of this.eventListeners) {
1443
+ try {
1444
+ listener(frame, peer);
1445
+ } catch {
1446
+ /* swallow listener errors */
1447
+ }
1448
+ }
1449
+ break;
1450
+ }
1451
+ case 'mcp.call': {
1452
+ void this.handleMcpCall(ws, frame);
1453
+ break;
1454
+ }
1455
+ case 'query': {
1456
+ void this.handleQuery(ws, connectionId, frame);
1457
+ break;
1458
+ }
1459
+ case 'hello.ack':
1460
+ case 'command':
1461
+ case 'mcp.return':
1462
+ case 'query.response':
1463
+ // Server doesn't expect to receive these; ignore.
1464
+ break;
1465
+ }
1466
+ }
1467
+
1468
+ /**
1469
+ * Runtime → daemon query dispatcher (0.5+). Whitelisted methods only.
1470
+ * Owner check: tasks.update / tasks.get / tasks.delete refuse to touch
1471
+ * tasks whose `visitorId` doesn't match the caller's `peer.visitorId`.
1472
+ */
1473
+ private async handleQuery(ws: WebSocket, connectionId: string, frame: QueryFrame): Promise<void> {
1474
+ const reply = (body: Omit<QueryResponseFrame, 'type' | 'id'>): void => {
1475
+ if (ws.readyState !== WebSocket.OPEN) return;
1476
+ const out: QueryResponseFrame = { type: 'query.response', id: frame.id, ...body };
1477
+ try { ws.send(JSON.stringify(out)); } catch { /* swallow */ }
1478
+ };
1479
+ const peer = this.router.getByConnectionId(connectionId);
1480
+ if (!peer) {
1481
+ reply({ ok: false, error: { code: 'unauthenticated', message: 'no peer for connection' } });
1482
+ return;
1483
+ }
1484
+ if (peer.role !== 'runtime-client' || !peer.visitorId) {
1485
+ reply({ ok: false, error: { code: 'forbidden', message: 'only runtime-client with visitorId may query' } });
1486
+ return;
1487
+ }
1488
+ if (!this.taskStore) {
1489
+ reply({ ok: false, error: { code: 'unavailable', message: 'no task store' } });
1490
+ return;
1491
+ }
1492
+ const projectId = peer.projectId;
1493
+ const callerVisitor = peer.visitorId;
1494
+
1495
+ try {
1496
+ switch (frame.method) {
1497
+ case 'tasks.mine': {
1498
+ const args = (frame.args ?? {}) as { status?: string; limit?: number };
1499
+ const all = this.taskStore.loadTasks(projectId);
1500
+ let mine = all.filter((t) => t.visitorId === callerVisitor);
1501
+ if (args.status) mine = mine.filter((t) => t.status === args.status);
1502
+ mine.sort((a, b) => b.createdAt - a.createdAt);
1503
+ if (args.limit) mine = mine.slice(0, args.limit);
1504
+ // Inline first attachment's base64 if ≤ 200 KB
1505
+ const MAX_INLINE = 200 * 1024; // base64 chars
1506
+ const withThumbs = mine.map((t) => {
1507
+ if (!t.attachments || t.attachments.length === 0) return t;
1508
+ const first = t.attachments[0];
1509
+ if (!first.path) return t;
1510
+ const b64 = this.readTaskAttachment(t.projectId, t.id, first.id);
1511
+ if (!b64 || b64.length > MAX_INLINE) return t;
1512
+ const inlined = { ...first, data: b64 };
1513
+ return { ...t, attachments: [inlined, ...t.attachments.slice(1)] };
1514
+ });
1515
+ reply({ ok: true, result: { tasks: withThumbs } });
1516
+ return;
1517
+ }
1518
+ case 'tasks.get': {
1519
+ const args = (frame.args ?? {}) as { id?: string };
1520
+ if (!args.id) {
1521
+ reply({ ok: false, error: { code: 'bad_request', message: 'id required' } });
1522
+ return;
1523
+ }
1524
+ const task = this.taskStore.loadTasks(projectId).find((t) => t.id === args.id);
1525
+ if (!task) {
1526
+ reply({ ok: false, error: { code: 'not_found', message: `no task ${args.id}` } });
1527
+ return;
1528
+ }
1529
+ if (task.visitorId !== callerVisitor) {
1530
+ reply({ ok: false, error: { code: 'forbidden', message: 'not your task' } });
1531
+ return;
1532
+ }
1533
+ reply({ ok: true, result: { task } });
1534
+ return;
1535
+ }
1536
+ case 'tasks.update': {
1537
+ const args = (frame.args ?? {}) as { id?: string; question?: string };
1538
+ if (!args.id || typeof args.question !== 'string') {
1539
+ reply({ ok: false, error: { code: 'bad_request', message: 'id + question required' } });
1540
+ return;
1541
+ }
1542
+ const tasks = this.taskStore.loadTasks(projectId);
1543
+ const idx = tasks.findIndex((t) => t.id === args.id);
1544
+ if (idx === -1) {
1545
+ reply({ ok: false, error: { code: 'not_found', message: `no task ${args.id}` } });
1546
+ return;
1547
+ }
1548
+ if (tasks[idx].visitorId !== callerVisitor) {
1549
+ reply({ ok: false, error: { code: 'forbidden', message: 'not your task' } });
1550
+ return;
1551
+ }
1552
+ tasks[idx] = { ...tasks[idx], question: args.question.trim(), updatedAt: Date.now() };
1553
+ this.taskStore.saveTasks(projectId, tasks);
1554
+ reply({ ok: true, result: { task: tasks[idx] } });
1555
+ return;
1556
+ }
1557
+ case 'tasks.delete': {
1558
+ const args = (frame.args ?? {}) as { id?: string };
1559
+ if (!args.id) {
1560
+ reply({ ok: false, error: { code: 'bad_request', message: 'id required' } });
1561
+ return;
1562
+ }
1563
+ const tasks = this.taskStore.loadTasks(projectId);
1564
+ const target = tasks.find((t) => t.id === args.id);
1565
+ if (!target) {
1566
+ reply({ ok: false, error: { code: 'not_found', message: `no task ${args.id}` } });
1567
+ return;
1568
+ }
1569
+ if (target.visitorId !== callerVisitor) {
1570
+ reply({ ok: false, error: { code: 'forbidden', message: 'not your task' } });
1571
+ return;
1572
+ }
1573
+ const remaining = tasks.filter((t) => t.id !== args.id);
1574
+ this.taskStore.saveTasks(projectId, remaining);
1575
+ // Also remove from in-memory queue so MCP tasks.pending stays in sync.
1576
+ this.tasks.delete(args.id);
1577
+ reply({ ok: true, result: { deleted: args.id } });
1578
+ return;
1579
+ }
1580
+ default:
1581
+ reply({ ok: false, error: { code: 'unknown_method', message: `unknown query method` } });
1582
+ }
1583
+ } catch (err) {
1584
+ reply({
1585
+ ok: false,
1586
+ error: { code: 'internal', message: err instanceof Error ? err.message : String(err) },
1587
+ });
1588
+ }
1589
+ }
1590
+
1591
+ private async handleMcpCall(ws: WebSocket, frame: McpCallFrame): Promise<void> {
1592
+ const reply = (payload: Omit<McpReturnFrame, 'type' | 'id'>): void => {
1593
+ if (ws.readyState !== WebSocket.OPEN) return;
1594
+ const out: McpReturnFrame = { type: 'mcp.return', id: frame.id, ...payload };
1595
+ try {
1596
+ ws.send(JSON.stringify(out));
1597
+ } catch {
1598
+ /* swallow */
1599
+ }
1600
+ };
1601
+ try {
1602
+ const result = await this.invokeMcpMethod(frame.method, frame.args);
1603
+ reply({ ok: true, result });
1604
+ } catch (err) {
1605
+ const message = err instanceof Error ? err.message : String(err);
1606
+ reply({ ok: false, error: { message } });
1607
+ }
1608
+ }
1609
+
1610
+ private async invokeMcpMethod(method: McpCallFrame['method'], args: unknown): Promise<unknown> {
1611
+ switch (method) {
1612
+ case 'sendCommand': {
1613
+ const a = (args ?? {}) as {
1614
+ command: string;
1615
+ args?: unknown;
1616
+ opts?: SendCommandOptions;
1617
+ };
1618
+ return this.sendCommand(a.command, a.args, a.opts);
1619
+ }
1620
+ case 'listTabs':
1621
+ return this.listTabs();
1622
+ case 'listTasks': {
1623
+ const a = (args ?? {}) as { status?: TaskStatus | 'all'; limit?: number };
1624
+ return this.listTasks(a);
1625
+ }
1626
+ case 'claimTask': {
1627
+ const a = args as { id: string };
1628
+ return this.claimTask(a.id);
1629
+ }
1630
+ case 'resolveTask': {
1631
+ const a = args as { id: string; note?: string };
1632
+ return this.resolveTask(a.id, a.note);
1633
+ }
1634
+ // ─── Store methods (proxied from follower) ─────────────────────
1635
+ case 'storeListProjects': {
1636
+ if (!this.store) throw new Error('bridge: store is not enabled');
1637
+ return this.store.listProjects();
1638
+ }
1639
+ case 'storeListSessions': {
1640
+ if (!this.store) throw new Error('bridge: store is not enabled');
1641
+ const a = args as { projectId?: string; tabId?: string; buildId?: string; limit?: number };
1642
+ return this.store.listSessions({ projectId: a.projectId, tabId: a.tabId, buildId: a.buildId, limit: a.limit });
1643
+ }
1644
+ case 'storeSummary': {
1645
+ if (!this.store) throw new Error('bridge: store is not enabled');
1646
+ const a = args as { sessionId: string };
1647
+ return this.store.summary(a.sessionId);
1648
+ }
1649
+ case 'storeTail': {
1650
+ if (!this.store) throw new Error('bridge: store is not enabled');
1651
+ const a = args as {
1652
+ sessionId: string;
1653
+ opts?: import('./store/index.js').TailOptions;
1654
+ };
1655
+ return this.store.tail(a.sessionId, a.opts);
1656
+ }
1657
+ case 'storeSearch': {
1658
+ if (!this.store) throw new Error('bridge: store is not enabled');
1659
+ const a = args as {
1660
+ sessionId: string;
1661
+ query: string;
1662
+ opts?: import('./store/index.js').SearchOptions;
1663
+ };
1664
+ return this.store.search(a.sessionId, a.query, a.opts);
1665
+ }
1666
+ case 'storeRecordingsList': {
1667
+ if (!this.store) throw new Error('bridge: store is not enabled');
1668
+ const a = args as { sessionId: string };
1669
+ return this.store.listRecordings(a.sessionId);
1670
+ }
1671
+ case 'storeRecordingsSlice': {
1672
+ if (!this.store) throw new Error('bridge: store is not enabled');
1673
+ const a = args as { sessionId: string; since: number; until: number };
1674
+ return this.store.sliceRecordings(a.sessionId, a.since, a.until);
1675
+ }
1676
+ case 'storeReplayCreate': {
1677
+ if (!this.store) throw new Error('bridge: store is not enabled');
1678
+ const { createReplayExport } = await import('./replayCreate.js');
1679
+ return createReplayExport(this.store, this.getViewerBaseUrl(), args as Parameters<typeof createReplayExport>[2]);
1680
+ }
1681
+ case 'storePurge': {
1682
+ if (!this.store) throw new Error('bridge: store is not enabled');
1683
+ const a = (args ?? {}) as import('./store/index.js').RetentionPolicy;
1684
+ return this.store.purge(a);
1685
+ }
1686
+ // ─── Memory methods (proxied from follower) ────────────────────
1687
+ case 'memorySet': {
1688
+ const a = args as { projectId: string; key: string; value: string };
1689
+ return this.memoryStore.set(a.projectId, a.key, a.value);
1690
+ }
1691
+ case 'memoryGet': {
1692
+ const a = args as { projectId: string; key: string };
1693
+ return this.memoryStore.get(a.projectId, a.key);
1694
+ }
1695
+ case 'memoryList': {
1696
+ const a = args as { projectId: string };
1697
+ return this.memoryStore.list(a.projectId);
1698
+ }
1699
+ case 'memoryDelete': {
1700
+ const a = args as { projectId: string; key: string };
1701
+ return this.memoryStore.delete(a.projectId, a.key);
1702
+ }
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ function deriveRecordingMarker(frame: EventFrame, tabId?: string): Record<string, unknown> | undefined {
1708
+ if (!tabId) return undefined;
1709
+
1710
+ if (frame.name === 'error') {
1711
+ const payload = frame.payload as { message?: unknown; source?: unknown } | undefined;
1712
+ return {
1713
+ markerId: `rrm_${frame.id}`,
1714
+ kind: 'error',
1715
+ ts: frame.ts,
1716
+ tabId,
1717
+ label: typeof payload?.message === 'string' ? payload.message : 'Runtime error',
1718
+ relatedEventType: 'error',
1719
+ source: typeof payload?.source === 'string' ? payload.source : undefined,
1720
+ };
1721
+ }
1722
+
1723
+ if (frame.name === 'network') {
1724
+ const payload = frame.payload as { status?: unknown; method?: unknown; url?: unknown } | undefined;
1725
+ const status = typeof payload?.status === 'number' ? payload.status : undefined;
1726
+ if (status === undefined || (status > 0 && status < 400)) return undefined;
1727
+ const method = typeof payload?.method === 'string' ? payload.method : 'REQUEST';
1728
+ const url = typeof payload?.url === 'string' ? payload.url : 'unknown URL';
1729
+ return {
1730
+ markerId: `rrm_${frame.id}`,
1731
+ kind: 'network',
1732
+ ts: frame.ts,
1733
+ tabId,
1734
+ label: `${method} ${url} -> ${status ?? 'ERR'}`,
1735
+ relatedEventType: 'network',
1736
+ status,
1737
+ };
1738
+ }
1739
+
1740
+ if (frame.name === 'console') {
1741
+ const payload = frame.payload as { level?: unknown; args?: unknown } | undefined;
1742
+ if (payload?.level !== 'error') return undefined;
1743
+ const firstArg = Array.isArray(payload.args) ? payload.args[0] : undefined;
1744
+ return {
1745
+ markerId: `rrm_${frame.id}`,
1746
+ kind: 'console',
1747
+ ts: frame.ts,
1748
+ tabId,
1749
+ label: typeof firstArg === 'string' ? firstArg : 'console.error',
1750
+ relatedEventType: 'console',
1751
+ };
1752
+ }
1753
+
1754
+ if (frame.name === EVENT_NAME.TASK_SUBMIT) {
1755
+ const parsed = taskSubmitPayloadSchema.safeParse(frame.payload);
1756
+ if (!parsed.success) return undefined;
1757
+ return {
1758
+ markerId: `rrm_${frame.id}`,
1759
+ kind: 'task',
1760
+ ts: frame.ts,
1761
+ tabId,
1762
+ label: parsed.data.question,
1763
+ relatedEventType: EVENT_NAME.TASK_SUBMIT,
1764
+ };
1765
+ }
1766
+
1767
+ return undefined;
1768
+ }
1769
+
1770
+ /** Extract the pathname portion of a request URL (without query string). */
1771
+ function pathnameOf(url: string): string {
1772
+ const qi = url.indexOf('?');
1773
+ return qi < 0 ? url : url.slice(0, qi);
1774
+ }
1775
+
1776
+ /** Return the first non-internal IPv4 address from the OS interfaces. */
1777
+ function firstNonInternalIpv4(): string | undefined {
1778
+ const ifaces = networkInterfaces();
1779
+ for (const list of Object.values(ifaces)) {
1780
+ if (!list) continue;
1781
+ for (const info of list) {
1782
+ if (info.family === 'IPv4' && !info.internal) return info.address;
1783
+ }
1784
+ }
1785
+ return undefined;
1786
+ }
1787
+
1788
+ /**
1789
+ * Strip large binary payloads (e.g. screenshot dataUrls) from command results
1790
+ * before persisting to the store, to avoid bloating timeline files.
1791
+ */
1792
+ function stripLargePayloads(value: unknown): unknown {
1793
+ if (typeof value === 'string' && value.startsWith('data:') && value.length > 1024) {
1794
+ return '[large data url omitted]';
1795
+ }
1796
+ if (value !== null && typeof value === 'object') {
1797
+ const result: Record<string, unknown> = {};
1798
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
1799
+ result[k] = stripLargePayloads(v);
1800
+ }
1801
+ return result;
1802
+ }
1803
+ return value;
1804
+ }