repoburg 1.3.255 → 1.3.256

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 (75) hide show
  1. package/daemon/dist/index.js +61 -3
  2. package/daemon/dist/staticServing.js +2 -2
  3. package/daemon/dist/tabtape/api/follow.js +174 -0
  4. package/daemon/dist/tabtape/api/http.js +221 -0
  5. package/daemon/dist/tabtape/api/record.js +149 -0
  6. package/daemon/dist/tabtape/api/router.js +519 -0
  7. package/daemon/dist/tabtape/api/router.spec.js +604 -0
  8. package/daemon/dist/tabtape/api/static.js +70 -0
  9. package/daemon/dist/tabtape/api/ws.js +166 -0
  10. package/daemon/dist/tabtape/buffer/buffer.js +347 -0
  11. package/daemon/dist/tabtape/buffer/buffer.spec.js +137 -0
  12. package/daemon/dist/tabtape/config.js +108 -0
  13. package/daemon/dist/tabtape/e2e-server.js +96 -0
  14. package/daemon/dist/tabtape/errors.js +77 -0
  15. package/daemon/dist/tabtape/export/export.js +331 -0
  16. package/daemon/dist/tabtape/export/export.spec.js +262 -0
  17. package/daemon/dist/tabtape/har/gostd.js +395 -0
  18. package/daemon/dist/tabtape/har/gostd.spec.js +139 -0
  19. package/daemon/dist/tabtape/har/har.js +582 -0
  20. package/daemon/dist/tabtape/har/har.spec.js +325 -0
  21. package/daemon/dist/tabtape/index.js +134 -0
  22. package/daemon/dist/tabtape/index.spec.js +104 -0
  23. package/daemon/dist/tabtape/lock.js +67 -0
  24. package/daemon/dist/tabtape/log.js +32 -0
  25. package/daemon/dist/tabtape/mcp/definitions.js +362 -0
  26. package/daemon/dist/tabtape/mcp/gofmt.js +227 -0
  27. package/daemon/dist/tabtape/mcp/params.js +345 -0
  28. package/daemon/dist/tabtape/mcp/params.spec.js +172 -0
  29. package/daemon/dist/tabtape/mcp/recordLink.js +122 -0
  30. package/daemon/dist/tabtape/mcp/server.js +411 -0
  31. package/daemon/dist/tabtape/mcp/server.spec.js +722 -0
  32. package/daemon/dist/tabtape/mcp/tools.js +1085 -0
  33. package/daemon/dist/tabtape/query/format.js +195 -0
  34. package/daemon/dist/tabtape/query/network.js +133 -0
  35. package/daemon/dist/tabtape/query/perf.js +93 -0
  36. package/daemon/dist/tabtape/query/query.spec.js +233 -0
  37. package/daemon/dist/tabtape/query/reader.js +602 -0
  38. package/daemon/dist/tabtape/query/types.js +274 -0
  39. package/daemon/dist/tabtape/redact.js +37 -0
  40. package/daemon/dist/tabtape/repro/lang.js +95 -0
  41. package/daemon/dist/tabtape/repro/repro.js +422 -0
  42. package/daemon/dist/tabtape/repro/repro.spec.js +210 -0
  43. package/daemon/dist/tabtape/sink.js +24 -0
  44. package/daemon/dist/tabtape/store/record.js +99 -0
  45. package/daemon/dist/tabtape/store/segment.js +267 -0
  46. package/daemon/dist/tabtape/store/store.js +846 -0
  47. package/daemon/dist/tabtape/store/store.spec.js +485 -0
  48. package/daemon/dist/tabtape/testing/querytest.js +119 -0
  49. package/daemon/dist/tabtape/testing/tmp.js +33 -0
  50. package/package.json +3 -2
  51. package/tabtape/extension/dist/background.js +8 -0
  52. package/tabtape/extension/dist/background.js.map +7 -0
  53. package/tabtape/extension/dist/bridge.js +3 -0
  54. package/tabtape/extension/dist/bridge.js.map +7 -0
  55. package/tabtape/extension/dist/icons/128.png +0 -0
  56. package/tabtape/extension/dist/icons/16.png +0 -0
  57. package/tabtape/extension/dist/icons/32.png +0 -0
  58. package/tabtape/extension/dist/icons/48.png +0 -0
  59. package/tabtape/extension/dist/manifest.json +28 -0
  60. package/tabtape/extension/dist/popup.css +62 -0
  61. package/tabtape/extension/dist/popup.html +34 -0
  62. package/tabtape/extension/dist/popup.js +4 -0
  63. package/tabtape/extension/dist/popup.js.map +7 -0
  64. package/tabtape/extension/dist/recorder.js +133 -0
  65. package/tabtape/extension/dist/recorder.js.map +1 -0
  66. package/tabtape/extension/dist/state.js +2 -0
  67. package/tabtape/extension/dist/state.js.map +1 -0
  68. package/tabtape/playwright/mcp.mjs +58 -0
  69. package/tabtape/playwright/tabtape.d.mts +37 -0
  70. package/tabtape/playwright/tabtape.mjs +146 -0
  71. package/tabtape/ui/dist/app/assets/index-Canv3Ljk.css +1 -0
  72. package/tabtape/ui/dist/app/assets/index-iWNSSrAE.js +145 -0
  73. package/tabtape/ui/dist/app/index.html +14 -0
  74. package/tabtape/ui/dist/export/viewer.css +2 -0
  75. package/tabtape/ui/dist/export/viewer.js +143 -0
@@ -0,0 +1,604 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const express_1 = __importDefault(require("express"));
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const http_1 = __importDefault(require("http"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const zlib_1 = __importDefault(require("zlib"));
11
+ const ws_1 = __importDefault(require("ws"));
12
+ const buffer_1 = require("../buffer/buffer");
13
+ const log_1 = require("../log");
14
+ const reader_1 = require("../query/reader");
15
+ const redact_1 = require("../redact");
16
+ const record_1 = require("../store/record");
17
+ const store_1 = require("../store/store");
18
+ const tmp_1 = require("../testing/tmp");
19
+ const follow_1 = require("./follow");
20
+ const router_1 = require("./router");
21
+ const ws_2 = require("./ws");
22
+ const MOUNT = '/tabtape';
23
+ const servers = [];
24
+ async function serve(o = {}) {
25
+ const dir = (0, tmp_1.tempDir)();
26
+ const st = store_1.Store.open({ dir, maxBytes: 1 << 20, segmentBytes: o.segmentBytes ?? 1 << 16, logger: log_1.silentLogger });
27
+ const buffers = o.buffers ? buffer_1.BufferStore.open({ dir: path_1.default.join(dir, 'buffer'), maxBytes: 1 << 20, logger: log_1.silentLogger }) : null;
28
+ let stopNow;
29
+ const stop = new Promise((r) => (stopNow = r));
30
+ const followers = new follow_1.Followers();
31
+ const router = (0, router_1.createApiRouter)({
32
+ st,
33
+ rd: new reader_1.Reader(st),
34
+ buffers,
35
+ version: o.version ?? 'test',
36
+ baseURL: 'http://127.0.0.1:9998/tabtape',
37
+ mount: MOUNT,
38
+ redact: new redact_1.RedactHeaders(),
39
+ uiDist: o.uiDist ?? null,
40
+ viewer: () => o.viewer ?? null,
41
+ mcp: o.mcp ?? null,
42
+ recordToken: o.recordToken ?? '',
43
+ followers,
44
+ stop,
45
+ logger: log_1.silentLogger,
46
+ maxBatchBytes: o.maxBatchBytes,
47
+ timeZone: 'UTC',
48
+ keepAliveMs: o.keepAliveMs,
49
+ });
50
+ const upgrades = new ws_2.FollowUpgrade({ st, followers, stop, mount: MOUNT, logger: log_1.silentLogger, keepAliveMs: o.keepAliveMs });
51
+ const app = (0, express_1.default)();
52
+ app.use(MOUNT, router);
53
+ app.use((_req, res) => res.status(418).end('not tabtape'));
54
+ const server = http_1.default.createServer(app);
55
+ server.on('upgrade', (req, socket, head) => (upgrades.matches(req) ? upgrades.handle(req, socket, head) : socket.destroy()));
56
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
57
+ const s = {
58
+ port: server.address().port,
59
+ st,
60
+ stop: stopNow,
61
+ close: async () => {
62
+ stopNow();
63
+ upgrades.close();
64
+ server.closeAllConnections();
65
+ await new Promise((resolve) => server.close(() => resolve()));
66
+ buffers?.close();
67
+ await st.close();
68
+ },
69
+ };
70
+ servers.push(s);
71
+ return s;
72
+ }
73
+ afterEach(async () => {
74
+ while (servers.length)
75
+ await servers.pop().close();
76
+ (0, tmp_1.cleanupTempDirs)();
77
+ });
78
+ const client = { 'X-Tabtape-Client': 'test' };
79
+ function request(s, method, p, o = {}) {
80
+ return new Promise((resolve, reject) => {
81
+ const req = http_1.default.request({ host: '127.0.0.1', port: s.port, method, path: p, headers: { Host: o.host ?? `127.0.0.1:${s.port}`, ...o.headers } }, (res) => {
82
+ const parts = [];
83
+ res.on('data', (c) => parts.push(c));
84
+ res.on('end', () => {
85
+ const raw = Buffer.concat(parts);
86
+ resolve({ status: res.statusCode ?? 0, headers: res.headers, body: raw.toString('utf8'), raw });
87
+ });
88
+ res.on('error', reject);
89
+ });
90
+ req.on('error', reject);
91
+ req.end(o.body);
92
+ });
93
+ }
94
+ const get = (s, p) => request(s, 'GET', MOUNT + p);
95
+ function appendEvent(st, id, ts) {
96
+ st.append(id, [(0, record_1.parseRecord)(`{"ts":${ts},"type":"event","data":{"name":"click","pad":"${'x'.repeat(300)}"}}`)]);
97
+ }
98
+ describe('sessions API', () => {
99
+ it('runs a session through its lifecycle', async () => {
100
+ const s = await serve();
101
+ let r = await request(s, 'POST', `${MOUNT}/api/sessions`, {
102
+ headers: client,
103
+ body: '{"id":"s1","startedAt":1000,"url":"https://a.test/","title":"A","userAgent":"UA"}',
104
+ });
105
+ expect(r.status).toBe(201);
106
+ const batch = '{"ts":1001,"type":"rrweb","data":{"type":4,"data":{"width":800,"height":600}}}\n' + '{"ts":1002,"type":"event","data":{"name":"click","url":"https://a.test/"}}\n';
107
+ r = await request(s, 'POST', `${MOUNT}/api/sessions/s1/events`, { headers: client, body: batch });
108
+ expect(r.status).toBe(200);
109
+ const ack = JSON.parse(r.body);
110
+ expect(ack.accepted).toBe(2);
111
+ expect(ack.bytes).toBeGreaterThan(0);
112
+ r = await get(s, '/api/sessions');
113
+ const list = JSON.parse(r.body);
114
+ expect(list.length).toBe(1);
115
+ expect([list[0].id, list[0].counts.total, list[0].counts.clicks, list[0].title]).toEqual(['s1', 2, 1, 'A']);
116
+ r = await get(s, '/api/sessions/s1/events');
117
+ expect(r.status).toBe(200);
118
+ expect(r.headers['content-type']).toBe('application/x-ndjson');
119
+ const lines = r.body.trim().split('\n');
120
+ expect(lines.length).toBe(2);
121
+ expect(lines[0].startsWith('{"sid":"s1","ts":1001,')).toBe(true);
122
+ r = await request(s, 'POST', `${MOUNT}/api/sessions/s1/end`, { headers: client, body: '{"endedAt":1500}' });
123
+ expect(r.status).toBe(200);
124
+ r = await get(s, '/api/sessions/s1');
125
+ expect(JSON.parse(r.body).endedAt).toBe(1500);
126
+ r = await request(s, 'DELETE', `${MOUNT}/api/sessions/s1`, { headers: client });
127
+ expect(r.status).toBe(204);
128
+ r = await get(s, '/api/sessions/s1/events');
129
+ expect(r.status).toBe(404);
130
+ });
131
+ it('reports health', async () => {
132
+ const s = await serve({ version: '1.2.3' });
133
+ const h = JSON.parse((await get(s, '/api/health')).body);
134
+ expect([h.ok, h.version, h.maxBytes]).toEqual([true, '1.2.3', 1 << 20]);
135
+ expect(h.redactHeaders).toContain('authorization');
136
+ });
137
+ it('serves loopback hosts only, never tunnel traffic', async () => {
138
+ const s = await serve();
139
+ expect((await request(s, 'GET', `${MOUNT}/api/sessions`, { host: 'evil.example:9998' })).status).toBe(403);
140
+ expect((await request(s, 'GET', `${MOUNT}/api/sessions`, { host: 'workstation.repoburg.com' })).status).toBe(403);
141
+ for (const host of ['localhost:9998', '127.0.0.1:9998', '[::1]:9998', 'LOCALHOST']) {
142
+ expect((await request(s, 'GET', `${MOUNT}/api/sessions`, { host })).status).toBe(200);
143
+ }
144
+ const tunneled = await request(s, 'GET', `${MOUNT}/api/sessions`, { headers: { 'CF-Ray': '8a1b2c3d4e5f-AMS' } });
145
+ expect(tunneled.status).toBe(403);
146
+ });
147
+ it('requires the client header on writes', async () => {
148
+ const s = await serve();
149
+ expect((await request(s, 'POST', `${MOUNT}/api/sessions/s1/events`, { body: '{"ts":1,"type":"rrweb"}' })).status).toBe(403);
150
+ expect((await request(s, 'DELETE', `${MOUNT}/api/sessions/s1`)).status).toBe(403);
151
+ });
152
+ it('answers CORS for the extension only', async () => {
153
+ const s = await serve();
154
+ let r = await request(s, 'OPTIONS', `${MOUNT}/api/sessions/s1/events`, {
155
+ headers: { Origin: 'chrome-extension://abcdef', 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'content-type,x-tabtape-client' },
156
+ });
157
+ expect(r.status).toBe(204);
158
+ expect(r.headers['access-control-allow-origin']).toBe('chrome-extension://abcdef');
159
+ for (const origin of ['https://evil.example', 'http://localhost:3000', 'https://workstation.repoburg.com']) {
160
+ r = await request(s, 'GET', `${MOUNT}/api/sessions`, { headers: { Origin: origin } });
161
+ expect(r.status).toBe(200);
162
+ expect(r.headers['access-control-allow-origin']).toBeUndefined();
163
+ }
164
+ });
165
+ it('validates ingested batches', async () => {
166
+ const s = await serve({ maxBatchBytes: 200 });
167
+ const cases = [
168
+ ['/api/sessions/s1/events', '{"ts":1,"type":"rrweb"}\nnot json\n', 400],
169
+ ['/api/sessions/bad.id/events', '{"ts":1,"type":"rrweb"}', 400],
170
+ ['/api/sessions/s1/events', `{"ts":1,"type":"rrweb","data":"${'x'.repeat(300)}"}`, 413],
171
+ ['/api/sessions/s1/events', '', 200],
172
+ ];
173
+ for (const [p, body, want] of cases) {
174
+ expect((await request(s, 'POST', MOUNT + p, { headers: client, body })).status).toBe(want);
175
+ }
176
+ });
177
+ it('answers 404 for unknown sessions', async () => {
178
+ const s = await serve();
179
+ expect((await get(s, '/api/sessions/nope')).status).toBe(404);
180
+ expect((await get(s, '/api/sessions/nope/events')).status).toBe(404);
181
+ expect((await request(s, 'POST', `${MOUNT}/api/sessions/nope/end`, { headers: client, body: '{}' })).status).toBe(404);
182
+ expect((await request(s, 'DELETE', `${MOUNT}/api/sessions/nope`, { headers: client })).status).toBe(404);
183
+ });
184
+ it('downloads the JSONL gzip-compressed', async () => {
185
+ const s = await serve({ segmentBytes: 1 << 10 });
186
+ for (let i = 0; i < 3; i++)
187
+ appendEvent(s.st, 'a', i);
188
+ const plain = (await get(s, '/api/sessions/a/events')).raw;
189
+ const gz = await get(s, '/api/sessions/a/events?gzip=1');
190
+ expect(gz.status).toBe(200);
191
+ expect(gz.headers['content-disposition']).toBe('attachment; filename="a.jsonl.gz"');
192
+ expect(zlib_1.default.gunzipSync(gz.raw).equals(plain)).toBe(true);
193
+ expect((await get(s, '/api/sessions/nope/events?gzip=1')).status).toBe(404);
194
+ });
195
+ });
196
+ describe('replay UI', () => {
197
+ it('serves the build, or a placeholder', async () => {
198
+ const dist = (0, tmp_1.tempDir)();
199
+ fs_1.default.mkdirSync(path_1.default.join(dist, 'app', 'assets'), { recursive: true });
200
+ fs_1.default.writeFileSync(path_1.default.join(dist, 'app', 'index.html'), '<html>replay ui</html>');
201
+ fs_1.default.writeFileSync(path_1.default.join(dist, 'app', 'assets', 'app.js'), 'console.log(1)');
202
+ const s = await serve({ uiDist: dist });
203
+ let r = await get(s, '/');
204
+ expect(r.status).toBe(200);
205
+ expect(r.body).toContain('replay ui');
206
+ expect((await get(s, '/assets/app.js')).body).toBe('console.log(1)');
207
+ r = await request(s, 'GET', MOUNT);
208
+ expect(r.status).toBe(301);
209
+ expect(r.headers.location).toBe(`${MOUNT}/`);
210
+ const bare = await serve({ uiDist: (0, tmp_1.tempDir)() });
211
+ r = await get(bare, '/');
212
+ expect(r.status).toBe(200);
213
+ expect(r.body).toContain('npm run build');
214
+ });
215
+ it('keeps everything under the mount to itself', async () => {
216
+ const s = await serve();
217
+ expect((await get(s, '/nope')).status).toBe(404);
218
+ expect((await request(s, 'GET', '/elsewhere')).status).toBe(418);
219
+ });
220
+ });
221
+ describe('record links', () => {
222
+ it('verifies tokens', async () => {
223
+ const s = await serve({ recordToken: 'tok-123' });
224
+ const verify = async (body, headers = client) => (await request(s, 'POST', `${MOUNT}/api/record/verify`, { body, headers })).status;
225
+ expect(await verify('{"token":"tok-123"}')).toBe(204);
226
+ for (const body of ['{"token":"tok-12"}', '{"token":""}', '{}'])
227
+ expect(await verify(body)).toBe(403);
228
+ expect(await verify('{"token":"tok-123"}', {})).toBe(403); // a web page cannot send the client header
229
+ expect(await verify('not json')).toBe(400);
230
+ const off = await serve();
231
+ expect((await request(off, 'POST', `${MOUNT}/api/record/verify`, { body: '{"token":""}', headers: client })).status).toBe(403);
232
+ });
233
+ it('serves the record page under the mount', async () => {
234
+ const s = await serve({ recordToken: 'tok' });
235
+ for (const p of ['/record?token=tok&name=x', '/record/stop?token=tok', '/record?stopped=s1']) {
236
+ const r = await get(s, p);
237
+ expect(r.status).toBe(200);
238
+ expect(r.headers['content-type']).toMatch(/^text\/html/);
239
+ expect(r.body).toContain('<script src="/tabtape/record/record.js"></script>');
240
+ expect(r.headers['referrer-policy']).toBe('no-referrer');
241
+ expect(r.headers['cache-control']).toBe('no-store');
242
+ expect(r.headers['content-security-policy']).toContain("script-src 'self'");
243
+ }
244
+ const js = await get(s, '/record/record.js');
245
+ expect(js.headers['content-type']).toMatch(/^text\/javascript/);
246
+ expect(js.body).toContain('No tabtape extension answered');
247
+ expect(js.body).toContain('const MOUNT = "/tabtape"');
248
+ });
249
+ });
250
+ describe('MCP mount', () => {
251
+ it('needs no client header but a loopback host', async () => {
252
+ let called = false;
253
+ const s = await serve({
254
+ mcp: {
255
+ handle: async (_req, res) => {
256
+ called = true;
257
+ res.writeHead(202);
258
+ res.end();
259
+ },
260
+ },
261
+ });
262
+ expect((await request(s, 'POST', `${MOUNT}/mcp`, { body: '{}' })).status).toBe(202);
263
+ expect(called).toBe(true);
264
+ expect((await request(s, 'POST', `${MOUNT}/mcp`, { body: '{}', host: 'evil.example' })).status).toBe(403);
265
+ });
266
+ });
267
+ describe('buffer API', () => {
268
+ it('buffers, saves and deletes', async () => {
269
+ const s = await serve({ buffers: true });
270
+ const now = Date.now();
271
+ const batch = `{"ts":${now},"type":"rrweb","data":{"type":4,"data":{"width":800,"height":600},"timestamp":${now}}}\n` +
272
+ `{"ts":${now},"type":"event","data":{"name":"page_view","kind":"load","url":"http://localhost/","title":"Home"}}`;
273
+ expect((await request(s, 'POST', `${MOUNT}/api/buffers/tab-1/events?window=60000`, { body: batch })).status).toBe(403);
274
+ expect((await request(s, 'POST', `${MOUNT}/api/buffers/tab-1/events?window=60000`, { body: batch, headers: client })).status).toBe(200);
275
+ const list = JSON.parse((await get(s, '/api/buffers')).body);
276
+ expect(list.length).toBe(1);
277
+ expect([list[0].windowMs, list[0].title]).toEqual([60000, 'Home']);
278
+ let r = await request(s, 'POST', `${MOUNT}/api/buffers/tab-1/save`, { body: '{"minutes":2}', headers: client });
279
+ expect(r.status).toBe(201);
280
+ const sess = JSON.parse(r.body);
281
+ expect([sess.source, sess.title]).toEqual(['buffer', 'Home']);
282
+ expect((await get(s, `/api/sessions/${sess.id}`)).status).toBe(200);
283
+ expect((await request(s, 'DELETE', `${MOUNT}/api/buffers/tab-1`, { headers: client })).status).toBe(204);
284
+ r = await request(s, 'POST', `${MOUNT}/api/buffers/tab-1/save`, { body: '{}', headers: client });
285
+ expect(r.status).toBe(404);
286
+ });
287
+ });
288
+ // ---------- live viewing ----------
289
+ /** A live HTTP stream, line by line. */
290
+ function openFollow(s, id) {
291
+ return new Promise((resolve, reject) => {
292
+ const req = http_1.default.get({ host: '127.0.0.1', port: s.port, path: `${MOUNT}/api/sessions/${id}/events?follow=1` }, (res) => {
293
+ let buf = '';
294
+ const queue = [];
295
+ let waiting = null;
296
+ let ended = false;
297
+ const push = () => {
298
+ while (waiting && (queue.length || ended)) {
299
+ const w = waiting;
300
+ waiting = null;
301
+ w(queue.length ? queue.shift() : null);
302
+ }
303
+ };
304
+ res.setEncoding('utf8');
305
+ res.on('data', (c) => {
306
+ buf += c;
307
+ let i;
308
+ while ((i = buf.indexOf('\n')) >= 0) {
309
+ queue.push(buf.slice(0, i));
310
+ buf = buf.slice(i + 1);
311
+ }
312
+ push();
313
+ });
314
+ res.on('close', () => {
315
+ ended = true;
316
+ push();
317
+ });
318
+ resolve({
319
+ status: res.statusCode ?? 0,
320
+ next: () => new Promise((r, rej) => {
321
+ const timer = setTimeout(() => rej(new Error('no line within 5s')), 5000);
322
+ waiting = (v) => {
323
+ clearTimeout(timer);
324
+ r(v);
325
+ };
326
+ push();
327
+ }),
328
+ close: () => req.destroy(),
329
+ });
330
+ });
331
+ req.on('error', reject);
332
+ });
333
+ }
334
+ async function nextRecord(f) {
335
+ for (;;) {
336
+ const l = await f.next();
337
+ if (l !== '')
338
+ return l;
339
+ }
340
+ }
341
+ const control = (line) => {
342
+ try {
343
+ const v = JSON.parse(line ?? '');
344
+ return v.type === 'tabtape' ? v.data.event : '';
345
+ }
346
+ catch {
347
+ return '';
348
+ }
349
+ };
350
+ describe('live viewing over HTTP', () => {
351
+ it('streams existing, then new lines, then ends', async () => {
352
+ const s = await serve({ segmentBytes: 1 << 10 });
353
+ for (let i = 0; i < 5; i++)
354
+ appendEvent(s.st, 'a', i); // several segments
355
+ const plain = (await get(s, '/api/sessions/a/events')).body;
356
+ const f = await openFollow(s, 'a');
357
+ let backlog = '';
358
+ for (;;) {
359
+ const l = await nextRecord(f);
360
+ if (control(l) === 'live')
361
+ break;
362
+ backlog += l + '\n';
363
+ }
364
+ expect(backlog).toBe(plain);
365
+ appendEvent(s.st, 'a', 10);
366
+ expect(await nextRecord(f)).toContain('"ts":10,');
367
+ s.st.end('a', 0);
368
+ expect(control(await nextRecord(f))).toBe('ended');
369
+ expect(await f.next()).toBeNull();
370
+ });
371
+ it('sends keep-alives on idle streams', async () => {
372
+ const s = await serve({ keepAliveMs: 50 });
373
+ appendEvent(s.st, 'a', 1);
374
+ const f = await openFollow(s, 'a');
375
+ await nextRecord(f);
376
+ expect(control(await nextRecord(f))).toBe('live');
377
+ expect(await f.next()).toBe('');
378
+ appendEvent(s.st, 'a', 2);
379
+ expect(await nextRecord(f)).toContain('"ts":2,');
380
+ f.close();
381
+ });
382
+ it('limits viewers and reports them to the extension', async () => {
383
+ const s = await serve();
384
+ appendEvent(s.st, 'a', 1);
385
+ appendEvent(s.st, 'b', 1);
386
+ const open = [];
387
+ for (let i = 0; i < follow_1.MAX_FOLLOWERS; i++) {
388
+ const f = await openFollow(s, i % 2 ? 'b' : 'a');
389
+ expect(f.status).toBe(200);
390
+ await nextRecord(f);
391
+ open.push(f);
392
+ }
393
+ const extra = await openFollow(s, 'a');
394
+ expect(extra.status).toBe(429);
395
+ const r = await request(s, 'POST', `${MOUNT}/api/sessions/a/events`, { headers: client, body: '{"ts":5,"type":"event","data":{}}' });
396
+ expect(JSON.parse(r.body).followers).toBe(follow_1.MAX_FOLLOWERS / 2);
397
+ open[0].close();
398
+ const deadline = Date.now() + 5000;
399
+ for (;;) {
400
+ const f = await openFollow(s, 'a');
401
+ if (f.status === 200) {
402
+ f.close();
403
+ break;
404
+ }
405
+ if (Date.now() > deadline)
406
+ throw new Error('no slot freed after a viewer left');
407
+ await new Promise((resolve) => setTimeout(resolve, 10));
408
+ }
409
+ for (const f of open)
410
+ f.close();
411
+ });
412
+ it('ends on delete and shutdown', async () => {
413
+ const s = await serve();
414
+ appendEvent(s.st, 'a', 1);
415
+ appendEvent(s.st, 'b', 1);
416
+ const a = await openFollow(s, 'a');
417
+ const b = await openFollow(s, 'b');
418
+ await nextRecord(a);
419
+ await nextRecord(b);
420
+ expect(control(await nextRecord(a))).toBe('live');
421
+ expect(control(await nextRecord(b))).toBe('live');
422
+ s.st.delete('a');
423
+ expect(await nextRecord(a)).toBeNull();
424
+ s.stop();
425
+ expect(await nextRecord(b)).toBeNull();
426
+ expect((await openFollow(s, 'nope')).status).toBe(404);
427
+ });
428
+ it('reports data deleted before the viewer read it', async () => {
429
+ const s = await serve({ segmentBytes: 1 << 10 });
430
+ for (let i = 0; i < 8; i++)
431
+ appendEvent(s.st, 'a', i);
432
+ const { segments } = s.st.snapshot('a');
433
+ expect(segments.length).toBeGreaterThanOrEqual(2);
434
+ const f = new follow_1.Follower();
435
+ const sink = () => { };
436
+ expect(await f.send(segments.slice(0, 1), sink)).toBe(false);
437
+ // Pretend the viewer read only half of the first segment, which the ring buffer then deleted.
438
+ f.off = Math.floor(f.off / 2);
439
+ expect(await f.send(segments.slice(1), sink)).toBe(true);
440
+ expect(await f.send(segments.slice(1), sink)).toBe(false);
441
+ });
442
+ });
443
+ function openWS(s, id, origin) {
444
+ return new Promise((resolve, reject) => {
445
+ const ws = new ws_1.default(`ws://127.0.0.1:${s.port}${MOUNT}/api/sessions/${id}/events?follow=1`, {
446
+ origin: origin ?? `http://127.0.0.1:${s.port}`,
447
+ });
448
+ const lines = [];
449
+ let wake = null;
450
+ const closed = new Promise((r) => ws.on('close', (code, reason) => r({ code, reason: reason.toString() })));
451
+ ws.on('message', (data) => {
452
+ const text = data.toString();
453
+ expect(text.endsWith('\n')).toBe(true); // complete lines only
454
+ for (const l of text.split('\n').slice(0, -1))
455
+ lines.push(l);
456
+ wake?.();
457
+ });
458
+ ws.on('unexpected-response', (_req, res) => reject(new Error(`HTTP ${res.statusCode}`)));
459
+ ws.on('open', () => resolve({
460
+ ws,
461
+ lines,
462
+ closed,
463
+ nextLine: async () => {
464
+ const deadline = Date.now() + 5000;
465
+ while (!lines.length) {
466
+ if (Date.now() > deadline)
467
+ throw new Error('no line within 5s');
468
+ await new Promise((r) => {
469
+ wake = r;
470
+ setTimeout(r, 50);
471
+ });
472
+ }
473
+ return lines.shift();
474
+ },
475
+ }));
476
+ ws.on('error', () => { });
477
+ });
478
+ }
479
+ describe('live viewing over WebSocket', () => {
480
+ it('streams the session like the HTTP stream', async () => {
481
+ const s = await serve({ segmentBytes: 1 << 10 });
482
+ for (let i = 0; i < 5; i++)
483
+ appendEvent(s.st, 'a', i);
484
+ // A record far larger than a read chunk still arrives whole.
485
+ s.st.append('a', [(0, record_1.parseRecord)(`{"ts":6,"type":"rrweb","data":{"type":3,"pad":"${'y'.repeat(600000)}"}}`)]);
486
+ const plain = (await get(s, '/api/sessions/a/events')).body;
487
+ const c = await openWS(s, 'a');
488
+ let backlog = '';
489
+ for (;;) {
490
+ const l = await c.nextLine();
491
+ if (control(l) === 'live')
492
+ break;
493
+ backlog += l + '\n';
494
+ }
495
+ expect(backlog).toBe(plain);
496
+ appendEvent(s.st, 'a', 10);
497
+ expect(await c.nextLine()).toContain('"ts":10,');
498
+ s.st.end('a', 0);
499
+ expect(control(await c.nextLine())).toBe('ended');
500
+ expect((await c.closed).code).toBe(1000);
501
+ });
502
+ it('checks the origin and the host', async () => {
503
+ const s = await serve();
504
+ appendEvent(s.st, 'a', 1);
505
+ await expect(openWS(s, 'a', 'https://evil.example')).rejects.toThrow('HTTP 403');
506
+ await expect(openWS(s, 'a', 'http://localhost:3000')).rejects.toThrow('HTTP 403');
507
+ const ext = await openWS(s, 'a', 'chrome-extension://abcdef');
508
+ ext.ws.close();
509
+ });
510
+ it('closes with 4404 and 4429', async () => {
511
+ const s = await serve();
512
+ const missing = await openWS(s, 'nope');
513
+ expect((await missing.closed).code).toBe(4404);
514
+ appendEvent(s.st, 'a', 1);
515
+ const open = [];
516
+ for (let i = 0; i < follow_1.MAX_FOLLOWERS; i++) {
517
+ const c = await openWS(s, 'a');
518
+ await c.nextLine();
519
+ open.push(c);
520
+ }
521
+ const extra = await openWS(s, 'a');
522
+ const closed = await extra.closed;
523
+ expect(closed.code).toBe(4429);
524
+ expect(closed.reason).toContain(`at most ${follow_1.MAX_FOLLOWERS}`);
525
+ for (const c of open)
526
+ c.ws.close();
527
+ });
528
+ it('closes with 1000 on delete and 1001 on shutdown', async () => {
529
+ const s = await serve();
530
+ appendEvent(s.st, 'a', 1);
531
+ appendEvent(s.st, 'b', 1);
532
+ const a = await openWS(s, 'a');
533
+ const b = await openWS(s, 'b');
534
+ await a.nextLine();
535
+ await b.nextLine();
536
+ s.st.delete('a');
537
+ expect((await a.closed).code).toBe(1000);
538
+ s.stop();
539
+ expect((await b.closed).code).toBe(1001);
540
+ });
541
+ });
542
+ describe('features', () => {
543
+ async function seed(s) {
544
+ let r = await request(s, 'POST', `${MOUNT}/api/sessions`, {
545
+ headers: client,
546
+ body: '{"id":"s1","startedAt":1000,"url":"https://a.test/","title":"A","userAgent":"Chrome/141 Mac OS X"}',
547
+ });
548
+ expect(r.status).toBe(201);
549
+ const batch = [
550
+ '{"ts":1000,"type":"rrweb","data":{"type":4,"data":{"href":"https://a.test/","width":800,"height":600},"timestamp":1000}}',
551
+ '{"ts":1000,"type":"rrweb","data":{"type":2,"data":{"node":{"type":0,"childNodes":[],"id":1}},"timestamp":1000}}',
552
+ '{"ts":1500,"type":"event","data":{"name":"click","url":"https://a.test/","target":{"tag":"button","selector":"#pay","text":"Pay"}}}',
553
+ '{"ts":1600,"type":"network","data":{"kind":"fetch","method":"POST","url":"https://a.test/api/pay","status":500,"reqHeaders":{"authorization":"Bearer t0ken"},"resBody":"{\\"error\\":\\"card_declined\\"}"}}',
554
+ '{"ts":2000,"type":"rrweb","data":{"type":3,"data":{"source":1,"positions":[]},"timestamp":2000}}',
555
+ ].join('\n');
556
+ r = await request(s, 'POST', `${MOUNT}/api/sessions/s1/events`, { headers: client, body: batch });
557
+ expect(r.status).toBe(200);
558
+ }
559
+ it('writes reproduction steps', async () => {
560
+ const s = await serve();
561
+ await seed(s);
562
+ let r = await get(s, '/api/sessions/s1/repro');
563
+ expect(r.status).toBe(200);
564
+ expect(r.headers['content-type']).toMatch(/^text\/markdown/);
565
+ expect(r.body).toContain('1. Clicked "Pay"');
566
+ expect(r.body).toContain('**500**');
567
+ expect(r.body).toContain('http://127.0.0.1:9998/tabtape/#/s/s1?t=');
568
+ r = await get(s, '/api/sessions/s1/repro?format=json&lang=tr&from=100');
569
+ const res = JSON.parse(r.body);
570
+ expect(res.steps).toBe(1);
571
+ expect(res.markdown).toContain('tıklandı');
572
+ expect(res.link.endsWith('?t=100')).toBe(true);
573
+ expect((await get(s, '/api/sessions/s1/repro?from=-1')).status).toBe(400);
574
+ expect((await get(s, '/api/sessions/nope/repro')).status).toBe(404);
575
+ });
576
+ it('exports HTML', async () => {
577
+ let s = await serve();
578
+ await seed(s);
579
+ expect((await get(s, '/api/sessions/s1/export')).status).toBe(503);
580
+ s = await serve({ viewer: { js: Buffer.from('1'), css: Buffer.from('') } });
581
+ await seed(s);
582
+ const r = await get(s, '/api/sessions/s1/export?bodies=0');
583
+ expect(r.status).toBe(200);
584
+ expect(r.headers['content-disposition']).toBe('attachment; filename="tabtape-s1.html"');
585
+ expect(r.body).toContain('id="tabtape-data"');
586
+ const est = JSON.parse((await get(s, '/api/sessions/s1/export?bodies=0&estimate=1')).body);
587
+ expect(Math.abs(est.bytes - r.raw.length)).toBeLessThanOrEqual(100);
588
+ });
589
+ it('exports HAR without secrets by default', async () => {
590
+ const s = await serve();
591
+ s.st.append('a', [
592
+ (0, record_1.parseRecord)('{"ts":1,"type":"network","data":{"kind":"fetch","method":"GET","url":"http://x/api","status":200,"reqHeaders":{"authorization":"Bearer test-token"}}}'),
593
+ ]);
594
+ let r = await get(s, '/api/sessions/a/har');
595
+ expect(r.status).toBe(200);
596
+ expect(r.body).toContain('"version": "1.2"');
597
+ expect(r.body).toContain('http://x/api');
598
+ expect(r.body).not.toContain('test-token');
599
+ r = await get(s, '/api/sessions/a/har?secrets=1');
600
+ expect(r.body).toContain('test-token');
601
+ expect((await get(s, '/api/sessions/nope/har')).status).toBe(404);
602
+ expect((await get(s, '/api/sessions/a/har?from=x')).status).toBe(400);
603
+ });
604
+ });
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.uiRouter = uiRouter;
7
+ exports.viewerLoader = viewerLoader;
8
+ /**
9
+ * The replay UI and the offline viewer, built by tabtape/ui:
10
+ * <dist>/app the replay UI (Vite, base /tabtape/)
11
+ * <dist>/export viewer.js + viewer.css, inlined into "Download HTML" files
12
+ * Without a build, a page explaining how to build it is served instead.
13
+ */
14
+ const express_1 = __importDefault(require("express"));
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const path_1 = __importDefault(require("path"));
17
+ const PLACEHOLDER = `<!doctype html>
18
+ <meta charset="utf-8" />
19
+ <title>tabtape</title>
20
+ <style>body{font:15px system-ui,sans-serif;max-width:560px;margin:80px auto;color:#18181b}code{background:#f4f4f5;padding:2px 6px;border-radius:4px}</style>
21
+ <h1>tabtape is running</h1>
22
+ <p>The replay UI is not built. In the Repoburg repository run <code>cd tabtape &amp;&amp; npm install &amp;&amp; npm run build</code>, then reload this page.</p>
23
+ `;
24
+ /** Serves the replay UI from <dist>/app, or the placeholder page. */
25
+ function uiRouter(dist) {
26
+ const router = express_1.default.Router();
27
+ const appDir = dist ? path_1.default.join(dist, 'app') : null;
28
+ // Checked per request: building the UI does not need a daemon restart.
29
+ const built = () => !!appDir && fs_1.default.existsSync(path_1.default.join(appDir, 'index.html'));
30
+ if (appDir) {
31
+ const serve = express_1.default.static(appDir, { fallthrough: true, index: 'index.html' });
32
+ router.use((req, res, next) => (built() ? serve(req, res, next) : next()));
33
+ }
34
+ router.get(['/', '/index.html'], (_req, res) => {
35
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
36
+ res.end(PLACEHOLDER);
37
+ });
38
+ return router;
39
+ }
40
+ /**
41
+ * Loads the offline viewer that HTML exports embed; null when the UI was
42
+ * built without it. Re-read when the build changes.
43
+ */
44
+ function viewerLoader(dist) {
45
+ let cached = null;
46
+ return () => {
47
+ if (!dist)
48
+ return null;
49
+ const js = path_1.default.join(dist, 'export', 'viewer.js');
50
+ let stat;
51
+ try {
52
+ stat = fs_1.default.statSync(js);
53
+ }
54
+ catch {
55
+ cached = null;
56
+ return null;
57
+ }
58
+ if (cached && cached.mtimeMs === stat.mtimeMs)
59
+ return cached.assets;
60
+ let css = Buffer.alloc(0);
61
+ try {
62
+ css = fs_1.default.readFileSync(path_1.default.join(dist, 'export', 'viewer.css'));
63
+ }
64
+ catch {
65
+ // no CSS: the viewer still works
66
+ }
67
+ cached = { mtimeMs: stat.mtimeMs, assets: { js: fs_1.default.readFileSync(js), css } };
68
+ return cached.assets;
69
+ };
70
+ }