@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/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
|
-
});
|
package/dist/dashboardApi.d.ts
DELETED
|
@@ -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>;
|
package/dist/dashboardApi.js
DELETED
|
@@ -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
|
-
}
|
package/dist/dashboardSpa.d.ts
DELETED
|
@@ -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;
|