@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.
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 +42 -16
  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 +44 -20
  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 -90
  36. package/dist/identity.js +0 -123
  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 -109
  78. package/src/identity.ts +0 -137
  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
@@ -1,61 +0,0 @@
1
- /**
2
- * RemoteBridge — IBridge implementation backed by a WS connection to an
3
- * already-running daemon (leader).
4
- *
5
- * Used when this process starts as a follower: another cli.js is already
6
- * listening on :47729, so we attach as a ws client and proxy every MCP tool
7
- * call through the new `mcp.call` / `mcp.return` control frames.
8
- */
9
- import { type McpCallFrame, type TabInfo, type Task, type TaskStatus } from '@harness-fe/protocol';
10
- import type { IBridge, SendCommandOptions } from './bridge.js';
11
- import type { IMemoryStore, IStore } from './store/index.js';
12
- export interface RemoteBridgeOptions {
13
- port: number;
14
- host?: string;
15
- /** Per-call timeout. Must be ≥ daemon's command timeout to surface upstream errors first. */
16
- callTimeoutMs?: number;
17
- /** Token used to authenticate against the leader, if it requires one. */
18
- token?: string;
19
- }
20
- export declare class RemoteBridge implements IBridge {
21
- private ws?;
22
- private pending;
23
- private closed;
24
- private readonly url;
25
- private readonly callTimeoutMs;
26
- private readonly host;
27
- private readonly port;
28
- private readonly token;
29
- constructor(opts: RemoteBridgeOptions);
30
- connect(): Promise<void>;
31
- stop(): Promise<void>;
32
- sendCommand(command: string, args: unknown, opts?: SendCommandOptions): Promise<unknown>;
33
- listTabs(): Promise<TabInfo[]>;
34
- listTasks(filter?: {
35
- status?: TaskStatus | 'all';
36
- limit?: number;
37
- }): Promise<Task[]>;
38
- claimTask(id: string): Promise<Task | undefined>;
39
- resolveTask(id: string, note?: string): Promise<Task | undefined>;
40
- /**
41
- * Returns a RemoteMemoryStore that proxies all memory operations to the
42
- * leader via the mcp.call channel. This allows follower instances to use
43
- * the same project.memory.* tools as the leader.
44
- */
45
- getMemoryStore(): IMemoryStore;
46
- getViewerBaseUrl(): string | undefined;
47
- getAuthToken(): string | undefined;
48
- getTaskAttachmentData(_taskId: string, _attachmentId: string): Promise<string | null>;
49
- /**
50
- * Returns a RemoteStore that proxies all store read/query operations to
51
- * the leader via the mcp.call channel. Write operations (openSession,
52
- * append, etc.) are not proxied — followers are read-only.
53
- */
54
- getStore(): IStore;
55
- /** @internal — used by RemoteMemoryStore and RemoteStore */
56
- invokeRemote(method: McpCallFrame['method'], args: unknown): Promise<unknown>;
57
- private invoke;
58
- private attachHandlers;
59
- private handleReturn;
60
- private handleClose;
61
- }
@@ -1,307 +0,0 @@
1
- /**
2
- * RemoteBridge — IBridge implementation backed by a WS connection to an
3
- * already-running daemon (leader).
4
- *
5
- * Used when this process starts as a follower: another cli.js is already
6
- * listening on :47729, so we attach as a ws client and proxy every MCP tool
7
- * call through the new `mcp.call` / `mcp.return` control frames.
8
- */
9
- import { WebSocket } from 'ws';
10
- import { randomUUID } from 'node:crypto';
11
- import { frameSchema, } from '@harness-fe/protocol';
12
- const DEFAULT_CALL_TIMEOUT_MS = 30_000;
13
- export class RemoteBridge {
14
- ws;
15
- pending = new Map();
16
- closed = false;
17
- url;
18
- callTimeoutMs;
19
- host;
20
- port;
21
- token;
22
- constructor(opts) {
23
- const host = opts.host ?? '127.0.0.1';
24
- this.host = host;
25
- this.port = opts.port;
26
- this.token = opts.token;
27
- const tokenQs = opts.token ? `?token=${encodeURIComponent(opts.token)}` : '';
28
- this.url = `ws://${host}:${opts.port}${tokenQs}`;
29
- this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
30
- }
31
- async connect() {
32
- return new Promise((resolve, reject) => {
33
- const headers = {};
34
- if (this.token)
35
- headers.authorization = `Bearer ${this.token}`;
36
- const ws = new WebSocket(this.url, { headers });
37
- this.ws = ws;
38
- const onOpen = () => {
39
- ws.off('error', onErr);
40
- this.attachHandlers(ws);
41
- resolve();
42
- };
43
- const onErr = (err) => {
44
- ws.off('open', onOpen);
45
- reject(err);
46
- };
47
- ws.once('open', onOpen);
48
- ws.once('error', onErr);
49
- });
50
- }
51
- async stop() {
52
- this.closed = true;
53
- const ws = this.ws;
54
- if (!ws)
55
- return;
56
- try {
57
- ws.close();
58
- }
59
- catch {
60
- /* swallow */
61
- }
62
- }
63
- sendCommand(command, args, opts) {
64
- return this.invoke('sendCommand', { command, args, opts });
65
- }
66
- listTabs() {
67
- return this.invoke('listTabs', {});
68
- }
69
- listTasks(filter = {}) {
70
- return this.invoke('listTasks', filter);
71
- }
72
- claimTask(id) {
73
- return this.invoke('claimTask', { id });
74
- }
75
- resolveTask(id, note) {
76
- return this.invoke('resolveTask', { id, note });
77
- }
78
- /**
79
- * Returns a RemoteMemoryStore that proxies all memory operations to the
80
- * leader via the mcp.call channel. This allows follower instances to use
81
- * the same project.memory.* tools as the leader.
82
- */
83
- getMemoryStore() {
84
- return new RemoteMemoryStore(this);
85
- }
86
- getViewerBaseUrl() {
87
- // Followers share the same WS/HTTP port as the leader.
88
- return `http://${this.host}:${this.port}`;
89
- }
90
- getAuthToken() {
91
- // Followers connect to the leader using their own configured token
92
- // (passed in via RemoteBridge constructor). Surface it so dashboard
93
- // links the follower hands out are pre-authenticated.
94
- return this.token;
95
- }
96
- async getTaskAttachmentData(_taskId, _attachmentId) {
97
- // Follower mode: attachment reads are not proxied in v0.6; direct leader access needed.
98
- return null;
99
- }
100
- /**
101
- * Returns a RemoteStore that proxies all store read/query operations to
102
- * the leader via the mcp.call channel. Write operations (openSession,
103
- * append, etc.) are not proxied — followers are read-only.
104
- */
105
- getStore() {
106
- return new RemoteStore(this);
107
- }
108
- /** @internal — used by RemoteMemoryStore and RemoteStore */
109
- invokeRemote(method, args) {
110
- return this.invoke(method, args);
111
- }
112
- invoke(method, args) {
113
- const ws = this.ws;
114
- if (!ws || ws.readyState !== WebSocket.OPEN) {
115
- return Promise.reject(new Error('remote-bridge: not connected'));
116
- }
117
- const id = randomUUID();
118
- const frame = { type: 'mcp.call', id, method, args };
119
- return new Promise((resolve, reject) => {
120
- const timer = setTimeout(() => {
121
- this.pending.delete(id);
122
- reject(new Error(`remote-bridge: "${method}" timed out after ${this.callTimeoutMs}ms`));
123
- }, this.callTimeoutMs);
124
- this.pending.set(id, { resolve, reject, timer });
125
- try {
126
- ws.send(JSON.stringify(frame));
127
- }
128
- catch (err) {
129
- clearTimeout(timer);
130
- this.pending.delete(id);
131
- reject(err);
132
- }
133
- });
134
- }
135
- attachHandlers(ws) {
136
- ws.on('message', (raw) => {
137
- let parsed;
138
- try {
139
- parsed = JSON.parse(raw.toString());
140
- }
141
- catch {
142
- return;
143
- }
144
- const frame = frameSchema.safeParse(parsed);
145
- if (!frame.success)
146
- return;
147
- if (frame.data.type !== 'mcp.return')
148
- return;
149
- this.handleReturn(frame.data);
150
- });
151
- ws.on('close', () => this.handleClose());
152
- ws.on('error', () => {
153
- /* close will follow */
154
- });
155
- }
156
- handleReturn(frame) {
157
- const p = this.pending.get(frame.id);
158
- if (!p)
159
- return;
160
- clearTimeout(p.timer);
161
- this.pending.delete(frame.id);
162
- if (frame.ok) {
163
- p.resolve(frame.result);
164
- }
165
- else {
166
- p.reject(new Error(frame.error?.message ?? 'remote-bridge: unknown error'));
167
- }
168
- }
169
- handleClose() {
170
- const err = new Error(this.closed
171
- ? 'remote-bridge: connection closed'
172
- : 'remote-bridge: lost connection to daemon');
173
- for (const p of this.pending.values()) {
174
- clearTimeout(p.timer);
175
- p.reject(err);
176
- }
177
- this.pending.clear();
178
- }
179
- }
180
- // ─── RemoteMemoryStore ────────────────────────────────────────────────────────
181
- //
182
- // Proxies IMemoryStore operations to the leader via mcp.call frames.
183
- // Used by follower instances so project.memory.* tools work in all windows.
184
- class RemoteMemoryStore {
185
- bridge;
186
- constructor(bridge) {
187
- this.bridge = bridge;
188
- }
189
- get(projectId, key) {
190
- // Synchronous interface — not directly awaitable. The MCP tool layer
191
- // wraps calls in async handlers, so we return a thenable-compatible
192
- // object. In practice mcp.ts awaits the result via the async handler.
193
- // We throw here to signal that callers must use the async path.
194
- throw new Error('RemoteMemoryStore.get() must be called via the async MCP tool handler. ' +
195
- 'Use remoteMemoryStore.getAsync() instead.');
196
- }
197
- /** Async variant used by the MCP tool handlers in mcp.ts. */
198
- async getAsync(projectId, key) {
199
- return this.bridge.invokeRemote('memoryGet', { projectId, key });
200
- }
201
- set(projectId, key, value) {
202
- throw new Error('RemoteMemoryStore.set() must be called via setAsync().');
203
- }
204
- async setAsync(projectId, key, value) {
205
- return this.bridge.invokeRemote('memorySet', { projectId, key, value });
206
- }
207
- delete(projectId, key) {
208
- throw new Error('RemoteMemoryStore.delete() must be called via deleteAsync().');
209
- }
210
- async deleteAsync(projectId, key) {
211
- return this.bridge.invokeRemote('memoryDelete', { projectId, key });
212
- }
213
- list(projectId) {
214
- throw new Error('RemoteMemoryStore.list() must be called via listAsync().');
215
- }
216
- async listAsync(projectId) {
217
- return this.bridge.invokeRemote('memoryList', { projectId });
218
- }
219
- }
220
- // ─── RemoteStore ──────────────────────────────────────────────────────────────
221
- //
222
- // Proxies IStore read operations to the leader via mcp.call frames.
223
- // Write operations throw — followers are read-only for the store.
224
- class RemoteStore {
225
- bridge;
226
- constructor(bridge) {
227
- this.bridge = bridge;
228
- }
229
- // ── Read operations (proxied) ──────────────────────────────────────────
230
- async listProjectsAsync() {
231
- return this.bridge.invokeRemote('storeListProjects', {});
232
- }
233
- async listSessionsAsync(opts) {
234
- return this.bridge.invokeRemote('storeListSessions', opts ?? {});
235
- }
236
- async summaryAsync(sessionId) {
237
- return this.bridge.invokeRemote('storeSummary', { sessionId });
238
- }
239
- async tailAsync(sessionId, opts) {
240
- return this.bridge.invokeRemote('storeTail', { sessionId, opts });
241
- }
242
- async searchAsync(sessionId, query, opts) {
243
- return this.bridge.invokeRemote('storeSearch', { sessionId, query, opts });
244
- }
245
- async listRecordingsAsync(sessionId) {
246
- return this.bridge.invokeRemote('storeRecordingsList', { sessionId });
247
- }
248
- async sliceRecordingsAsync(sessionId, since, until) {
249
- return this.bridge.invokeRemote('storeRecordingsSlice', { sessionId, since, until });
250
- }
251
- async replayCreateAsync(args) {
252
- return this.bridge.invokeRemote('storeReplayCreate', args);
253
- }
254
- async purgeAsync(policy) {
255
- return this.bridge.invokeRemote('storePurge', policy ?? {});
256
- }
257
- // ── Synchronous IStore interface stubs (not used by follower) ─────────
258
- // These satisfy the interface but throw — the MCP tool handlers in mcp.ts
259
- // use the async variants above when running in follower mode.
260
- // Build lifecycle
261
- openBuild(_p, _patch) { throw notSupported('openBuild'); }
262
- closeBuild(_b, _c) { throw notSupported('closeBuild'); }
263
- // Tab lifecycle
264
- upsertTab(_t, _patch) { throw notSupported('upsertTab'); }
265
- getTab(_t) { throw notSupported('getTab'); }
266
- closeTab(_t, _d) { throw notSupported('closeTab'); }
267
- // Session lifecycle
268
- upsertSession(_s, _m) { throw notSupported('upsertSession'); }
269
- closeSession(_s, _e) { throw notSupported('closeSession'); }
270
- getSession(_id) { throw notSupported('getSession'); }
271
- listSessions(_opts) { throw notSupported('listSessions'); }
272
- // Write
273
- appendEvent(_s, _e) { throw notSupported('appendEvent'); }
274
- appendEventBatch(_s, _e) { throw notSupported('appendEventBatch'); }
275
- appendRecording(_s, _c) { throw notSupported('appendRecording'); }
276
- writeNote(_p, _k, _v) { throw notSupported('writeNote'); }
277
- // Project metadata
278
- listProjects() { throw notSupported('listProjects'); }
279
- upsertProject(_p, _patch) { throw notSupported('upsertProject'); }
280
- getProject(_p) { throw notSupported('getProject'); }
281
- getProjectTree(_r) { throw notSupported('getProjectTree'); }
282
- // Build metadata
283
- upsertBuild(_p, _b, _patch) { throw notSupported('upsertBuild'); }
284
- getBuild(_p, _b) { throw notSupported('getBuild'); }
285
- listBuilds(_p, _l) { throw notSupported('listBuilds'); }
286
- // Visitor metadata (0.5+) — followers don't proxy yet; leader-only for now.
287
- upsertVisitor(_v, _patch) { throw notSupported('upsertVisitor'); }
288
- getVisitor(_v) { throw notSupported('getVisitor'); }
289
- listVisitors(_opts) { throw notSupported('listVisitors'); }
290
- // Read
291
- tail(_s, _o) { throw notSupported('tail'); }
292
- search(_s, _q, _o) { throw notSupported('search'); }
293
- listRecordings(_s) { throw notSupported('listRecordings'); }
294
- sliceRecordings(_s, _since, _until) { throw notSupported('sliceRecordings'); }
295
- writeExport(_i) { throw notSupported('writeExport'); }
296
- getExport(_id) { throw notSupported('getExport'); }
297
- readExportEvents(_id) { throw notSupported('readExportEvents'); }
298
- listExports(_p, _l) { throw notSupported('listExports'); }
299
- summary(_s) { throw notSupported('summary'); }
300
- listNotes(_p) { throw notSupported('listNotes'); }
301
- // Maintenance
302
- purge(_p) { throw notSupported('purge'); }
303
- close() { }
304
- }
305
- function notSupported(method) {
306
- return new Error(`remote-bridge: IStore.${method}() is not available in follower mode`);
307
- }
@@ -1,36 +0,0 @@
1
- /**
2
- * Shared replay-export logic used by both the leader's MCP tool handler and
3
- * the leader's mcp.call dispatcher (for follower proxy calls).
4
- *
5
- * Takes a time window (or a center timestamp), pulls the overlapping rrweb
6
- * chunks for a single tab, concatenates the events, persists them as an
7
- * export, and returns the metadata + viewerUrl.
8
- */
9
- import type { IStore } from './store/index.js';
10
- export interface ReplayCreateArgs {
11
- sessionId: string;
12
- tabId?: string;
13
- ts?: number;
14
- windowMs?: number;
15
- since?: number;
16
- until?: number;
17
- label?: string;
18
- }
19
- export interface ReplayCreateResult {
20
- exportId?: string;
21
- viewerUrl?: string;
22
- sessionId: string;
23
- tabId?: string;
24
- since: number;
25
- until: number;
26
- startTs?: number;
27
- endTs?: number;
28
- durationMs?: number;
29
- eventCount?: number;
30
- chunkCount?: number;
31
- bytes?: number;
32
- createdAt?: number;
33
- label?: string;
34
- error?: string;
35
- }
36
- export declare function createReplayExport(store: IStore, baseUrl: string | undefined, input: ReplayCreateArgs): ReplayCreateResult;
@@ -1,156 +0,0 @@
1
- /**
2
- * Shared replay-export logic used by both the leader's MCP tool handler and
3
- * the leader's mcp.call dispatcher (for follower proxy calls).
4
- *
5
- * Takes a time window (or a center timestamp), pulls the overlapping rrweb
6
- * chunks for a single tab, concatenates the events, persists them as an
7
- * export, and returns the metadata + viewerUrl.
8
- */
9
- /** rrweb event types: 2 = FullSnapshot baseline. Replay needs at least one. */
10
- function isFullSnapshotEvent(ev) {
11
- return (typeof ev === 'object' &&
12
- ev !== null &&
13
- ev.type === 2);
14
- }
15
- export function createReplayExport(store, baseUrl, input) {
16
- const { sessionId, tabId, ts, windowMs, since, until, label } = input;
17
- const session = store.getSession(sessionId);
18
- if (!session) {
19
- return { error: 'session not found', sessionId, since: since ?? 0, until: until ?? 0 };
20
- }
21
- let resolvedSince;
22
- let resolvedUntil;
23
- if (typeof since === 'number' && typeof until === 'number') {
24
- if (until <= since) {
25
- return { error: 'until must be greater than since', sessionId, since, until };
26
- }
27
- resolvedSince = since;
28
- resolvedUntil = until;
29
- }
30
- else if (typeof ts === 'number') {
31
- const radius = windowMs ?? 15_000;
32
- resolvedSince = ts - radius;
33
- resolvedUntil = ts + radius;
34
- }
35
- else {
36
- return {
37
- error: 'must provide either ts (with optional windowMs) or both since and until',
38
- sessionId,
39
- since: 0,
40
- until: 0,
41
- };
42
- }
43
- const chunks = store.sliceRecordings(sessionId, resolvedSince, resolvedUntil);
44
- if (chunks.length === 0) {
45
- return {
46
- error: 'no rrweb chunks found in window',
47
- sessionId,
48
- tabId,
49
- since: resolvedSince,
50
- until: resolvedUntil,
51
- };
52
- }
53
- let scopedTabId = tabId;
54
- if (!scopedTabId) {
55
- const byTab = new Map();
56
- for (const c of chunks)
57
- byTab.set(c.tabId, (byTab.get(c.tabId) ?? 0) + c.eventCount);
58
- let best = '';
59
- let bestEvents = -1;
60
- for (const [t, count] of byTab) {
61
- if (count > bestEvents) {
62
- best = t;
63
- bestEvents = count;
64
- }
65
- }
66
- scopedTabId = best;
67
- }
68
- const tabChunks = chunks.filter((c) => c.tabId === scopedTabId);
69
- const events = [];
70
- let startTs = Infinity;
71
- let endTs = -Infinity;
72
- for (const c of tabChunks) {
73
- for (const ev of c.events)
74
- events.push(ev);
75
- if (c.startTs < startTs)
76
- startTs = c.startTs;
77
- if (c.endTs > endTs)
78
- endTs = c.endTs;
79
- }
80
- // rrweb replay requires a baseline pair — type:4 (Meta) + type:2
81
- // (FullSnapshot) — before any type:3 (IncrementalSnapshot) is meaningful.
82
- // If the window only contains incremental mutations (e.g. user picked a
83
- // narrow window long after the page loaded, or the very first chunk was
84
- // lost during a daemon restart), look back across earlier chunks for the
85
- // most recent baseline and prepend it. Replay will then start from that
86
- // earlier DOM state and roll mutations forward into the window.
87
- if (!events.some(isFullSnapshotEvent) && resolvedSince > 0) {
88
- const priorChunks = store
89
- .sliceRecordings(sessionId, 0, resolvedSince - 1)
90
- .filter((c) => c.tabId === scopedTabId)
91
- .sort((a, b) => a.startTs - b.startTs);
92
- // Walk backwards from the chunk closest to window start; the first
93
- // chunk that has a FullSnapshot becomes our baseline.
94
- for (let i = priorChunks.length - 1; i >= 0; i--) {
95
- const baseline = priorChunks[i];
96
- if (!baseline)
97
- continue;
98
- if (baseline.events.some(isFullSnapshotEvent)) {
99
- // Prepend baseline events (full chunk — preserves Meta + FS
100
- // ordering rrweb emitted them in). startTs widens to baseline.
101
- events.unshift(...baseline.events);
102
- if (baseline.startTs < startTs)
103
- startTs = baseline.startTs;
104
- break;
105
- }
106
- }
107
- }
108
- if (events.length < 2) {
109
- return {
110
- error: 'window contains fewer than 2 rrweb events — not enough to replay',
111
- sessionId,
112
- tabId: scopedTabId,
113
- since: resolvedSince,
114
- until: resolvedUntil,
115
- eventCount: events.length,
116
- };
117
- }
118
- if (!events.some(isFullSnapshotEvent)) {
119
- return {
120
- error: 'window contains no rrweb FullSnapshot (type:2) baseline, and no earlier baseline could be found — replay would be blank',
121
- sessionId,
122
- tabId: scopedTabId,
123
- since: resolvedSince,
124
- until: resolvedUntil,
125
- eventCount: events.length,
126
- };
127
- }
128
- const meta = store.writeExport({
129
- sessionId,
130
- tabId: scopedTabId,
131
- since: resolvedSince,
132
- until: resolvedUntil,
133
- label,
134
- events,
135
- startTs,
136
- endTs,
137
- chunkCount: tabChunks.length,
138
- });
139
- const viewerUrl = baseUrl ? `${baseUrl}/replay/${meta.exportId}` : undefined;
140
- return {
141
- exportId: meta.exportId,
142
- viewerUrl,
143
- sessionId,
144
- tabId: scopedTabId,
145
- since: resolvedSince,
146
- until: resolvedUntil,
147
- startTs,
148
- endTs,
149
- durationMs: endTs - startTs,
150
- eventCount: meta.eventCount,
151
- chunkCount: meta.chunkCount,
152
- bytes: meta.bytes,
153
- createdAt: meta.createdAt,
154
- label,
155
- };
156
- }
@@ -1,20 +0,0 @@
1
- /**
2
- * Replay viewer — HTTP routes for serving exported rrweb recordings.
3
- *
4
- * Routes (all served on the same port as the WS bridge):
5
- * GET /replay/:id HTML viewer page (rrweb-player UI)
6
- * GET /replay/:id.json Raw events array for the player to fetch
7
- * GET /replay/static/player.js Bundled rrweb-player (UMD)
8
- * GET /replay/static/player.css Bundled rrweb-player styles
9
- *
10
- * The HTML page loads the static assets relatively and then calls
11
- * /replay/:id.json to hydrate the player. This keeps the viewer self-contained
12
- * and offline-capable — no CDN dependencies.
13
- */
14
- import type { IncomingMessage, ServerResponse } from 'node:http';
15
- import type { IStore } from './store/index.js';
16
- /**
17
- * Build a handler that dispatches /replay/* requests. Returns undefined for
18
- * non-replay paths so the caller can chain or 404.
19
- */
20
- export declare function createReplayHandler(store: IStore): (req: IncomingMessage, res: ServerResponse) => boolean;