@harness-fe/mcp-server 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
package/dist/cli.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry — boots WS bridge + MCP server (stdio or HTTP).
4
+ *
5
+ * Usage:
6
+ * npx @harness-fe/mcp-server # 127.0.0.1, stdio MCP
7
+ * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto
8
+ * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto --mcp-transport http
9
+ *
10
+ * Leader / follower:
11
+ * - first process bound to the WS port = leader (in-process Bridge)
12
+ * - subsequent processes (EADDRINUSE) become followers that attach to
13
+ * the leader via the `mcp.call` control channel using `RemoteBridge`.
14
+ *
15
+ * This lets multiple Claude Code windows share a single dev-bridge daemon
16
+ * (and thus the same browser / vite-plugin connections).
17
+ */
18
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry — boots WS bridge + MCP server (stdio or HTTP).
4
+ *
5
+ * Usage:
6
+ * npx @harness-fe/mcp-server # 127.0.0.1, stdio MCP
7
+ * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto
8
+ * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto --mcp-transport http
9
+ *
10
+ * Leader / follower:
11
+ * - first process bound to the WS port = leader (in-process Bridge)
12
+ * - subsequent processes (EADDRINUSE) become followers that attach to
13
+ * the leader via the `mcp.call` control channel using `RemoteBridge`.
14
+ *
15
+ * This lets multiple Claude Code windows share a single dev-bridge daemon
16
+ * (and thus the same browser / vite-plugin connections).
17
+ */
18
+ import { randomBytes } from 'node:crypto';
19
+ import { DEFAULT_HOST, DEFAULT_WS_PORT, buildHttpUrl, isLoopbackHost, parseWsUrl, } from '@harness-fe/protocol';
20
+ import { Bridge, defaultDataDir } from './bridge.js';
21
+ import { RemoteBridge } from './remoteBridge.js';
22
+ import { startMcpStdioServer } from './mcp.js';
23
+ import { startMcpHttpServer } from './mcpHttp.js';
24
+ function printHelpAndExit() {
25
+ const help = `harness-fe — frontend harness MCP daemon
26
+
27
+ Usage:
28
+ harness-fe [options]
29
+
30
+ Options:
31
+ --host <addr> Bind address. Default 127.0.0.1.
32
+ Use 0.0.0.0 to accept LAN connections (requires --token).
33
+ --port <number> TCP port. Default ${DEFAULT_WS_PORT}.
34
+ --token <value|auto> Token required for HTTP/WS auth. Pass "auto" to generate one.
35
+ Required when --host is not loopback.
36
+ --mcp-transport <kind> stdio (default) or http. http mounts /mcp on the bridge.
37
+ --mcp-path <path> URL path for the MCP HTTP endpoint. Default /mcp.
38
+ --public-host <addr> Override the host printed in outbound URLs. Useful when
39
+ binding 0.0.0.0 and the auto-detected LAN IP is wrong.
40
+ -h, --help Show this help.
41
+
42
+ Environment:
43
+ HARNESS_FE_HOST Same as --host
44
+ HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
45
+ HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
46
+ HARNESS_FE_MCP_PATH Same as --mcp-path
47
+ HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
48
+ `;
49
+ process.stderr.write(help);
50
+ process.exit(0);
51
+ }
52
+ function parseArgs(argv) {
53
+ const args = argv.slice(2);
54
+ let host;
55
+ let port;
56
+ let token;
57
+ let mcpTransport;
58
+ let mcpPath;
59
+ let publicHost;
60
+ for (let i = 0; i < args.length; i++) {
61
+ const a = args[i];
62
+ const next = () => {
63
+ const v = args[++i];
64
+ if (v == null) {
65
+ process.stderr.write(`harness-fe: missing value for ${a}\n`);
66
+ process.exit(2);
67
+ }
68
+ return v;
69
+ };
70
+ switch (a) {
71
+ case '-h':
72
+ case '--help':
73
+ printHelpAndExit();
74
+ break;
75
+ case '--host':
76
+ host = next();
77
+ break;
78
+ case '--port':
79
+ port = Number(next());
80
+ if (!Number.isFinite(port) || port <= 0) {
81
+ process.stderr.write(`harness-fe: invalid --port\n`);
82
+ process.exit(2);
83
+ }
84
+ break;
85
+ case '--token':
86
+ token = next();
87
+ break;
88
+ case '--mcp-transport': {
89
+ const v = next();
90
+ if (v !== 'stdio' && v !== 'http') {
91
+ process.stderr.write(`harness-fe: invalid --mcp-transport (stdio|http)\n`);
92
+ process.exit(2);
93
+ }
94
+ mcpTransport = v;
95
+ break;
96
+ }
97
+ case '--mcp-path':
98
+ mcpPath = next();
99
+ break;
100
+ case '--public-host':
101
+ publicHost = next();
102
+ break;
103
+ default:
104
+ process.stderr.write(`harness-fe: unknown argument ${a}\n`);
105
+ process.exit(2);
106
+ }
107
+ }
108
+ // Apply env fallbacks, then URL fallback for host/port.
109
+ const envUrl = process.env.HARNESS_FE_URL;
110
+ let envHost;
111
+ let envPort;
112
+ if (envUrl) {
113
+ try {
114
+ const parsed = parseWsUrl(envUrl);
115
+ envHost = parsed.host;
116
+ envPort = parsed.port;
117
+ }
118
+ catch {
119
+ // ignore — fall back to defaults below
120
+ }
121
+ }
122
+ const finalHost = host ?? process.env.HARNESS_FE_HOST ?? envHost ?? DEFAULT_HOST;
123
+ const finalPort = port ?? envPort ?? DEFAULT_WS_PORT;
124
+ let finalToken = token ?? process.env.HARNESS_FE_TOKEN;
125
+ if (finalToken === 'auto') {
126
+ finalToken = randomBytes(24).toString('base64url');
127
+ }
128
+ if (finalToken === '')
129
+ finalToken = undefined;
130
+ const finalTransport = (mcpTransport ?? process.env.HARNESS_FE_MCP_TRANSPORT) ??
131
+ 'stdio';
132
+ if (finalTransport !== 'stdio' && finalTransport !== 'http') {
133
+ process.stderr.write(`harness-fe: invalid mcp transport "${finalTransport}"\n`);
134
+ process.exit(2);
135
+ }
136
+ const finalMcpPath = mcpPath ?? process.env.HARNESS_FE_MCP_PATH ?? '/mcp';
137
+ // Data dir defaults to port-keyed path. Explicit env override wins.
138
+ const finalDataDir = process.env.HARNESS_FE_DATA_DIR ?? defaultDataDir(finalPort);
139
+ const finalLabel = process.env.HARNESS_FE_LABEL || undefined;
140
+ return {
141
+ host: finalHost,
142
+ port: finalPort,
143
+ token: finalToken,
144
+ mcpTransport: finalTransport,
145
+ mcpPath: finalMcpPath,
146
+ publicHost,
147
+ label: finalLabel,
148
+ dataDir: finalDataDir,
149
+ };
150
+ }
151
+ function validate(_cfg) {
152
+ // Token requirement is left entirely to the operator. We don't refuse
153
+ // a non-loopback bind without a token — that's their call, not ours.
154
+ // Warnings are emitted from the banner so the operator sees them; CI /
155
+ // automation that pipes stderr can suppress as needed.
156
+ }
157
+ function printBanner(cfg, role, viewerUrl) {
158
+ const lines = [];
159
+ const labelSuffix = cfg.label ? ` (${cfg.label})` : '';
160
+ lines.push(`[harness-fe] ${role}: WS bridge listening on ws://${cfg.host}:${cfg.port}${labelSuffix}`);
161
+ if (role === 'leader') {
162
+ // Surface the data dir so the user can see exactly where this
163
+ // daemon's sessions / recordings / projects are landing.
164
+ lines.push(`[harness-fe] data: ${cfg.dataDir}`);
165
+ }
166
+ const isLan = !isLoopbackHost(cfg.host);
167
+ if (isLan) {
168
+ lines.push(`[harness-fe] WARNING: bound to non-loopback host ${cfg.host}.`);
169
+ if (cfg.token) {
170
+ lines.push(`[harness-fe] anyone reaching this host:port with the token can read console / network / recordings.`);
171
+ }
172
+ else {
173
+ lines.push(`[harness-fe] no token set — anyone on this network can read console / network / recordings.`);
174
+ lines.push(`[harness-fe] add --token auto (or HARNESS_FE_TOKEN=…) to enable auth.`);
175
+ }
176
+ }
177
+ // Always print the dashboard URL. The token (when present) is folded
178
+ // into the query so the first hit hands it off to a cookie; without a
179
+ // token, auth is disabled and the URL works on its own.
180
+ const host = cfg.publicHost ?? viewerHost(viewerUrl) ?? cfg.host;
181
+ const dashboard = buildHttpUrl({ host, port: cfg.port, token: cfg.token });
182
+ lines.push(`[harness-fe] dashboard: ${dashboard}`);
183
+ if (cfg.mcpTransport === 'http') {
184
+ const mcp = buildHttpUrl({ host, port: cfg.port, token: cfg.token, path: cfg.mcpPath });
185
+ lines.push(`[harness-fe] mcp http: ${mcp}`);
186
+ if (cfg.token) {
187
+ const mcpNoTok = buildHttpUrl({ host, port: cfg.port, path: cfg.mcpPath });
188
+ lines.push(`[harness-fe] agent config: { "url": "${mcpNoTok}", "headers": { "Authorization": "Bearer ${cfg.token}" } }`);
189
+ }
190
+ else {
191
+ lines.push(`[harness-fe] agent config: { "url": "${mcp}" } (no auth — token unset)`);
192
+ }
193
+ }
194
+ if (cfg.token) {
195
+ lines.push(`[harness-fe] token: ${cfg.token}`);
196
+ }
197
+ process.stderr.write(lines.join('\n') + '\n');
198
+ }
199
+ function viewerHost(viewerUrl) {
200
+ if (!viewerUrl)
201
+ return undefined;
202
+ try {
203
+ return new URL(viewerUrl).hostname;
204
+ }
205
+ catch {
206
+ return undefined;
207
+ }
208
+ }
209
+ async function main() {
210
+ const cfg = parseArgs(process.argv);
211
+ validate(cfg);
212
+ const { active, shutdown, role } = await startBridgeOrAttach(cfg);
213
+ printBanner(cfg, role, active.getViewerBaseUrl());
214
+ let mcpShutdown;
215
+ if (cfg.mcpTransport === 'stdio') {
216
+ await startMcpStdioServer(active);
217
+ process.stderr.write('[harness-fe] MCP stdio server connected\n');
218
+ }
219
+ else {
220
+ if (role === 'follower') {
221
+ process.stderr.write('[harness-fe] --mcp-transport=http is only supported on the leader. ' +
222
+ 'Another daemon already holds the port; stop it first.\n');
223
+ await shutdown();
224
+ process.exit(2);
225
+ }
226
+ const handle = await startMcpHttpServer(active, { path: cfg.mcpPath });
227
+ process.stderr.write(`[harness-fe] MCP http server mounted at ${handle.path}\n`);
228
+ mcpShutdown = () => handle.close();
229
+ }
230
+ const onSignal = async () => {
231
+ process.stderr.write('[harness-fe] shutting down\n');
232
+ if (mcpShutdown) {
233
+ try {
234
+ await mcpShutdown();
235
+ }
236
+ catch { /* swallow */ }
237
+ }
238
+ await shutdown();
239
+ process.exit(0);
240
+ };
241
+ process.on('SIGINT', onSignal);
242
+ process.on('SIGTERM', onSignal);
243
+ }
244
+ async function startBridgeOrAttach(cfg) {
245
+ const bridge = new Bridge({
246
+ port: cfg.port,
247
+ host: cfg.host,
248
+ dataDir: cfg.dataDir,
249
+ label: cfg.label,
250
+ auth: cfg.token ? { token: cfg.token } : undefined,
251
+ publicHost: cfg.publicHost,
252
+ });
253
+ try {
254
+ await bridge.start();
255
+ return {
256
+ active: bridge,
257
+ shutdown: () => bridge.stop(),
258
+ role: 'leader',
259
+ };
260
+ }
261
+ catch (err) {
262
+ if (err?.code !== 'EADDRINUSE')
263
+ throw err;
264
+ }
265
+ // Port already taken — attach as follower.
266
+ const remote = new RemoteBridge({ port: cfg.port, host: cfg.host, token: cfg.token });
267
+ await remote.connect();
268
+ return {
269
+ active: remote,
270
+ shutdown: () => remote.stop(),
271
+ role: 'follower',
272
+ };
273
+ }
274
+ main().catch((err) => {
275
+ process.stderr.write(`[harness-fe] fatal: ${err?.stack ?? err}\n`);
276
+ process.exit(1);
277
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `createDaemon` — programmatic entry point for embedding the harness-fe
3
+ * daemon inside another Node.js process. Wraps `Bridge` construction +
4
+ * `startMcpHttpServer` so a host application can boot the daemon with
5
+ * `import { createDaemon } from '@harness-fe/mcp-server'` instead of
6
+ * spawning the CLI as a sidecar.
7
+ *
8
+ * Scope (v1):
9
+ * - Factory mode only — the daemon owns its own HTTP listener on a
10
+ * caller-chosen port. Attaching to a host's existing `http.Server`
11
+ * (middleware / handle modes) is a follow-up that requires Bridge
12
+ * surgery; tracked separately.
13
+ * - Resumable SSE — pass through `eventStore` (defaults to
14
+ * `MemoryEventStore`; `null` disables resumability).
15
+ * - Custom auth — pass `authorize: (req) => boolean` to replace the
16
+ * built-in token check. The CLI translates `--token` into one of
17
+ * these so the daemon itself has exactly one auth pipeline.
18
+ *
19
+ * Standalone CLI use is unchanged: `cli.ts` is a thin caller of this
20
+ * factory. Anything the CLI does that's specific to a developer's
21
+ * machine (port-keyed `defaultDataDir`, `HARNESS_FE_*` env, leader /
22
+ * follower attachment, banner, signal handlers) stays in `cli.ts` —
23
+ * not pushed into the factory.
24
+ */
25
+ import type { IncomingMessage } from 'node:http';
26
+ import { Bridge } from './bridge.js';
27
+ import type { EventStore, IStore } from './store/types.js';
28
+ import type { ITaskStore, IMemoryStore } from './store/types.js';
29
+ export interface DaemonOptions {
30
+ /** TCP port to listen on. Default `DEFAULT_WS_PORT` (see protocol). */
31
+ port?: number;
32
+ /** Bind address. Default `127.0.0.1` (loopback only). */
33
+ host?: string;
34
+ /**
35
+ * Override the host used when building outbound URLs (dashboard, replay
36
+ * viewer). Useful when binding `0.0.0.0` and the auto-detected LAN IP is
37
+ * wrong, or when the host application sits behind a reverse proxy.
38
+ */
39
+ publicHost?: string;
40
+ /**
41
+ * Custom request authorizer applied to every HTTP request and WS upgrade.
42
+ * Return `false` to reject. When supplied, the built-in token check is
43
+ * skipped — there is exactly one auth pipeline. Synchronous because the
44
+ * WS upgrade handshake completes inline; async auth should be cached in
45
+ * a cookie by the host's own middleware and read back here.
46
+ */
47
+ authorize?: (req: IncomingMessage) => boolean;
48
+ /**
49
+ * IStore implementation. Omit for the default JSONL store at `dataDir`.
50
+ * Pass `null` to disable session/event persistence entirely.
51
+ */
52
+ store?: IStore | null;
53
+ /** Task store. Omit for default JsonTaskStore. `null` disables. */
54
+ taskStore?: ITaskStore | null;
55
+ /** Memory store. Omit for default JsonMemoryStore. `null` disables. */
56
+ memoryStore?: IMemoryStore | null;
57
+ /**
58
+ * EventStore backing resumable SSE streams. Omit for the in-memory
59
+ * default (1000 events / 5 minutes / 50 MiB). `null` disables
60
+ * resumability — reconnects after a drop start at the live tail.
61
+ */
62
+ eventStore?: EventStore | null;
63
+ /**
64
+ * Root data directory for the default stores. Omit to let `Bridge`
65
+ * compute a port-keyed default; pass explicitly when the host wants
66
+ * everything at a known location.
67
+ */
68
+ dataDir?: string;
69
+ /** Cosmetic friendly name; surfaces in the dashboard banner. */
70
+ label?: string;
71
+ /** URL path the MCP HTTP transport mounts on. Default `/mcp`. */
72
+ mcpPath?: string;
73
+ /**
74
+ * Whether to use stateful MCP sessions (sessionId in headers) or
75
+ * stateless one-shot requests. Default `true` (stateful — matches what
76
+ * Claude Code, Cursor, and the MCP spec default expect).
77
+ */
78
+ mcpStateful?: boolean;
79
+ }
80
+ export interface DaemonHandle {
81
+ /**
82
+ * Start the bridge and mount the MCP HTTP transport. Throws if the port
83
+ * is already in use — in embedded mode there's no leader/follower
84
+ * fallback; the host decides how to handle the conflict.
85
+ */
86
+ start(): Promise<void>;
87
+ /** Stop the MCP transport and bridge. Safe to call multiple times. */
88
+ stop(): Promise<void>;
89
+ /** Bound TCP port (only meaningful after `start`). */
90
+ getBoundPort(): number | undefined;
91
+ /** Outbound base URL for dashboard / replay viewer links. */
92
+ getViewerBaseUrl(): string | undefined;
93
+ /** Path the MCP HTTP transport is mounted on. */
94
+ readonly mcpPath: string;
95
+ /** Underlying bridge — escape hatch for tests and advanced wiring. */
96
+ readonly bridge: Bridge;
97
+ }
98
+ export declare function createDaemon(opts?: DaemonOptions): DaemonHandle;
package/dist/daemon.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `createDaemon` — programmatic entry point for embedding the harness-fe
3
+ * daemon inside another Node.js process. Wraps `Bridge` construction +
4
+ * `startMcpHttpServer` so a host application can boot the daemon with
5
+ * `import { createDaemon } from '@harness-fe/mcp-server'` instead of
6
+ * spawning the CLI as a sidecar.
7
+ *
8
+ * Scope (v1):
9
+ * - Factory mode only — the daemon owns its own HTTP listener on a
10
+ * caller-chosen port. Attaching to a host's existing `http.Server`
11
+ * (middleware / handle modes) is a follow-up that requires Bridge
12
+ * surgery; tracked separately.
13
+ * - Resumable SSE — pass through `eventStore` (defaults to
14
+ * `MemoryEventStore`; `null` disables resumability).
15
+ * - Custom auth — pass `authorize: (req) => boolean` to replace the
16
+ * built-in token check. The CLI translates `--token` into one of
17
+ * these so the daemon itself has exactly one auth pipeline.
18
+ *
19
+ * Standalone CLI use is unchanged: `cli.ts` is a thin caller of this
20
+ * factory. Anything the CLI does that's specific to a developer's
21
+ * machine (port-keyed `defaultDataDir`, `HARNESS_FE_*` env, leader /
22
+ * follower attachment, banner, signal handlers) stays in `cli.ts` —
23
+ * not pushed into the factory.
24
+ */
25
+ import { Bridge } from './bridge.js';
26
+ import { startMcpHttpServer } from './mcpHttp.js';
27
+ export function createDaemon(opts = {}) {
28
+ const mcpPath = opts.mcpPath ?? '/mcp';
29
+ const bridge = new Bridge({
30
+ port: opts.port,
31
+ host: opts.host,
32
+ publicHost: opts.publicHost,
33
+ store: opts.store,
34
+ taskStore: opts.taskStore,
35
+ memoryStore: opts.memoryStore,
36
+ dataDir: opts.dataDir,
37
+ label: opts.label,
38
+ auth: opts.authorize ? { authorize: opts.authorize } : undefined,
39
+ });
40
+ let mcpHandle;
41
+ let started = false;
42
+ let stopped = false;
43
+ return {
44
+ mcpPath,
45
+ bridge,
46
+ async start() {
47
+ if (started)
48
+ return;
49
+ started = true;
50
+ await bridge.start();
51
+ // Forward eventStore as-is so `null` opts out of resumability and
52
+ // `undefined` falls through to the default MemoryEventStore.
53
+ mcpHandle = await startMcpHttpServer(bridge, {
54
+ path: mcpPath,
55
+ stateful: opts.mcpStateful,
56
+ eventStore: opts.eventStore,
57
+ });
58
+ },
59
+ async stop() {
60
+ if (stopped)
61
+ return;
62
+ stopped = true;
63
+ if (mcpHandle) {
64
+ try {
65
+ await mcpHandle.close();
66
+ }
67
+ catch {
68
+ /* swallow — bridge.stop will be called anyway */
69
+ }
70
+ }
71
+ await bridge.stop();
72
+ },
73
+ getBoundPort() {
74
+ return bridge.getBoundPort();
75
+ },
76
+ getViewerBaseUrl() {
77
+ return bridge.getViewerBaseUrl();
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * JSON API surface consumed by `@harness-fe/dashboard-ui` (the React SPA).
3
+ *
4
+ * The shape mirrors what the legacy server-rendered dashboard.ts displayed,
5
+ * but as JSON so the SPA can render it with proper components and live
6
+ * updates. Routes live under `/api/*` to keep them clearly separated from
7
+ * SPA assets (`/dashboard/*`) and the replay viewer (`/replay/*`).
8
+ *
9
+ * Reuses `createReplayExport` from replayCreate.ts for the replay POST —
10
+ * same logic the legacy dashboard's form submission ran through, just
11
+ * returns JSON instead of redirecting.
12
+ *
13
+ * Auth is already enforced by `isAuthorized` in bridge.ts before this
14
+ * handler runs, so we never need to check tokens here.
15
+ */
16
+ import type { IncomingMessage, ServerResponse } from 'node:http';
17
+ import type { IStore, ProjectMeta, RecordingChunkSummary, ReplayExportMeta, SessionMeta, SessionSummary, StoreEvent } from './store/types.js';
18
+ export interface ProjectListEntry {
19
+ project: ProjectMeta;
20
+ recentSessions: SessionMeta[];
21
+ }
22
+ export interface SessionDetailResponse {
23
+ session: SessionMeta;
24
+ summary: SessionSummary;
25
+ chunks: RecordingChunkSummary[];
26
+ timeline: StoreEvent[];
27
+ exports: ReplayExportMeta[];
28
+ }
29
+ export interface ReplayCreateBody {
30
+ tabId?: string;
31
+ ts?: number;
32
+ windowMs?: number;
33
+ since?: number;
34
+ until?: number;
35
+ label?: string;
36
+ }
37
+ export declare function createDashboardApiHandler(store: IStore, getBaseUrl: () => string | undefined, onExportCreated?: (input: {
38
+ sessionId: string;
39
+ projectId?: string;
40
+ }) => void): (req: IncomingMessage, res: ServerResponse) => boolean | Promise<boolean>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * JSON API surface consumed by `@harness-fe/dashboard-ui` (the React SPA).
3
+ *
4
+ * The shape mirrors what the legacy server-rendered dashboard.ts displayed,
5
+ * but as JSON so the SPA can render it with proper components and live
6
+ * updates. Routes live under `/api/*` to keep them clearly separated from
7
+ * SPA assets (`/dashboard/*`) and the replay viewer (`/replay/*`).
8
+ *
9
+ * Reuses `createReplayExport` from replayCreate.ts for the replay POST —
10
+ * same logic the legacy dashboard's form submission ran through, just
11
+ * returns JSON instead of redirecting.
12
+ *
13
+ * Auth is already enforced by `isAuthorized` in bridge.ts before this
14
+ * handler runs, so we never need to check tokens here.
15
+ */
16
+ import { createReplayExport } from './replayCreate.js';
17
+ const TIMELINE_DEFAULT_TAIL = 100;
18
+ const SESSIONS_PER_PROJECT = 10;
19
+ export function createDashboardApiHandler(store, getBaseUrl, onExportCreated) {
20
+ return async (req, res) => {
21
+ if (!req.url)
22
+ return false;
23
+ const url = new URL(req.url, 'http://localhost');
24
+ const path = url.pathname;
25
+ if (!path.startsWith('/api/'))
26
+ return false;
27
+ const method = req.method ?? 'GET';
28
+ // GET /api/projects
29
+ if (method === 'GET' && path === '/api/projects') {
30
+ const projects = store.listProjects();
31
+ const entries = projects.map((project) => {
32
+ const recentSessions = store.listSessions({
33
+ projectId: project.id,
34
+ limit: SESSIONS_PER_PROJECT,
35
+ });
36
+ return { project, recentSessions };
37
+ });
38
+ sendJson(res, 200, { projects: entries });
39
+ return true;
40
+ }
41
+ // GET /api/sessions?projectId=&tabId=&buildId=&limit=
42
+ if (method === 'GET' && path === '/api/sessions') {
43
+ const sessions = store.listSessions({
44
+ projectId: url.searchParams.get('projectId') ?? undefined,
45
+ tabId: url.searchParams.get('tabId') ?? undefined,
46
+ buildId: url.searchParams.get('buildId') ?? undefined,
47
+ limit: parseIntOr(url.searchParams.get('limit'), 50),
48
+ });
49
+ sendJson(res, 200, { sessions });
50
+ return true;
51
+ }
52
+ // /api/sessions/:id and /api/sessions/:id/replay
53
+ const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)(\/replay)?$/);
54
+ if (sessionMatch) {
55
+ const sessionId = decodeURIComponent(sessionMatch[1]);
56
+ const isReplay = !!sessionMatch[2];
57
+ if (isReplay && method === 'POST') {
58
+ let body;
59
+ try {
60
+ body = await readJsonBody(req);
61
+ }
62
+ catch (err) {
63
+ sendJson(res, 400, { error: `invalid JSON body: ${err.message}` });
64
+ return true;
65
+ }
66
+ const result = createReplayExport(store, getBaseUrl(), {
67
+ sessionId,
68
+ tabId: body.tabId,
69
+ ts: body.ts,
70
+ windowMs: body.windowMs,
71
+ since: body.since,
72
+ until: body.until,
73
+ label: body.label,
74
+ });
75
+ const status = result.error ? 400 : 200;
76
+ if (!result.error && result.exportId && onExportCreated) {
77
+ // Find the session's project so subscribers can filter.
78
+ const projectId = store.getSession(sessionId)?.participants[0]?.projectId;
79
+ onExportCreated({ sessionId, projectId });
80
+ }
81
+ sendJson(res, status, result);
82
+ return true;
83
+ }
84
+ if (method === 'GET') {
85
+ const session = store.getSession(sessionId);
86
+ if (!session) {
87
+ sendJson(res, 404, { error: 'session not found', sessionId });
88
+ return true;
89
+ }
90
+ const summary = store.summary(sessionId);
91
+ const chunks = store.listRecordings(sessionId);
92
+ const tailN = parseIntOr(url.searchParams.get('timeline'), TIMELINE_DEFAULT_TAIL);
93
+ const timeline = store.tail(sessionId, { n: tailN });
94
+ // Exports for the session's owning project (filter by sessionId).
95
+ const projectId = session.participants[0]?.projectId ?? '';
96
+ const exports = projectId
97
+ ? store.listExports(projectId, 50).filter((e) => e.sessionId === sessionId)
98
+ : [];
99
+ const body = {
100
+ session,
101
+ summary,
102
+ chunks,
103
+ timeline,
104
+ exports,
105
+ };
106
+ sendJson(res, 200, body);
107
+ return true;
108
+ }
109
+ }
110
+ // Any other /api/ path → 404 (consumed so the legacy handler doesn't try).
111
+ sendJson(res, 404, { error: 'not found', path });
112
+ return true;
113
+ };
114
+ }
115
+ function sendJson(res, status, body) {
116
+ res.statusCode = status;
117
+ res.setHeader('content-type', 'application/json; charset=utf-8');
118
+ res.setHeader('cache-control', 'no-store');
119
+ res.end(JSON.stringify(body));
120
+ }
121
+ async function readJsonBody(req) {
122
+ const chunks = [];
123
+ let total = 0;
124
+ const MAX = 1024 * 1024; // 1 MB — replay create bodies are tiny; cap to defend against DoS
125
+ for await (const chunk of req) {
126
+ const buf = chunk;
127
+ total += buf.length;
128
+ if (total > MAX)
129
+ throw new Error(`request body exceeds ${MAX} bytes`);
130
+ chunks.push(buf);
131
+ }
132
+ if (chunks.length === 0)
133
+ return {};
134
+ const text = Buffer.concat(chunks).toString('utf-8');
135
+ return JSON.parse(text);
136
+ }
137
+ function parseIntOr(raw, fallback) {
138
+ if (raw == null)
139
+ return fallback;
140
+ const n = Number.parseInt(raw, 10);
141
+ return Number.isFinite(n) && n > 0 ? n : fallback;
142
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * HTTP handler that serves the React SPA built by `@harness-fe/dashboard-ui`.
3
+ *
4
+ * Routing rules (after the `isAuthorized` middleware in bridge.ts):
5
+ * - GET / → 302 to /dashboard/?token=<preserved> (legacy root)
6
+ * - GET /sessions/:id → 302 to /dashboard/sessions/:id?token=… (legacy bookmarks)
7
+ * - GET /dashboard → 302 to /dashboard/?token=<preserved>
8
+ * - GET /dashboard/ → serve index.html (SPA shell)
9
+ * - GET /dashboard/<asset.ext> → serve that file from dist/ (if it exists)
10
+ * - GET /dashboard/<other-path> → serve index.html (SPA client-side routing)
11
+ *
12
+ * The dist directory is resolved at module load via `require.resolve()` on
13
+ * the dashboard-ui package — same trick `replayViewer.ts` uses for
14
+ * rrweb-player. No copy step needed; pnpm workspace symlinks just work in
15
+ * dev, and `pnpm deploy` bundles the dist into the published tarball.
16
+ */
17
+ import type { IncomingMessage, ServerResponse } from 'node:http';
18
+ export declare function createDashboardSpaHandler(): (req: IncomingMessage, res: ServerResponse) => boolean;