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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/bin.d.ts +2 -0
  2. package/dist/bin.js +15 -0
  3. package/dist/daemon.d.ts +3 -3
  4. package/dist/daemon.js +1 -1
  5. package/dist/index.d.ts +4 -4
  6. package/dist/index.js +3 -3
  7. package/dist/mcp.d.ts +2 -2
  8. package/dist/mcp.js +49 -15
  9. package/dist/mcpHttp.d.ts +2 -2
  10. package/dist/mcpHttp.js +8 -2
  11. package/package.json +5 -7
  12. package/src/bin.ts +19 -0
  13. package/src/daemon.ts +3 -3
  14. package/src/experimental.test.ts +2 -2
  15. package/src/index.ts +4 -4
  16. package/src/mcp.ts +51 -19
  17. package/src/mcpHttp.test.ts +3 -3
  18. package/src/mcpHttp.ts +10 -4
  19. package/src/mcpLayer.e2e.test.ts +2 -2
  20. package/src/newCapabilities.e2e.test.ts +3 -3
  21. package/dist/auth.d.ts +0 -53
  22. package/dist/auth.js +0 -212
  23. package/dist/bridge.d.ts +0 -323
  24. package/dist/bridge.js +0 -1618
  25. package/dist/cli.d.ts +0 -18
  26. package/dist/cli.js +0 -293
  27. package/dist/dashboardApi.d.ts +0 -40
  28. package/dist/dashboardApi.js +0 -142
  29. package/dist/dashboardSpa.d.ts +0 -18
  30. package/dist/dashboardSpa.js +0 -180
  31. package/dist/dashboardUrl.d.ts +0 -13
  32. package/dist/dashboardUrl.js +0 -18
  33. package/dist/eventsHandler.d.ts +0 -24
  34. package/dist/eventsHandler.js +0 -114
  35. package/dist/identity.d.ts +0 -74
  36. package/dist/identity.js +0 -101
  37. package/dist/openBrowser.d.ts +0 -33
  38. package/dist/openBrowser.js +0 -63
  39. package/dist/remoteBridge.d.ts +0 -61
  40. package/dist/remoteBridge.js +0 -307
  41. package/dist/replayCreate.d.ts +0 -36
  42. package/dist/replayCreate.js +0 -156
  43. package/dist/replayViewer.d.ts +0 -20
  44. package/dist/replayViewer.js +0 -168
  45. package/dist/sessionRouter.d.ts +0 -45
  46. package/dist/sessionRouter.js +0 -88
  47. package/dist/store/JsonMemoryStore.d.ts +0 -52
  48. package/dist/store/JsonMemoryStore.js +0 -119
  49. package/dist/store/JsonTaskStore.d.ts +0 -21
  50. package/dist/store/JsonTaskStore.js +0 -53
  51. package/dist/store/JsonlStore.d.ts +0 -128
  52. package/dist/store/JsonlStore.js +0 -1172
  53. package/dist/store/MemoryEventStore.d.ts +0 -47
  54. package/dist/store/MemoryEventStore.js +0 -111
  55. package/dist/store/WriteQueue.d.ts +0 -51
  56. package/dist/store/WriteQueue.js +0 -142
  57. package/dist/store/index.d.ts +0 -6
  58. package/dist/store/index.js +0 -5
  59. package/dist/store/types.d.ts +0 -427
  60. package/dist/store/types.js +0 -19
  61. package/dist/visitorTimeline.d.ts +0 -24
  62. package/dist/visitorTimeline.js +0 -68
  63. package/src/auth.test.ts +0 -90
  64. package/src/auth.ts +0 -248
  65. package/src/bridge-auth.test.ts +0 -196
  66. package/src/bridge.test.ts +0 -1708
  67. package/src/bridge.ts +0 -1854
  68. package/src/cli.ts +0 -338
  69. package/src/dashboardApi.test.ts +0 -235
  70. package/src/dashboardApi.ts +0 -184
  71. package/src/dashboardSpa.test.ts +0 -239
  72. package/src/dashboardSpa.ts +0 -195
  73. package/src/dashboardUrl.test.ts +0 -46
  74. package/src/dashboardUrl.ts +0 -28
  75. package/src/eventsHandler.test.ts +0 -247
  76. package/src/eventsHandler.ts +0 -136
  77. package/src/identity.test.ts +0 -86
  78. package/src/identity.ts +0 -116
  79. package/src/openBrowser.test.ts +0 -103
  80. package/src/openBrowser.ts +0 -81
  81. package/src/remoteBridge.test.ts +0 -119
  82. package/src/remoteBridge.ts +0 -404
  83. package/src/replay.test.ts +0 -271
  84. package/src/replayCreate.ts +0 -194
  85. package/src/replayViewer.ts +0 -173
  86. package/src/sessionRouter.ts +0 -119
  87. package/src/store/JsonMemoryStore.test.ts +0 -175
  88. package/src/store/JsonMemoryStore.ts +0 -128
  89. package/src/store/JsonTaskStore.test.ts +0 -212
  90. package/src/store/JsonTaskStore.ts +0 -59
  91. package/src/store/JsonlStore.test.ts +0 -1538
  92. package/src/store/JsonlStore.ts +0 -1325
  93. package/src/store/MemoryEventStore.test.ts +0 -119
  94. package/src/store/MemoryEventStore.ts +0 -151
  95. package/src/store/WriteQueue.ts +0 -165
  96. package/src/store/identityTagging.test.ts +0 -67
  97. package/src/store/index.ts +0 -29
  98. package/src/store/types.ts +0 -532
  99. package/src/visitorTimeline.test.ts +0 -197
  100. package/src/visitorTimeline.ts +0 -89
package/dist/cli.d.ts DELETED
@@ -1,18 +0,0 @@
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 DELETED
@@ -1,293 +0,0 @@
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 { defaultDataDir } from './bridge.js';
21
- import { createDaemon } from './daemon.js';
22
- import { RemoteBridge } from './remoteBridge.js';
23
- import { startMcpStdioServer } from './mcp.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
- --experimental-env-var <name>
41
- Restrict experimental (in-testing) tools to machines
42
- where <name> is set to a non-empty value. Omit this and
43
- experimental tools are fully on (the default).
44
- -h, --help Show this help.
45
-
46
- Environment:
47
- HARNESS_FE_HOST Same as --host
48
- HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
49
- HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
50
- HARNESS_FE_MCP_PATH Same as --mcp-path
51
- HARNESS_FE_EXPERIMENTAL_ENV_VAR Same as --experimental-env-var
52
- HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
53
- `;
54
- process.stderr.write(help);
55
- process.exit(0);
56
- }
57
- function parseArgs(argv) {
58
- const args = argv.slice(2);
59
- let host;
60
- let port;
61
- let token;
62
- let mcpTransport;
63
- let mcpPath;
64
- let publicHost;
65
- let experimentalEnvVar;
66
- for (let i = 0; i < args.length; i++) {
67
- const a = args[i];
68
- const next = () => {
69
- const v = args[++i];
70
- if (v == null) {
71
- process.stderr.write(`harness-fe: missing value for ${a}\n`);
72
- process.exit(2);
73
- }
74
- return v;
75
- };
76
- switch (a) {
77
- case '-h':
78
- case '--help':
79
- printHelpAndExit();
80
- break;
81
- case '--host':
82
- host = next();
83
- break;
84
- case '--port':
85
- port = Number(next());
86
- if (!Number.isFinite(port) || port <= 0) {
87
- process.stderr.write(`harness-fe: invalid --port\n`);
88
- process.exit(2);
89
- }
90
- break;
91
- case '--token':
92
- token = next();
93
- break;
94
- case '--mcp-transport': {
95
- const v = next();
96
- if (v !== 'stdio' && v !== 'http') {
97
- process.stderr.write(`harness-fe: invalid --mcp-transport (stdio|http)\n`);
98
- process.exit(2);
99
- }
100
- mcpTransport = v;
101
- break;
102
- }
103
- case '--mcp-path':
104
- mcpPath = next();
105
- break;
106
- case '--public-host':
107
- publicHost = next();
108
- break;
109
- case '--experimental-env-var':
110
- experimentalEnvVar = next();
111
- break;
112
- default:
113
- process.stderr.write(`harness-fe: unknown argument ${a}\n`);
114
- process.exit(2);
115
- }
116
- }
117
- // Apply env fallbacks, then URL fallback for host/port.
118
- const envUrl = process.env.HARNESS_FE_URL;
119
- let envHost;
120
- let envPort;
121
- if (envUrl) {
122
- try {
123
- const parsed = parseWsUrl(envUrl);
124
- envHost = parsed.host;
125
- envPort = parsed.port;
126
- }
127
- catch {
128
- // ignore — fall back to defaults below
129
- }
130
- }
131
- const finalHost = host ?? process.env.HARNESS_FE_HOST ?? envHost ?? DEFAULT_HOST;
132
- const finalPort = port ?? envPort ?? DEFAULT_WS_PORT;
133
- let finalToken = token ?? process.env.HARNESS_FE_TOKEN;
134
- if (finalToken === 'auto') {
135
- finalToken = randomBytes(24).toString('base64url');
136
- }
137
- if (finalToken === '')
138
- finalToken = undefined;
139
- const finalTransport = (mcpTransport ?? process.env.HARNESS_FE_MCP_TRANSPORT) ??
140
- 'stdio';
141
- if (finalTransport !== 'stdio' && finalTransport !== 'http') {
142
- process.stderr.write(`harness-fe: invalid mcp transport "${finalTransport}"\n`);
143
- process.exit(2);
144
- }
145
- const finalMcpPath = mcpPath ?? process.env.HARNESS_FE_MCP_PATH ?? '/mcp';
146
- // Data dir defaults to port-keyed path. Explicit env override wins.
147
- const finalDataDir = process.env.HARNESS_FE_DATA_DIR ?? defaultDataDir(finalPort);
148
- const finalLabel = process.env.HARNESS_FE_LABEL || undefined;
149
- // Omitted → undefined → experimental tools fully on (no gate). Supply a
150
- // name only to restrict them to machines where that var is set.
151
- const finalExperimentalEnvVar = experimentalEnvVar || process.env.HARNESS_FE_EXPERIMENTAL_ENV_VAR || undefined;
152
- return {
153
- host: finalHost,
154
- port: finalPort,
155
- token: finalToken,
156
- mcpTransport: finalTransport,
157
- mcpPath: finalMcpPath,
158
- publicHost,
159
- label: finalLabel,
160
- dataDir: finalDataDir,
161
- experimentalEnvVar: finalExperimentalEnvVar,
162
- };
163
- }
164
- function validate(_cfg) {
165
- // Token requirement is left entirely to the operator. We don't refuse
166
- // a non-loopback bind without a token — that's their call, not ours.
167
- // Warnings are emitted from the banner so the operator sees them; CI /
168
- // automation that pipes stderr can suppress as needed.
169
- }
170
- function printBanner(cfg, role, viewerUrl) {
171
- const lines = [];
172
- const labelSuffix = cfg.label ? ` (${cfg.label})` : '';
173
- lines.push(`[harness-fe] ${role}: WS bridge listening on ws://${cfg.host}:${cfg.port}${labelSuffix}`);
174
- if (role === 'leader') {
175
- // Surface the data dir so the user can see exactly where this
176
- // daemon's sessions / recordings / projects are landing.
177
- lines.push(`[harness-fe] data: ${cfg.dataDir}`);
178
- }
179
- const isLan = !isLoopbackHost(cfg.host);
180
- if (isLan) {
181
- lines.push(`[harness-fe] WARNING: bound to non-loopback host ${cfg.host}.`);
182
- if (cfg.token) {
183
- lines.push(`[harness-fe] anyone reaching this host:port with the token can read console / network / recordings.`);
184
- }
185
- else {
186
- lines.push(`[harness-fe] no token set — anyone on this network can read console / network / recordings.`);
187
- lines.push(`[harness-fe] add --token auto (or HARNESS_FE_TOKEN=…) to enable auth.`);
188
- }
189
- }
190
- // Always print the dashboard URL. The token (when present) is folded
191
- // into the query so the first hit hands it off to a cookie; without a
192
- // token, auth is disabled and the URL works on its own.
193
- const host = cfg.publicHost ?? viewerHost(viewerUrl) ?? cfg.host;
194
- const dashboard = buildHttpUrl({ host, port: cfg.port, token: cfg.token });
195
- lines.push(`[harness-fe] dashboard: ${dashboard}`);
196
- if (cfg.mcpTransport === 'http') {
197
- const mcp = buildHttpUrl({ host, port: cfg.port, token: cfg.token, path: cfg.mcpPath });
198
- lines.push(`[harness-fe] mcp http: ${mcp}`);
199
- if (cfg.token) {
200
- const mcpNoTok = buildHttpUrl({ host, port: cfg.port, path: cfg.mcpPath });
201
- lines.push(`[harness-fe] agent config: { "url": "${mcpNoTok}", "headers": { "Authorization": "Bearer ${cfg.token}" } }`);
202
- }
203
- else {
204
- lines.push(`[harness-fe] agent config: { "url": "${mcp}" } (no auth — token unset)`);
205
- }
206
- }
207
- if (cfg.token) {
208
- lines.push(`[harness-fe] token: ${cfg.token}`);
209
- }
210
- process.stderr.write(lines.join('\n') + '\n');
211
- }
212
- function viewerHost(viewerUrl) {
213
- if (!viewerUrl)
214
- return undefined;
215
- try {
216
- return new URL(viewerUrl).hostname;
217
- }
218
- catch {
219
- return undefined;
220
- }
221
- }
222
- async function main() {
223
- const cfg = parseArgs(process.argv);
224
- validate(cfg);
225
- const { active, shutdown, role } = await startBridgeOrAttach(cfg);
226
- printBanner(cfg, role, active.getViewerBaseUrl());
227
- if (cfg.mcpTransport === 'stdio') {
228
- await startMcpStdioServer(active, { experimentalEnvVar: cfg.experimentalEnvVar });
229
- process.stderr.write('[harness-fe] MCP stdio server connected\n');
230
- }
231
- else {
232
- // HTTP transport: the leader's createDaemon() call already mounted
233
- // /mcp via mcpHttp:true. Followers fall through here with no leader
234
- // attached, so HTTP mode is unsupported for them.
235
- if (role === 'follower') {
236
- process.stderr.write('[harness-fe] --mcp-transport=http is only supported on the leader. ' +
237
- 'Another daemon already holds the port; stop it first.\n');
238
- await shutdown();
239
- process.exit(2);
240
- }
241
- process.stderr.write(`[harness-fe] MCP http server mounted at ${cfg.mcpPath}\n`);
242
- }
243
- const onSignal = async () => {
244
- process.stderr.write('[harness-fe] shutting down\n');
245
- await shutdown();
246
- process.exit(0);
247
- };
248
- process.on('SIGINT', onSignal);
249
- process.on('SIGTERM', onSignal);
250
- }
251
- async function startBridgeOrAttach(cfg) {
252
- // Leader path: use createDaemon so there's exactly one boot path between
253
- // the CLI and any host application that embeds the daemon. The factory
254
- // mounts /mcp itself when mcpHttp:true, so we don't need to call
255
- // startMcpHttpServer here.
256
- const daemon = createDaemon({
257
- port: cfg.port,
258
- host: cfg.host,
259
- dataDir: cfg.dataDir,
260
- label: cfg.label,
261
- token: cfg.token,
262
- publicHost: cfg.publicHost,
263
- mcpHttp: cfg.mcpTransport === 'http',
264
- mcpPath: cfg.mcpPath,
265
- experimentalEnvVar: cfg.experimentalEnvVar,
266
- });
267
- try {
268
- await daemon.start();
269
- return {
270
- active: daemon.bridge,
271
- shutdown: () => daemon.stop(),
272
- role: 'leader',
273
- };
274
- }
275
- catch (err) {
276
- if (err?.code !== 'EADDRINUSE')
277
- throw err;
278
- // Factory's bridge.start failed on EADDRINUSE; the factory itself
279
- // didn't mount anything else, so there's nothing further to clean up.
280
- }
281
- // Port already taken — attach as follower.
282
- const remote = new RemoteBridge({ port: cfg.port, host: cfg.host, token: cfg.token });
283
- await remote.connect();
284
- return {
285
- active: remote,
286
- shutdown: () => remote.stop(),
287
- role: 'follower',
288
- };
289
- }
290
- main().catch((err) => {
291
- process.stderr.write(`[harness-fe] fatal: ${err?.stack ?? err}\n`);
292
- process.exit(1);
293
- });
@@ -1,40 +0,0 @@
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>;
@@ -1,142 +0,0 @@
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
- }
@@ -1,18 +0,0 @@
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;