repoburg 1.3.254 → 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.
- package/agent-pane/frontend/dist/assets/{index-hYkepxdY.js → index-BzDC6DcX.js} +86 -86
- package/agent-pane/frontend/dist/index.html +1 -1
- package/daemon/dist/index.js +61 -3
- package/daemon/dist/staticServing.js +2 -2
- package/daemon/dist/tabtape/api/follow.js +174 -0
- package/daemon/dist/tabtape/api/http.js +221 -0
- package/daemon/dist/tabtape/api/record.js +149 -0
- package/daemon/dist/tabtape/api/router.js +519 -0
- package/daemon/dist/tabtape/api/router.spec.js +604 -0
- package/daemon/dist/tabtape/api/static.js +70 -0
- package/daemon/dist/tabtape/api/ws.js +166 -0
- package/daemon/dist/tabtape/buffer/buffer.js +347 -0
- package/daemon/dist/tabtape/buffer/buffer.spec.js +137 -0
- package/daemon/dist/tabtape/config.js +108 -0
- package/daemon/dist/tabtape/e2e-server.js +96 -0
- package/daemon/dist/tabtape/errors.js +77 -0
- package/daemon/dist/tabtape/export/export.js +331 -0
- package/daemon/dist/tabtape/export/export.spec.js +262 -0
- package/daemon/dist/tabtape/har/gostd.js +395 -0
- package/daemon/dist/tabtape/har/gostd.spec.js +139 -0
- package/daemon/dist/tabtape/har/har.js +582 -0
- package/daemon/dist/tabtape/har/har.spec.js +325 -0
- package/daemon/dist/tabtape/index.js +134 -0
- package/daemon/dist/tabtape/index.spec.js +104 -0
- package/daemon/dist/tabtape/lock.js +67 -0
- package/daemon/dist/tabtape/log.js +32 -0
- package/daemon/dist/tabtape/mcp/definitions.js +362 -0
- package/daemon/dist/tabtape/mcp/gofmt.js +227 -0
- package/daemon/dist/tabtape/mcp/params.js +345 -0
- package/daemon/dist/tabtape/mcp/params.spec.js +172 -0
- package/daemon/dist/tabtape/mcp/recordLink.js +122 -0
- package/daemon/dist/tabtape/mcp/server.js +411 -0
- package/daemon/dist/tabtape/mcp/server.spec.js +722 -0
- package/daemon/dist/tabtape/mcp/tools.js +1085 -0
- package/daemon/dist/tabtape/query/format.js +195 -0
- package/daemon/dist/tabtape/query/network.js +133 -0
- package/daemon/dist/tabtape/query/perf.js +93 -0
- package/daemon/dist/tabtape/query/query.spec.js +233 -0
- package/daemon/dist/tabtape/query/reader.js +602 -0
- package/daemon/dist/tabtape/query/types.js +274 -0
- package/daemon/dist/tabtape/redact.js +37 -0
- package/daemon/dist/tabtape/repro/lang.js +95 -0
- package/daemon/dist/tabtape/repro/repro.js +422 -0
- package/daemon/dist/tabtape/repro/repro.spec.js +210 -0
- package/daemon/dist/tabtape/sink.js +24 -0
- package/daemon/dist/tabtape/store/record.js +99 -0
- package/daemon/dist/tabtape/store/segment.js +267 -0
- package/daemon/dist/tabtape/store/store.js +846 -0
- package/daemon/dist/tabtape/store/store.spec.js +485 -0
- package/daemon/dist/tabtape/testing/querytest.js +119 -0
- package/daemon/dist/tabtape/testing/tmp.js +33 -0
- package/package.json +3 -2
- package/tabtape/extension/dist/background.js +8 -0
- package/tabtape/extension/dist/background.js.map +7 -0
- package/tabtape/extension/dist/bridge.js +3 -0
- package/tabtape/extension/dist/bridge.js.map +7 -0
- package/tabtape/extension/dist/icons/128.png +0 -0
- package/tabtape/extension/dist/icons/16.png +0 -0
- package/tabtape/extension/dist/icons/32.png +0 -0
- package/tabtape/extension/dist/icons/48.png +0 -0
- package/tabtape/extension/dist/manifest.json +28 -0
- package/tabtape/extension/dist/popup.css +62 -0
- package/tabtape/extension/dist/popup.html +34 -0
- package/tabtape/extension/dist/popup.js +4 -0
- package/tabtape/extension/dist/popup.js.map +7 -0
- package/tabtape/extension/dist/recorder.js +133 -0
- package/tabtape/extension/dist/recorder.js.map +1 -0
- package/tabtape/extension/dist/state.js +2 -0
- package/tabtape/extension/dist/state.js.map +1 -0
- package/tabtape/playwright/mcp.mjs +58 -0
- package/tabtape/playwright/tabtape.d.mts +37 -0
- package/tabtape/playwright/tabtape.mjs +146 -0
- package/tabtape/ui/dist/app/assets/index-Canv3Ljk.css +1 -0
- package/tabtape/ui/dist/app/assets/index-iWNSSrAE.js +145 -0
- package/tabtape/ui/dist/app/index.html +14 -0
- package/tabtape/ui/dist/export/viewer.css +2 -0
- package/tabtape/ui/dist/export/viewer.js +143 -0
- package/visual-editor-proxy/dist/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,519 @@
|
|
|
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.CLIENT_HEADER = void 0;
|
|
7
|
+
exports.createApiRouter = createApiRouter;
|
|
8
|
+
/**
|
|
9
|
+
* tabtape's HTTP API, mounted on the daemon's port under /tabtape: the
|
|
10
|
+
* browser extension posts events, the replay UI reads them, Claude Code asks
|
|
11
|
+
* the MCP server.
|
|
12
|
+
*
|
|
13
|
+
* The router answers everything under the mount itself (nothing falls
|
|
14
|
+
* through to the daemon's other routes) and is mounted before the daemon's
|
|
15
|
+
* CORS and body-parsing middleware:
|
|
16
|
+
* - only loopback Host names are served — never requests that came through
|
|
17
|
+
* the Repoburg remote-access tunnel (DNS rebinding, remote access);
|
|
18
|
+
* - CORS is answered for the extension only, so web pages cannot read
|
|
19
|
+
* recordings (they hold cookies, Authorization headers and bodies);
|
|
20
|
+
* - every write needs the X-Tabtape-Client header, which web pages cannot
|
|
21
|
+
* send cross-origin without a preflight that only the extension passes.
|
|
22
|
+
*/
|
|
23
|
+
const express_1 = __importDefault(require("express"));
|
|
24
|
+
const zlib_1 = __importDefault(require("zlib"));
|
|
25
|
+
const errors_1 = require("../errors");
|
|
26
|
+
const export_1 = require("../export/export");
|
|
27
|
+
const har_1 = require("../har/har");
|
|
28
|
+
const repro_1 = require("../repro/repro");
|
|
29
|
+
const sink_1 = require("../sink");
|
|
30
|
+
const record_1 = require("../store/record");
|
|
31
|
+
const store_1 = require("../store/store");
|
|
32
|
+
const follow_1 = require("./follow");
|
|
33
|
+
const http_1 = require("./http");
|
|
34
|
+
const record_2 = require("./record");
|
|
35
|
+
const static_1 = require("./static");
|
|
36
|
+
const DEFAULT_MAX_BATCH_BYTES = 64 << 20;
|
|
37
|
+
/** Must accompany every write; see the file comment. */
|
|
38
|
+
exports.CLIENT_HEADER = 'X-Tabtape-Client';
|
|
39
|
+
function createApiRouter(opts) {
|
|
40
|
+
const { st, rd, buffers, logger } = opts;
|
|
41
|
+
const maxBatch = opts.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
|
|
42
|
+
const router = express_1.default.Router();
|
|
43
|
+
const wrap = (fn) => (req, res, next) => {
|
|
44
|
+
Promise.resolve(fn(req, res)).catch(next);
|
|
45
|
+
};
|
|
46
|
+
const storeError = (res, err, id) => {
|
|
47
|
+
if (err instanceof errors_1.NotFoundError)
|
|
48
|
+
(0, http_1.sendText)(res, 404, err.message);
|
|
49
|
+
else if (err instanceof errors_1.InvalidIDError)
|
|
50
|
+
(0, http_1.sendText)(res, 400, err.message);
|
|
51
|
+
else {
|
|
52
|
+
logger.error('store failure', { session: id, err });
|
|
53
|
+
(0, http_1.sendText)(res, 500, 'internal error');
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const bufferError = (res, err, key) => {
|
|
57
|
+
if (err instanceof errors_1.BufferNotFoundError || err instanceof errors_1.BufferEmptyError)
|
|
58
|
+
(0, http_1.sendText)(res, 404, err.message);
|
|
59
|
+
else
|
|
60
|
+
storeError(res, err, key);
|
|
61
|
+
};
|
|
62
|
+
// ---------- guard ----------
|
|
63
|
+
router.use((req, res, next) => {
|
|
64
|
+
if (!(0, http_1.loopbackHost)(req.headers.host) || (0, http_1.viaTunnel)(req)) {
|
|
65
|
+
(0, http_1.sendText)(res, 403, 'forbidden host');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const origin = req.headers.origin;
|
|
69
|
+
if (typeof origin === 'string' && origin.startsWith('chrome-extension://')) {
|
|
70
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
71
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
72
|
+
res.setHeader('Access-Control-Allow-Headers', `Content-Type, ${exports.CLIENT_HEADER}`);
|
|
73
|
+
res.setHeader('Access-Control-Max-Age', '600');
|
|
74
|
+
res.appendHeader('Vary', 'Origin');
|
|
75
|
+
}
|
|
76
|
+
if (req.method === 'OPTIONS') {
|
|
77
|
+
(0, http_1.sendStatus)(res, 204);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// MCP clients are not browsers: /mcp rejects any request with an Origin
|
|
81
|
+
// header instead of requiring the client header.
|
|
82
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.path !== '/mcp' && !req.headers[exports.CLIENT_HEADER.toLowerCase()]) {
|
|
83
|
+
(0, http_1.sendText)(res, 403, `missing ${exports.CLIENT_HEADER} header`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
next();
|
|
87
|
+
});
|
|
88
|
+
// ---------- sessions ----------
|
|
89
|
+
router.get('/api/health', wrap((_req, res) => {
|
|
90
|
+
const s = st.stats();
|
|
91
|
+
(0, http_1.sendJSON)(res, 200, {
|
|
92
|
+
ok: true,
|
|
93
|
+
version: opts.version,
|
|
94
|
+
...s,
|
|
95
|
+
redactHeaders: opts.redact.list(),
|
|
96
|
+
...(buffers ? { buffers: buffers.stats() } : {}),
|
|
97
|
+
});
|
|
98
|
+
}));
|
|
99
|
+
router.get('/api/sessions', wrap((_req, res) => (0, http_1.sendJSON)(res, 200, st.list())));
|
|
100
|
+
router.post('/api/sessions', wrap(async (req, res) => {
|
|
101
|
+
const body = await (0, http_1.readJSON)(req, res);
|
|
102
|
+
if (body === undefined)
|
|
103
|
+
return;
|
|
104
|
+
const info = startInfo(body);
|
|
105
|
+
if (typeof info === 'string') {
|
|
106
|
+
(0, http_1.sendText)(res, 400, `invalid JSON body: ${info}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
(0, http_1.sendJSON)(res, 201, st.start(info));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
storeError(res, err, info.id);
|
|
114
|
+
}
|
|
115
|
+
}));
|
|
116
|
+
router.get('/api/sessions/:id', wrap((req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
(0, http_1.sendJSON)(res, 200, st.get(req.params.id));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
storeError(res, err, req.params.id);
|
|
122
|
+
}
|
|
123
|
+
}));
|
|
124
|
+
router.delete('/api/sessions/:id', wrap((req, res) => {
|
|
125
|
+
const id = req.params.id;
|
|
126
|
+
try {
|
|
127
|
+
st.delete(id);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
storeError(res, err, id);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
rd.forget(id);
|
|
134
|
+
(0, http_1.sendStatus)(res, 204);
|
|
135
|
+
}));
|
|
136
|
+
router.post('/api/sessions/:id/events', wrap(async (req, res) => {
|
|
137
|
+
const id = req.params.id;
|
|
138
|
+
if (!(0, store_1.validID)(id)) {
|
|
139
|
+
(0, http_1.sendText)(res, 400, new errors_1.InvalidIDError().message);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let body;
|
|
143
|
+
try {
|
|
144
|
+
body = await (0, http_1.readBody)(req, maxBatch);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (err instanceof http_1.TooLargeError)
|
|
148
|
+
(0, http_1.sendText)(res, 413, 'batch too large');
|
|
149
|
+
else
|
|
150
|
+
(0, http_1.sendText)(res, 400, err.message);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
let recs;
|
|
154
|
+
try {
|
|
155
|
+
recs = (0, record_1.readRecords)(body);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
(0, http_1.sendText)(res, 400, err instanceof errors_1.RecordError ? err.message : String(err));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let n;
|
|
162
|
+
try {
|
|
163
|
+
n = st.append(id, recs);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
storeError(res, err, id);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// followers lets the extension flush more often while someone watches.
|
|
170
|
+
(0, http_1.sendJSON)(res, 200, { accepted: recs.length, bytes: n, followers: opts.followers.count(id) });
|
|
171
|
+
}));
|
|
172
|
+
router.post('/api/sessions/:id/end', wrap(async (req, res) => {
|
|
173
|
+
const id = req.params.id;
|
|
174
|
+
const body = await (0, http_1.readJSON)(req, res);
|
|
175
|
+
if (body === undefined)
|
|
176
|
+
return;
|
|
177
|
+
const endedAt = isObject(body) ? body.endedAt : undefined;
|
|
178
|
+
if (endedAt !== undefined && endedAt !== null && typeof endedAt !== 'number') {
|
|
179
|
+
(0, http_1.sendText)(res, 400, 'invalid JSON body: endedAt must be a number');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
(0, http_1.sendJSON)(res, 200, st.end(id, typeof endedAt === 'number' ? Math.trunc(endedAt) : 0));
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
storeError(res, err, id);
|
|
187
|
+
}
|
|
188
|
+
}));
|
|
189
|
+
// events streams the session's JSONL. ?follow=1 keeps the stream open for
|
|
190
|
+
// new lines (live viewing); ?gzip=1 downloads it as .jsonl.gz.
|
|
191
|
+
router.get('/api/sessions/:id/events', wrap(async (req, res) => {
|
|
192
|
+
const id = req.params.id;
|
|
193
|
+
const q = (0, http_1.queryOf)(req);
|
|
194
|
+
if (q.get('follow') === '1') {
|
|
195
|
+
await httpFollow(id, req, res);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!st.has(id)) {
|
|
199
|
+
storeError(res, new errors_1.NotFoundError(), id);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
203
|
+
try {
|
|
204
|
+
if (q.get('gzip') === '1') {
|
|
205
|
+
res.writeHead(200, {
|
|
206
|
+
'Content-Type': 'application/gzip',
|
|
207
|
+
'Content-Disposition': `attachment; filename="${id}.jsonl.gz"`,
|
|
208
|
+
});
|
|
209
|
+
const gz = zlib_1.default.createGzip();
|
|
210
|
+
gz.pipe(res);
|
|
211
|
+
await st.writeSession(id, (0, http_1.writableSink)(gz, res));
|
|
212
|
+
await new Promise((resolve) => gz.end(resolve));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
|
|
216
|
+
await st.writeSession(id, (0, http_1.writableSink)(res));
|
|
217
|
+
res.end();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
if (err instanceof errors_1.NotFoundError && !res.headersSent) {
|
|
222
|
+
storeError(res, err, id);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!(err instanceof http_1.ClientGoneError))
|
|
226
|
+
logger.warn('stream session', { session: id, err });
|
|
227
|
+
res.destroy();
|
|
228
|
+
}
|
|
229
|
+
}));
|
|
230
|
+
/** The HTTP form of live viewing (curl, scripts); the replay UI uses the WebSocket. */
|
|
231
|
+
const httpFollow = async (id, req, res) => {
|
|
232
|
+
if (!st.has(id)) {
|
|
233
|
+
storeError(res, new errors_1.NotFoundError(), id);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!opts.followers.acquire(id)) {
|
|
237
|
+
(0, http_1.sendText)(res, 429, `too many live viewers (at most ${opts.followers.max})`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store' });
|
|
242
|
+
res.flushHeaders();
|
|
243
|
+
req.socket.setTimeout(0);
|
|
244
|
+
const sink = (0, http_1.writableSink)(res);
|
|
245
|
+
const out = {
|
|
246
|
+
write: sink,
|
|
247
|
+
keepAlive: () => sink(Buffer.from('\n')),
|
|
248
|
+
left: new Promise((resolve) => res.once('close', () => resolve())),
|
|
249
|
+
};
|
|
250
|
+
const end = await (0, follow_1.follow)(st, id, out, opts.stop, opts.keepAliveMs);
|
|
251
|
+
if (end === 'left')
|
|
252
|
+
res.destroy();
|
|
253
|
+
else
|
|
254
|
+
res.end();
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
opts.followers.release(id);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
// repro returns markdown reproduction steps; ?format=json wraps them with
|
|
261
|
+
// the step count and the replay link.
|
|
262
|
+
router.get('/api/sessions/:id/repro', wrap(async (req, res) => {
|
|
263
|
+
const id = req.params.id;
|
|
264
|
+
const q = (0, http_1.queryOf)(req);
|
|
265
|
+
const from = (0, http_1.msParam)(q, res, 'from');
|
|
266
|
+
if (from === null)
|
|
267
|
+
return;
|
|
268
|
+
const to = (0, http_1.msParam)(q, res, 'to');
|
|
269
|
+
if (to === null)
|
|
270
|
+
return;
|
|
271
|
+
let v;
|
|
272
|
+
try {
|
|
273
|
+
v = await rd.view(id);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
storeError(res, err, id);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
let result;
|
|
280
|
+
try {
|
|
281
|
+
result = await (0, repro_1.generateRepro)(v, { from, to, lang: q.get('lang') ?? '', baseURL: opts.baseURL, timeZone: opts.timeZone });
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
if (err instanceof errors_1.EmptyRangeError)
|
|
285
|
+
(0, http_1.sendText)(res, 400, err.message);
|
|
286
|
+
else
|
|
287
|
+
storeError(res, err, id);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (q.get('format') === 'json') {
|
|
291
|
+
(0, http_1.sendJSON)(res, 200, result);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
|
|
295
|
+
res.end(result.markdown);
|
|
296
|
+
}));
|
|
297
|
+
// export downloads the session as one offline HTML file. Options:
|
|
298
|
+
// secrets=1 keeps sensitive headers, bodies=0 and args=0 remove bodies and
|
|
299
|
+
// console arguments, from/to (ms) export a range, estimate=1 returns only
|
|
300
|
+
// {"bytes": n}.
|
|
301
|
+
router.get('/api/sessions/:id/export', wrap(async (req, res) => {
|
|
302
|
+
const id = req.params.id;
|
|
303
|
+
const q = (0, http_1.queryOf)(req);
|
|
304
|
+
const from = (0, http_1.msParam)(q, res, 'from');
|
|
305
|
+
if (from === null)
|
|
306
|
+
return;
|
|
307
|
+
const to = (0, http_1.msParam)(q, res, 'to');
|
|
308
|
+
if (to === null)
|
|
309
|
+
return;
|
|
310
|
+
let v;
|
|
311
|
+
try {
|
|
312
|
+
v = await rd.view(id);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
storeError(res, err, id);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const exportOpts = {
|
|
319
|
+
secrets: q.get('secrets') === '1',
|
|
320
|
+
stripBodies: q.get('bodies') === '0',
|
|
321
|
+
stripConsoleArgs: q.get('args') === '0',
|
|
322
|
+
from,
|
|
323
|
+
to,
|
|
324
|
+
redact: opts.redact,
|
|
325
|
+
version: opts.version,
|
|
326
|
+
};
|
|
327
|
+
const viewer = opts.viewer() ?? { js: '', css: '' };
|
|
328
|
+
if (q.get('estimate') === '1') {
|
|
329
|
+
const c = (0, sink_1.counter)();
|
|
330
|
+
try {
|
|
331
|
+
await (0, export_1.writeExport)(c.sink, v, viewer, exportOpts);
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
if (err instanceof errors_1.NoViewerError)
|
|
335
|
+
(0, http_1.sendText)(res, 503, err.message);
|
|
336
|
+
else
|
|
337
|
+
storeError(res, err, id);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
(0, http_1.sendJSON)(res, 200, { bytes: c.count() });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (viewer.js.length === 0) {
|
|
344
|
+
(0, http_1.sendText)(res, 503, new errors_1.NoViewerError().message);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
res.writeHead(200, {
|
|
348
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
349
|
+
'Content-Disposition': `attachment; filename="tabtape-${id}.html"`,
|
|
350
|
+
'Cache-Control': 'no-store',
|
|
351
|
+
});
|
|
352
|
+
try {
|
|
353
|
+
await (0, export_1.writeExport)((0, http_1.writableSink)(res), v, viewer, exportOpts);
|
|
354
|
+
res.end();
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
if (!(err instanceof http_1.ClientGoneError))
|
|
358
|
+
logger.warn('export session', { session: id, err }); // mid-stream: the status is sent
|
|
359
|
+
res.destroy();
|
|
360
|
+
}
|
|
361
|
+
}));
|
|
362
|
+
// har downloads the session's network data as HAR 1.2. Options: secrets=1
|
|
363
|
+
// keeps sensitive headers, from/to (ms) select a range.
|
|
364
|
+
router.get('/api/sessions/:id/har', wrap(async (req, res) => {
|
|
365
|
+
const id = req.params.id;
|
|
366
|
+
const q = (0, http_1.queryOf)(req);
|
|
367
|
+
const from = (0, http_1.msParam)(q, res, 'from');
|
|
368
|
+
if (from === null)
|
|
369
|
+
return;
|
|
370
|
+
const to = (0, http_1.msParam)(q, res, 'to');
|
|
371
|
+
if (to === null)
|
|
372
|
+
return;
|
|
373
|
+
let har;
|
|
374
|
+
try {
|
|
375
|
+
const v = await rd.view(id);
|
|
376
|
+
har = await (0, har_1.buildHar)(v, { secrets: q.get('secrets') === '1', redact: opts.redact, from, to, version: opts.version });
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
storeError(res, err, id);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const body = JSON.stringify(har, null, 2) + '\n';
|
|
383
|
+
res.writeHead(200, {
|
|
384
|
+
'Content-Type': 'application/json',
|
|
385
|
+
'Content-Disposition': `attachment; filename="tabtape-${id}.har"`,
|
|
386
|
+
'Cache-Control': 'no-store',
|
|
387
|
+
'Content-Length': Buffer.byteLength(body),
|
|
388
|
+
});
|
|
389
|
+
res.end(body);
|
|
390
|
+
}));
|
|
391
|
+
// ---------- buffer mode ----------
|
|
392
|
+
if (buffers) {
|
|
393
|
+
router.get('/api/buffers', wrap((_req, res) => (0, http_1.sendJSON)(res, 200, buffers.list())));
|
|
394
|
+
// Appends NDJSON records to a tab's buffer; ?window=<ms> is how much the tab keeps.
|
|
395
|
+
router.post('/api/buffers/:key/events', wrap(async (req, res) => {
|
|
396
|
+
const key = req.params.key;
|
|
397
|
+
const window = (0, http_1.msParam)((0, http_1.queryOf)(req), res, 'window');
|
|
398
|
+
if (window === null)
|
|
399
|
+
return;
|
|
400
|
+
let body;
|
|
401
|
+
try {
|
|
402
|
+
body = await (0, http_1.readBody)(req, maxBatch);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
if (err instanceof http_1.TooLargeError)
|
|
406
|
+
(0, http_1.sendText)(res, 413, 'batch too large');
|
|
407
|
+
else
|
|
408
|
+
(0, http_1.sendText)(res, 400, err.message);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
let recs;
|
|
412
|
+
try {
|
|
413
|
+
recs = (0, record_1.readRecords)(body);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
(0, http_1.sendText)(res, 400, err instanceof errors_1.RecordError ? err.message : String(err));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
buffers.append(key, recs, window, req.headers['user-agent'] ?? '');
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
bufferError(res, err, key);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
(0, http_1.sendJSON)(res, 200, { accepted: recs.length });
|
|
427
|
+
}));
|
|
428
|
+
// Copies the last minutes of a buffer into a new session. Body:
|
|
429
|
+
// {"minutes": 2, "continue": false}; continue keeps the session open so a
|
|
430
|
+
// recording can carry on in it.
|
|
431
|
+
router.post('/api/buffers/:key/save', wrap(async (req, res) => {
|
|
432
|
+
const key = req.params.key;
|
|
433
|
+
const body = await (0, http_1.readJSON)(req, res);
|
|
434
|
+
if (body === undefined)
|
|
435
|
+
return;
|
|
436
|
+
const o = isObject(body) ? body : {};
|
|
437
|
+
if ((o.minutes !== undefined && typeof o.minutes !== 'number') || (o.continue !== undefined && typeof o.continue !== 'boolean')) {
|
|
438
|
+
(0, http_1.sendText)(res, 400, 'invalid JSON body: minutes must be a number and continue a boolean');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const minutes = typeof o.minutes === 'number' ? o.minutes : 0;
|
|
442
|
+
try {
|
|
443
|
+
const sess = await buffers.save(key, st, { windowMs: Math.trunc(minutes * 60000), continue: o.continue === true });
|
|
444
|
+
(0, http_1.sendJSON)(res, 201, sess);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
bufferError(res, err, key);
|
|
448
|
+
}
|
|
449
|
+
}));
|
|
450
|
+
router.delete('/api/buffers/:key', wrap((req, res) => {
|
|
451
|
+
const key = req.params.key;
|
|
452
|
+
try {
|
|
453
|
+
buffers.delete(key);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
if (!(err instanceof errors_1.BufferNotFoundError)) {
|
|
457
|
+
bufferError(res, err, key);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
(0, http_1.sendStatus)(res, 204);
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
464
|
+
// ---------- record links ----------
|
|
465
|
+
router.get(['/record', '/record/stop'], (_req, res) => (0, record_2.sendRecordAsset)(res, 'text/html; charset=utf-8', (0, record_2.recordHTML)(opts.mount)));
|
|
466
|
+
router.get('/record/record.js', (_req, res) => (0, record_2.sendRecordAsset)(res, 'text/javascript; charset=utf-8', (0, record_2.recordJS)(opts.mount)));
|
|
467
|
+
// Answers the extension: 204 when the token is this daemon's, 403 otherwise (or when record links are off).
|
|
468
|
+
router.post('/api/record/verify', wrap(async (req, res) => {
|
|
469
|
+
const body = await (0, http_1.readJSON)(req, res);
|
|
470
|
+
if (body === undefined)
|
|
471
|
+
return;
|
|
472
|
+
const token = isObject(body) ? body.token : undefined;
|
|
473
|
+
if (!(0, record_2.validRecordToken)(opts.recordToken, token)) {
|
|
474
|
+
(0, http_1.sendText)(res, 403, 'invalid record token');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
(0, http_1.sendStatus)(res, 204);
|
|
478
|
+
}));
|
|
479
|
+
// ---------- MCP ----------
|
|
480
|
+
if (opts.mcp) {
|
|
481
|
+
const mcp = opts.mcp;
|
|
482
|
+
router.all('/mcp', wrap((req, res) => mcp.handle(req, res)));
|
|
483
|
+
}
|
|
484
|
+
// ---------- replay UI ----------
|
|
485
|
+
router.use((0, static_1.uiRouter)(opts.uiDist));
|
|
486
|
+
// Nothing under the mount falls through to the daemon's other routes.
|
|
487
|
+
router.use((_req, res) => (0, http_1.sendText)(res, 404, '404 page not found'));
|
|
488
|
+
router.use((err, req, res, _next) => {
|
|
489
|
+
logger.error('request failed', { path: req.originalUrl, err });
|
|
490
|
+
if (res.headersSent)
|
|
491
|
+
res.destroy();
|
|
492
|
+
else
|
|
493
|
+
(0, http_1.sendText)(res, 500, 'internal error');
|
|
494
|
+
});
|
|
495
|
+
return router;
|
|
496
|
+
}
|
|
497
|
+
function isObject(v) {
|
|
498
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
499
|
+
}
|
|
500
|
+
/** Decodes a StartInfo body; a string is the error. */
|
|
501
|
+
function startInfo(body) {
|
|
502
|
+
if (!isObject(body))
|
|
503
|
+
return 'expected a JSON object';
|
|
504
|
+
const out = { id: '' };
|
|
505
|
+
for (const key of ['id', 'url', 'title', 'userAgent', 'source']) {
|
|
506
|
+
const v = body[key];
|
|
507
|
+
if (v === undefined || v === null)
|
|
508
|
+
continue;
|
|
509
|
+
if (typeof v !== 'string')
|
|
510
|
+
return `${key} must be a string`;
|
|
511
|
+
out[key] = v;
|
|
512
|
+
}
|
|
513
|
+
if (body.startedAt !== undefined && body.startedAt !== null) {
|
|
514
|
+
if (typeof body.startedAt !== 'number')
|
|
515
|
+
return 'startedAt must be a number';
|
|
516
|
+
out.startedAt = Math.trunc(body.startedAt);
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
}
|