@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.3

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