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