@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
@@ -0,0 +1,195 @@
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
+
18
+ import { createRequire } from 'node:module';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { dirname, extname, join, resolve as resolvePath } from 'node:path';
21
+ import type { IncomingMessage, ServerResponse } from 'node:http';
22
+ import { DEFAULT_COOKIE_NAME } from './auth.js';
23
+
24
+ const require = createRequire(import.meta.url);
25
+
26
+ /**
27
+ * If a request arrived with `?token=` AND no harness_fe_token cookie yet,
28
+ * stamp the cookie and 302 back to the same path without the query.
29
+ *
30
+ * Why this matters: the SPA bundle is loaded by the browser as
31
+ * `<script src="/dashboard/assets/index-XXX.js">` — relative paths without
32
+ * the token query — which would 401 against the upstream auth middleware.
33
+ * Setting the cookie on the first HTML request means every subsequent
34
+ * same-origin fetch (assets, /api/*, WebSocket upgrade) is automatically
35
+ * authenticated, and the visible URL stays clean.
36
+ */
37
+ function maybeHandleTokenHandoff(req: IncomingMessage, res: ServerResponse, url: URL): boolean {
38
+ const tokenInQuery = url.searchParams.get('token');
39
+ if (!tokenInQuery) return false;
40
+ // If the cookie's already set with the same value, nothing to do —
41
+ // pass through (the browser will follow links without ?token=).
42
+ const cookies = req.headers.cookie ?? '';
43
+ if (cookies.includes(`${DEFAULT_COOKIE_NAME}=`)) return false;
44
+ // Strip token from the URL and 302 back to the canonical path so the
45
+ // browser bookmarks / shares clean URLs. The cookie carries auth from
46
+ // here on. Also normalize `/dashboard` → `/dashboard/` in the same hop
47
+ // so we don't waste a second redirect chasing the trailing slash.
48
+ url.searchParams.delete('token');
49
+ const canonicalPath = url.pathname === '/dashboard' ? '/dashboard/' : url.pathname;
50
+ const remaining = url.searchParams.toString();
51
+ const clean = `${canonicalPath}${remaining ? '?' + remaining : ''}`;
52
+ const cookie =
53
+ `${DEFAULT_COOKIE_NAME}=${encodeURIComponent(tokenInQuery)}; ` +
54
+ // Path=/ so /api/*, /replay/*, /dashboard/* all see it.
55
+ // SameSite=Lax keeps the cookie on cross-tab nav.
56
+ // No HttpOnly: the SPA's WS code may want to surface the token in
57
+ // a Bearer header for tools that don't share cookies (rare —
58
+ // browsers attach cookies to WS, so this is just defense in depth).
59
+ // 30-day Max-Age matches the /__auth login flow.
60
+ `Path=/; SameSite=Lax; Max-Age=2592000`;
61
+ res.statusCode = 302;
62
+ res.setHeader('set-cookie', cookie);
63
+ res.setHeader('location', clean);
64
+ res.setHeader('cache-control', 'no-store');
65
+ res.end();
66
+ return true;
67
+ }
68
+
69
+ function resolveDashboardDist(): string | undefined {
70
+ try {
71
+ const pkgPath = require.resolve('@harness-fe/dashboard-ui/package.json');
72
+ const dist = join(dirname(pkgPath), 'dist');
73
+ return existsSync(join(dist, 'index.html')) ? dist : undefined;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ const CONTENT_TYPES: Record<string, string> = {
80
+ '.html': 'text/html; charset=utf-8',
81
+ '.js': 'application/javascript; charset=utf-8',
82
+ '.mjs': 'application/javascript; charset=utf-8',
83
+ '.css': 'text/css; charset=utf-8',
84
+ '.json': 'application/json; charset=utf-8',
85
+ '.svg': 'image/svg+xml',
86
+ '.png': 'image/png',
87
+ '.jpg': 'image/jpeg',
88
+ '.jpeg': 'image/jpeg',
89
+ '.gif': 'image/gif',
90
+ '.webp': 'image/webp',
91
+ '.ico': 'image/x-icon',
92
+ '.woff': 'font/woff',
93
+ '.woff2': 'font/woff2',
94
+ '.txt': 'text/plain; charset=utf-8',
95
+ '.map': 'application/json; charset=utf-8',
96
+ };
97
+
98
+ function contentTypeFor(filePath: string): string {
99
+ return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
100
+ }
101
+
102
+ export function createDashboardSpaHandler(): (
103
+ req: IncomingMessage,
104
+ res: ServerResponse,
105
+ ) => boolean {
106
+ const dist = resolveDashboardDist();
107
+ return (req, res) => {
108
+ if (!req.url) return false;
109
+ const url = new URL(req.url, 'http://localhost');
110
+ const path = url.pathname;
111
+ const method = req.method ?? 'GET';
112
+ if (method !== 'GET' && method !== 'HEAD') return false;
113
+
114
+ // Legacy paths from the old server-rendered dashboard — redirect into
115
+ // the SPA preserving the token query so the user stays authenticated.
116
+ if (path === '/' || path === '/index.html') {
117
+ res.statusCode = 302;
118
+ res.setHeader('location', `/dashboard/${url.search ?? ''}`);
119
+ res.end();
120
+ return true;
121
+ }
122
+ const legacySession = path.match(/^\/sessions\/([^/]+)$/);
123
+ if (legacySession) {
124
+ const sid = legacySession[1];
125
+ res.statusCode = 302;
126
+ res.setHeader('location', `/dashboard/sessions/${sid}${url.search ?? ''}`);
127
+ res.end();
128
+ return true;
129
+ }
130
+
131
+ if (!path.startsWith('/dashboard')) return false;
132
+
133
+ // First-load token handoff: ?token=… → cookie + redirect.
134
+ // Skip for static asset paths; setting the cookie there would still
135
+ // work but the redirect would break the script tag's request chain.
136
+ if (!path.startsWith('/dashboard/assets/') && !path.startsWith('/dashboard/static/')) {
137
+ if (maybeHandleTokenHandoff(req, res, url)) return true;
138
+ }
139
+
140
+ // If dashboard-ui isn't installed (someone using an older deploy
141
+ // or running tests in isolation), fall through with a friendly
142
+ // 503 rather than 404 — clearer signal than silent miss.
143
+ if (!dist) {
144
+ res.statusCode = 503;
145
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
146
+ res.end('Dashboard UI is not bundled with this build.');
147
+ return true;
148
+ }
149
+
150
+ // /dashboard with no trailing slash → redirect, preserving token.
151
+ if (path === '/dashboard') {
152
+ const search = url.search ?? '';
153
+ res.statusCode = 302;
154
+ res.setHeader('location', `/dashboard/${search}`);
155
+ res.end();
156
+ return true;
157
+ }
158
+
159
+ // Strip the `/dashboard/` prefix to get the relative path inside dist.
160
+ const rel = path.slice('/dashboard/'.length) || 'index.html';
161
+
162
+ // Path traversal defense: resolve against dist and require the
163
+ // result is still inside dist. Without this, a crafted URL like
164
+ // `/dashboard/../../etc/passwd` would escape.
165
+ const candidate = resolvePath(dist, rel);
166
+ if (!candidate.startsWith(dist + (dist.endsWith('/') ? '' : '/')) && candidate !== dist) {
167
+ res.statusCode = 403;
168
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
169
+ res.end('Forbidden');
170
+ return true;
171
+ }
172
+
173
+ // Serve the file if it exists; otherwise fall back to index.html
174
+ // so the SPA's client-side router can take over (this is how /sessions/:id
175
+ // works without server config).
176
+ const target = existsSync(candidate) ? candidate : join(dist, 'index.html');
177
+ try {
178
+ const body = readFileSync(target);
179
+ res.statusCode = 200;
180
+ res.setHeader('content-type', contentTypeFor(target));
181
+ // Hashed assets are immutable; HTML shells should never cache.
182
+ const isHashed = /\/assets\/.+-[A-Za-z0-9_-]{6,}\.[a-z0-9]+$/.test(target);
183
+ res.setHeader(
184
+ 'cache-control',
185
+ isHashed ? 'public, max-age=31536000, immutable' : 'no-store',
186
+ );
187
+ res.end(body);
188
+ } catch (err) {
189
+ res.statusCode = 500;
190
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
191
+ res.end(`dashboard read error: ${err instanceof Error ? err.message : String(err)}`);
192
+ }
193
+ return true;
194
+ };
195
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildDashboardUrl } from './dashboardUrl.js';
3
+ import type { IBridge } from './bridge.js';
4
+
5
+ function makeBridge(opts: { base?: string; token?: string }): IBridge {
6
+ return {
7
+ getViewerBaseUrl: () => opts.base,
8
+ getAuthToken: () => opts.token,
9
+ } as unknown as IBridge;
10
+ }
11
+
12
+ describe('buildDashboardUrl', () => {
13
+ it('returns the project-list URL with token when both are set', () => {
14
+ const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'abc' });
15
+ expect(buildDashboardUrl(bridge)).toBe('http://127.0.0.1:47729/dashboard/?token=abc');
16
+ });
17
+
18
+ it('deep-links into a session detail when sessionId is provided', () => {
19
+ const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'abc' });
20
+ expect(buildDashboardUrl(bridge, { sessionId: 'sess-1' })).toBe(
21
+ 'http://127.0.0.1:47729/dashboard/sessions/sess-1?token=abc',
22
+ );
23
+ });
24
+
25
+ it('URL-encodes the session id', () => {
26
+ const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'tok' });
27
+ expect(buildDashboardUrl(bridge, { sessionId: 'a/b c' })).toBe(
28
+ 'http://127.0.0.1:47729/dashboard/sessions/a%2Fb%20c?token=tok',
29
+ );
30
+ });
31
+
32
+ it('omits the token query when no token is configured', () => {
33
+ const bridge = makeBridge({ base: 'http://127.0.0.1:47729' });
34
+ expect(buildDashboardUrl(bridge)).toBe('http://127.0.0.1:47729/dashboard/');
35
+ });
36
+
37
+ it('returns undefined when the bridge has no base URL (no bound port yet)', () => {
38
+ const bridge = makeBridge({});
39
+ expect(buildDashboardUrl(bridge)).toBeUndefined();
40
+ });
41
+
42
+ it('URL-encodes the token (defensive against weird characters in HARNESS_FE_TOKEN)', () => {
43
+ const bridge = makeBridge({ base: 'http://127.0.0.1:47729', token: 'a b&c' });
44
+ expect(buildDashboardUrl(bridge)).toBe('http://127.0.0.1:47729/dashboard/?token=a%20b%26c');
45
+ });
46
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Compose dashboard URLs the user (or agent) should hit.
3
+ *
4
+ * Carries the configured auth token in the query string so the browser
5
+ * lands pre-authenticated. On loopback hosts with no token configured,
6
+ * the URL is left bare.
7
+ */
8
+
9
+ import type { IBridge } from './bridge.js';
10
+
11
+ export interface DashboardUrlOptions {
12
+ /** When provided, deep-link to a specific session detail page. */
13
+ sessionId?: string;
14
+ }
15
+
16
+ export function buildDashboardUrl(
17
+ bridge: IBridge,
18
+ opts: DashboardUrlOptions = {},
19
+ ): string | undefined {
20
+ const base = bridge.getViewerBaseUrl();
21
+ if (!base) return undefined;
22
+ const path = opts.sessionId
23
+ ? `/dashboard/sessions/${encodeURIComponent(opts.sessionId)}`
24
+ : '/dashboard/';
25
+ const token = bridge.getAuthToken();
26
+ const qs = token ? `?token=${encodeURIComponent(token)}` : '';
27
+ return `${base}${path}${qs}`;
28
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Unit tests for createEventsHandler — POST /events, GET /events/ping, CORS.
3
+ */
4
+
5
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
6
+ import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import { PROTOCOL_VERSION } from '@harness-fe/protocol';
8
+ import { createEventsHandler } from './eventsHandler.js';
9
+
10
+ // ─── Minimal IncomingMessage / ServerResponse mocks ──────────────────────────
11
+
12
+ function makeReq(opts: {
13
+ url: string;
14
+ method: string;
15
+ host?: string;
16
+ body?: string;
17
+ }): IncomingMessage {
18
+ const { Readable } = require('node:stream') as typeof import('node:stream');
19
+ const stream = new Readable({ read() {} });
20
+ const req = stream as unknown as IncomingMessage;
21
+ req.url = opts.url;
22
+ req.method = opts.method;
23
+ req.headers = opts.host ? { host: opts.host } : {};
24
+ if (opts.body !== undefined) {
25
+ process.nextTick(() => {
26
+ stream.push(Buffer.from(opts.body!));
27
+ stream.push(null);
28
+ });
29
+ } else {
30
+ process.nextTick(() => stream.push(null));
31
+ }
32
+ return req;
33
+ }
34
+
35
+ interface MockRes {
36
+ statusCode: number;
37
+ headers: Record<string, string>;
38
+ body: string;
39
+ setHeader(name: string, value: string): void;
40
+ end(data?: string): void;
41
+ }
42
+
43
+ function makeRes(): MockRes {
44
+ const res: MockRes = {
45
+ statusCode: 200,
46
+ headers: {},
47
+ body: '',
48
+ setHeader(name, value) { this.headers[name.toLowerCase()] = value; },
49
+ end(data) { if (data) this.body = data; },
50
+ };
51
+ return res;
52
+ }
53
+
54
+ // ─── Bridge stub ─────────────────────────────────────────────────────────────
55
+
56
+ function makeBridge(onBatch?: (hello: unknown, events: unknown[]) => void) {
57
+ return {
58
+ handleHttpBatch: vi.fn((hello: unknown, events: unknown[]) => {
59
+ onBatch?.(hello, events);
60
+ }),
61
+ };
62
+ }
63
+
64
+ // ─── Tests ───────────────────────────────────────────────────────────────────
65
+
66
+ describe('eventsHandler', () => {
67
+ describe('GET /events/ping', () => {
68
+ it('returns 200 with ok + version', async () => {
69
+ const bridge = makeBridge();
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ const handler = createEventsHandler(bridge as any);
72
+ const req = makeReq({ url: '/events/ping', method: 'GET' });
73
+ const res = makeRes();
74
+ const handled = await handler(req, res as unknown as ServerResponse);
75
+ expect(handled).toBe(true);
76
+ expect(res.statusCode).toBe(200);
77
+ const payload = JSON.parse(res.body) as { ok: boolean; version: string };
78
+ expect(payload.ok).toBe(true);
79
+ expect(payload.version).toBe(PROTOCOL_VERSION);
80
+ });
81
+
82
+ it('does not set CORS when host is not loopback', async () => {
83
+ const bridge = makeBridge();
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
+ const handler = createEventsHandler(bridge as any);
86
+ const req = makeReq({ url: '/events/ping', method: 'GET', host: 'example.com' });
87
+ const res = makeRes();
88
+ await handler(req, res as unknown as ServerResponse);
89
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
90
+ });
91
+
92
+ it('sets CORS when host is localhost', async () => {
93
+ const bridge = makeBridge();
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ const handler = createEventsHandler(bridge as any);
96
+ const req = makeReq({ url: '/events/ping', method: 'GET', host: 'localhost:47729' });
97
+ const res = makeRes();
98
+ await handler(req, res as unknown as ServerResponse);
99
+ expect(res.headers['access-control-allow-origin']).toBe('*');
100
+ });
101
+
102
+ it('sets CORS when host is 127.0.0.1', async () => {
103
+ const bridge = makeBridge();
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ const handler = createEventsHandler(bridge as any);
106
+ const req = makeReq({ url: '/events/ping', method: 'GET', host: '127.0.0.1:47729' });
107
+ const res = makeRes();
108
+ await handler(req, res as unknown as ServerResponse);
109
+ expect(res.headers['access-control-allow-origin']).toBe('*');
110
+ });
111
+ });
112
+
113
+ describe('OPTIONS preflight', () => {
114
+ it('returns 204 for /events', async () => {
115
+ const bridge = makeBridge();
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ const handler = createEventsHandler(bridge as any);
118
+ const req = makeReq({ url: '/events', method: 'OPTIONS', host: 'localhost:47729' });
119
+ const res = makeRes();
120
+ const handled = await handler(req, res as unknown as ServerResponse);
121
+ expect(handled).toBe(true);
122
+ expect(res.statusCode).toBe(204);
123
+ });
124
+
125
+ it('returns 204 for /events/ping', async () => {
126
+ const bridge = makeBridge();
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ const handler = createEventsHandler(bridge as any);
129
+ const req = makeReq({ url: '/events/ping', method: 'OPTIONS', host: 'localhost:47729' });
130
+ const res = makeRes();
131
+ const handled = await handler(req, res as unknown as ServerResponse);
132
+ expect(handled).toBe(true);
133
+ expect(res.statusCode).toBe(204);
134
+ });
135
+ });
136
+
137
+ describe('POST /events', () => {
138
+ it('returns 204 and calls handleHttpBatch with valid body', async () => {
139
+ const bridge = makeBridge();
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ const handler = createEventsHandler(bridge as any);
142
+ const body = JSON.stringify({
143
+ hello: {
144
+ role: 'node-runtime',
145
+ projectId: 'test-proj',
146
+ sessionId: 'sess-abc',
147
+ buildId: 'build-1',
148
+ },
149
+ events: [
150
+ { id: 'e1', name: 'server-err', ts: 1000, payload: { message: 'boom' } },
151
+ { id: 'e2', name: 'server-log', ts: 1001, payload: { level: 'info' } },
152
+ ],
153
+ });
154
+ const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
155
+ const res = makeRes();
156
+ const handled = await handler(req, res as unknown as ServerResponse);
157
+ expect(handled).toBe(true);
158
+ expect(res.statusCode).toBe(204);
159
+ expect(bridge.handleHttpBatch).toHaveBeenCalledOnce();
160
+ const [hello, events] = bridge.handleHttpBatch.mock.calls[0] as [unknown, unknown[]];
161
+ expect((hello as { projectId: string }).projectId).toBe('test-proj');
162
+ expect(events).toHaveLength(2);
163
+ });
164
+
165
+ it('returns 400 for invalid JSON', async () => {
166
+ const bridge = makeBridge();
167
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
168
+ const handler = createEventsHandler(bridge as any);
169
+ const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body: '{bad json' });
170
+ const res = makeRes();
171
+ const handled = await handler(req, res as unknown as ServerResponse);
172
+ expect(handled).toBe(true);
173
+ expect(res.statusCode).toBe(400);
174
+ expect(bridge.handleHttpBatch).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('returns 400 when body fails schema validation (bad role)', async () => {
178
+ const bridge = makeBridge();
179
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
+ const handler = createEventsHandler(bridge as any);
181
+ const body = JSON.stringify({
182
+ hello: {
183
+ role: 'runtime-client', // wrong role
184
+ projectId: 'test',
185
+ },
186
+ events: [],
187
+ });
188
+ const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
189
+ const res = makeRes();
190
+ await handler(req, res as unknown as ServerResponse);
191
+ expect(res.statusCode).toBe(400);
192
+ });
193
+
194
+ it('returns 400 when hello.projectId is missing', async () => {
195
+ const bridge = makeBridge();
196
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
197
+ const handler = createEventsHandler(bridge as any);
198
+ const body = JSON.stringify({
199
+ hello: { role: 'node-runtime' },
200
+ events: [],
201
+ });
202
+ const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
203
+ const res = makeRes();
204
+ await handler(req, res as unknown as ServerResponse);
205
+ expect(res.statusCode).toBe(400);
206
+ });
207
+
208
+ it('returns 500 when handleHttpBatch throws', async () => {
209
+ const bridge = {
210
+ handleHttpBatch: vi.fn(() => { throw new Error('store error'); }),
211
+ };
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ const handler = createEventsHandler(bridge as any);
214
+ const body = JSON.stringify({
215
+ hello: { role: 'node-runtime', projectId: 'test', sessionId: 's1' },
216
+ events: [],
217
+ });
218
+ const req = makeReq({ url: '/events', method: 'POST', host: 'localhost:47729', body });
219
+ const res = makeRes();
220
+ const handled = await handler(req, res as unknown as ServerResponse);
221
+ expect(handled).toBe(true);
222
+ expect(res.statusCode).toBe(500);
223
+ });
224
+ });
225
+
226
+ describe('fall-through for unmatched routes', () => {
227
+ it('returns false for /other', async () => {
228
+ const bridge = makeBridge();
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ const handler = createEventsHandler(bridge as any);
231
+ const req = makeReq({ url: '/other', method: 'GET' });
232
+ const res = makeRes();
233
+ const handled = await handler(req, res as unknown as ServerResponse);
234
+ expect(handled).toBe(false);
235
+ });
236
+
237
+ it('returns false for /events/unknown-sub-path', async () => {
238
+ const bridge = makeBridge();
239
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
+ const handler = createEventsHandler(bridge as any);
241
+ const req = makeReq({ url: '/events/unknown', method: 'GET' });
242
+ const res = makeRes();
243
+ const handled = await handler(req, res as unknown as ServerResponse);
244
+ expect(handled).toBe(false);
245
+ });
246
+ });
247
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * HTTP POST /events handler — stateless alternative to the WebSocket path.
3
+ *
4
+ * Accepts a JSON body matching `httpBatchSchema`:
5
+ * { hello: { role: 'node-runtime', projectId, sessionId, ... },
6
+ * events: [ { id, name, ts, payload, ... }, ... ] }
7
+ *
8
+ * Each POST is treated as a one-shot hello+events sequence:
9
+ * 1. Register (or re-register) the peer via the same bridge internals.
10
+ * 2. Persist every event to the right session timeline.
11
+ * 3. Respond 204 No Content.
12
+ *
13
+ * Also handles:
14
+ * GET /events/ping → 200 { ok: true, version }
15
+ * OPTIONS /events → 204 CORS preflight
16
+ */
17
+
18
+ import type { IncomingMessage, ServerResponse } from 'node:http';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { PROTOCOL_VERSION, httpBatchSchema } from '@harness-fe/protocol';
21
+ import type { Bridge } from './bridge.js';
22
+
23
+ // ─── CORS helpers ────────────────────────────────────────────────────────────
24
+
25
+ function isLoopback(req: IncomingMessage): boolean {
26
+ const host = req.headers['host'] ?? '';
27
+ // Match 127.0.0.1:* and localhost:*
28
+ return /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
29
+ }
30
+
31
+ function setCorsHeaders(res: ServerResponse): void {
32
+ res.setHeader('Access-Control-Allow-Origin', '*');
33
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
34
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
35
+ }
36
+
37
+ // ─── Body reader ─────────────────────────────────────────────────────────────
38
+
39
+ function readBody(req: IncomingMessage): Promise<string> {
40
+ return new Promise((resolve, reject) => {
41
+ const chunks: Buffer[] = [];
42
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
43
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
44
+ req.on('error', reject);
45
+ });
46
+ }
47
+
48
+ // ─── Factory ─────────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Returns a handler that matches /events/* routes and returns `true` when it
52
+ * handled the request (so the bridge can short-circuit its 404 fallback).
53
+ */
54
+ export function createEventsHandler(
55
+ bridge: Bridge,
56
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
57
+ return async (req, res): Promise<boolean> => {
58
+ const url = req.url ?? '';
59
+ const method = req.method ?? 'GET';
60
+
61
+ // Only intercept /events and /events/ping
62
+ if (url !== '/events' && url !== '/events/ping') return false;
63
+
64
+ // CORS: only emit headers when request comes from loopback
65
+ if (isLoopback(req)) {
66
+ setCorsHeaders(res);
67
+ }
68
+
69
+ // Preflight for both routes
70
+ if (method === 'OPTIONS') {
71
+ res.statusCode = 204;
72
+ res.end();
73
+ return true;
74
+ }
75
+
76
+ // GET /events/ping
77
+ if (url === '/events/ping' && method === 'GET') {
78
+ res.statusCode = 200;
79
+ res.setHeader('content-type', 'application/json; charset=utf-8');
80
+ res.end(JSON.stringify({ ok: true, version: PROTOCOL_VERSION }));
81
+ return true;
82
+ }
83
+
84
+ // POST /events
85
+ if (url === '/events' && method === 'POST') {
86
+ let rawBody: string;
87
+ try {
88
+ rawBody = await readBody(req);
89
+ } catch {
90
+ res.statusCode = 400;
91
+ res.setHeader('content-type', 'application/json; charset=utf-8');
92
+ res.end(JSON.stringify({ error: 'failed to read request body' }));
93
+ return true;
94
+ }
95
+
96
+ let parsed: unknown;
97
+ try {
98
+ parsed = JSON.parse(rawBody);
99
+ } catch {
100
+ res.statusCode = 400;
101
+ res.setHeader('content-type', 'application/json; charset=utf-8');
102
+ res.end(JSON.stringify({ error: 'invalid JSON' }));
103
+ return true;
104
+ }
105
+
106
+ const validated = httpBatchSchema.safeParse(parsed);
107
+ if (!validated.success) {
108
+ res.statusCode = 400;
109
+ res.setHeader('content-type', 'application/json; charset=utf-8');
110
+ res.end(JSON.stringify({ error: validated.error.message }));
111
+ return true;
112
+ }
113
+
114
+ const { hello, events } = validated.data;
115
+
116
+ try {
117
+ bridge.handleHttpBatch(hello, events);
118
+ } catch (err) {
119
+ res.statusCode = 500;
120
+ res.setHeader('content-type', 'application/json; charset=utf-8');
121
+ res.end(JSON.stringify({
122
+ error: err instanceof Error ? err.message : 'internal error',
123
+ }));
124
+ return true;
125
+ }
126
+
127
+ res.statusCode = 204;
128
+ res.end();
129
+ return true;
130
+ }
131
+
132
+ return false;
133
+ };
134
+ }
135
+
136
+ export type EventsHandler = ReturnType<typeof createEventsHandler>;
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export { Bridge, defaultDataDir, type BridgeOptions } from './bridge.js';
2
+ export { createDaemon, type DaemonOptions, type DaemonHandle } from './daemon.js';
3
+ export { SessionRouter, type PeerSession } from './sessionRouter.js';
4
+ export { startMcpStdioServer } from './mcp.js';
5
+ export { startMcpHttpServer, type McpHttpOptions, type McpHttpHandle } from './mcpHttp.js';
6
+ export {
7
+ JsonlStore,
8
+ JsonTaskStore,
9
+ JsonMemoryStore,
10
+ MemoryEventStore,
11
+ sanitizeId,
12
+ type MemoryEventStoreOptions,
13
+ } from './store/index.js';
14
+ export type {
15
+ IStore,
16
+ ITaskStore,
17
+ IMemoryStore,
18
+ EventStore,
19
+ EventId,
20
+ StreamId,
21
+ ProjectMeta,
22
+ ProjectTreeNode,
23
+ BuildMeta,
24
+ SessionMeta,
25
+ TabMeta,
26
+ } from './store/index.js';