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