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.
- 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/daemon/dist/index.js
CHANGED
|
@@ -19,7 +19,29 @@ const routes_1 = require("./remote/routes");
|
|
|
19
19
|
const checkRemoteAuth_1 = require("./middleware/checkRemoteAuth");
|
|
20
20
|
const staticServing_1 = require("./staticServing");
|
|
21
21
|
const router_1 = require("./recorder/router");
|
|
22
|
+
const tabtape_1 = require("./tabtape");
|
|
22
23
|
const PORT = 9998;
|
|
24
|
+
/**
|
|
25
|
+
* tabtape (browser session recording and replay) on this port under
|
|
26
|
+
* /tabtape. Failure is non-fatal: the daemon keeps running without it.
|
|
27
|
+
* Kill switch: REPOBURG_TABTAPE=0.
|
|
28
|
+
*/
|
|
29
|
+
function loadTabtape() {
|
|
30
|
+
if (process.env.REPOBURG_TABTAPE === '0') {
|
|
31
|
+
console.log('[tabtape] disabled via REPOBURG_TABTAPE=0');
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return (0, tabtape_1.createTabtape)({
|
|
36
|
+
baseURL: `http://127.0.0.1:${PORT}${tabtape_1.TABTAPE_MOUNT}`,
|
|
37
|
+
uiDist: (0, tabtape_1.resolveTabtapeUiDist)(__dirname),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.warn('[tabtape] failed to start:', err);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
23
45
|
async function main() {
|
|
24
46
|
await serviceManager_1.serviceManager.connect();
|
|
25
47
|
// Embedded agent-pane backend — served on THIS port (no second listener).
|
|
@@ -28,10 +50,25 @@ async function main() {
|
|
|
28
50
|
console.warn('[agentPane] embed failed:', err);
|
|
29
51
|
return null;
|
|
30
52
|
});
|
|
53
|
+
// Opened once the port is ours (see server.listen below): a second daemon
|
|
54
|
+
// started by mistake must not touch the running one's recordings.
|
|
55
|
+
let tabtape = null;
|
|
31
56
|
const app = (0, express_1.default)();
|
|
32
57
|
const server = http_1.default.createServer(app);
|
|
33
58
|
// Daemon wss in noServer mode — the shared upgrade router dispatches below.
|
|
34
59
|
const wss = new ws_1.WebSocketServer({ noServer: true });
|
|
60
|
+
// tabtape: local-only recording API + replay UI + MCP. Mounted FIRST: it
|
|
61
|
+
// answers CORS for its Chrome extension only (the daemon-wide allowlist
|
|
62
|
+
// below would let any localhost page read recordings), refuses non-loopback
|
|
63
|
+
// Host names (tunnel traffic) and reads its own NDJSON bodies. The prefix
|
|
64
|
+
// stays tabtape's even while it is off.
|
|
65
|
+
app.use(tabtape_1.TABTAPE_MOUNT, (req, res, next) => {
|
|
66
|
+
if (tabtape) {
|
|
67
|
+
tabtape.router(req, res, next);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
res.status(503).type('text/plain').send('tabtape is not available on this daemon\n');
|
|
71
|
+
});
|
|
35
72
|
// Spec: allowlist only — local tooling (vite, daemon-desktop) and
|
|
36
73
|
// *.repoburg.com tunnel hostnames. The previous origin:'*' with
|
|
37
74
|
// credentials:true was an open API surface.
|
|
@@ -98,14 +135,27 @@ async function main() {
|
|
|
98
135
|
ws.on('close', () => console.log('Client disconnected from daemon WebSocket'));
|
|
99
136
|
ws.on('error', (error) => console.error('WebSocket error:', error));
|
|
100
137
|
});
|
|
101
|
-
// Single upgrade router: '/
|
|
102
|
-
// when configured), everything
|
|
103
|
-
|
|
138
|
+
// Single upgrade router: '/tabtape/...' -> tabtape live viewing, '/ws' ->
|
|
139
|
+
// embedded agent-pane (guarded by CF Access when configured), everything
|
|
140
|
+
// else -> daemon wss.
|
|
141
|
+
const paneUpgrade = (0, agentPane_1.createUpgradeRouter)(pane, wss, checkRemoteAuth_1.checkRemoteWsAuth);
|
|
142
|
+
server.on('upgrade', (req, socket, head) => {
|
|
143
|
+
const url = req.url ?? '';
|
|
144
|
+
if (url === tabtape_1.TABTAPE_MOUNT || url.startsWith(`${tabtape_1.TABTAPE_MOUNT}/`)) {
|
|
145
|
+
if (tabtape)
|
|
146
|
+
tabtape.handleUpgrade(req, socket, head);
|
|
147
|
+
else
|
|
148
|
+
socket.destroy();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
paneUpgrade(req, socket, head);
|
|
152
|
+
});
|
|
104
153
|
// Spec: loopback binding only — remote access is exclusively via the
|
|
105
154
|
// cloudflared tunnel; the daemon must never listen on public interfaces.
|
|
106
155
|
server.listen(PORT, '127.0.0.1', () => {
|
|
107
156
|
console.log(`Repoburg Platform Daemon listening on http://127.0.0.1:${PORT}`);
|
|
108
157
|
console.log(`MCP Gateway available at http://localhost:${PORT}/mcp`);
|
|
158
|
+
tabtape = loadTabtape();
|
|
109
159
|
});
|
|
110
160
|
// Clean up MCP gateway sessions and the embedded agent-pane on shutdown.
|
|
111
161
|
const shutdown = async () => {
|
|
@@ -119,6 +169,14 @@ async function main() {
|
|
|
119
169
|
}
|
|
120
170
|
}
|
|
121
171
|
await mcp_gateway_1.mcpGateway.closeAllSessions();
|
|
172
|
+
if (tabtape) {
|
|
173
|
+
try {
|
|
174
|
+
await tabtape.close();
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
console.error('[tabtape] shutdown failed:', err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
122
180
|
process.exit(0);
|
|
123
181
|
};
|
|
124
182
|
process.on('SIGINT', shutdown);
|
|
@@ -11,8 +11,8 @@ exports.createPaneFrontendRouter = createPaneFrontendRouter;
|
|
|
11
11
|
* Spec: a machine's daemon serves the pane at https://<mahlas>.repoburg.com
|
|
12
12
|
* from its own origin — SPA and API share one origin (no CORS, no
|
|
13
13
|
* mixed-content). The router is mounted LAST: every API path (/api, /ws,
|
|
14
|
-
* /services, /auth, /registry, /mcp, /remote) is matched by
|
|
15
|
-
* routers and never reaches the fallback.
|
|
14
|
+
* /services, /auth, /registry, /mcp, /remote, /tabtape) is matched by
|
|
15
|
+
* earlier routers and never reaches the fallback.
|
|
16
16
|
*
|
|
17
17
|
* Path resolution is ABSOLUTE (pm2 God-daemon-cwd bug — see agentPane.ts).
|
|
18
18
|
*/
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Follower = exports.Followers = exports.KEEP_ALIVE_MS = exports.MAX_FOLLOWERS = void 0;
|
|
4
|
+
exports.controlLine = controlLine;
|
|
5
|
+
exports.follow = follow;
|
|
6
|
+
/**
|
|
7
|
+
* Live viewing: a session streamed like tail -f — the lines that exist, a
|
|
8
|
+
* "live" control line, then new lines as they are written. When the session
|
|
9
|
+
* ends the stream sends an "ended" control line and stops; it also stops when
|
|
10
|
+
* the viewer leaves, the session is deleted or tabtape shuts down. Viewers
|
|
11
|
+
* never slow down appends: they wait on store.watch() and read the segment
|
|
12
|
+
* files themselves.
|
|
13
|
+
*
|
|
14
|
+
* The same loop feeds two transports: the replay UI's WebSocket (see ws.ts)
|
|
15
|
+
* and the HTTP NDJSON stream of GET …/events?follow=1 (curl, scripts).
|
|
16
|
+
*/
|
|
17
|
+
const errors_1 = require("../errors");
|
|
18
|
+
const segment_1 = require("../store/segment");
|
|
19
|
+
/**
|
|
20
|
+
* Bounds live viewers. The replay UI follows over WebSockets, which do not
|
|
21
|
+
* count against the browser's six HTTP connections per host; the bound only
|
|
22
|
+
* caps the daemon's work.
|
|
23
|
+
*/
|
|
24
|
+
exports.MAX_FOLLOWERS = 8;
|
|
25
|
+
/** How often an idle stream gets a keep-alive. */
|
|
26
|
+
exports.KEEP_ALIVE_MS = 15000;
|
|
27
|
+
/** Counts live viewers, in total (for the limit) and per session (reported to the extension so it can flush more often). */
|
|
28
|
+
class Followers {
|
|
29
|
+
constructor(max = exports.MAX_FOLLOWERS) {
|
|
30
|
+
this.max = max;
|
|
31
|
+
this.total = 0;
|
|
32
|
+
this.per = new Map();
|
|
33
|
+
}
|
|
34
|
+
acquire(id) {
|
|
35
|
+
if (this.total >= this.max)
|
|
36
|
+
return false;
|
|
37
|
+
this.total++;
|
|
38
|
+
this.per.set(id, (this.per.get(id) ?? 0) + 1);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
release(id) {
|
|
42
|
+
this.total--;
|
|
43
|
+
const n = (this.per.get(id) ?? 0) - 1;
|
|
44
|
+
if (n <= 0)
|
|
45
|
+
this.per.delete(id);
|
|
46
|
+
else
|
|
47
|
+
this.per.set(id, n);
|
|
48
|
+
}
|
|
49
|
+
count(id) {
|
|
50
|
+
return this.per.get(id) ?? 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.Followers = Followers;
|
|
54
|
+
function controlLine(event, now = Date.now()) {
|
|
55
|
+
return Buffer.from(`{"ts":${now},"type":"tabtape","data":{"event":"${event}"}}\n`);
|
|
56
|
+
}
|
|
57
|
+
/** A live viewer's position: the segment being read and how much of it was sent. */
|
|
58
|
+
class Follower {
|
|
59
|
+
constructor() {
|
|
60
|
+
this.seq = 0;
|
|
61
|
+
this.off = 0;
|
|
62
|
+
/** The segment's size when last seen. */
|
|
63
|
+
this.known = 0;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Writes everything in segs past the viewer's position. Reports whether
|
|
67
|
+
* data the viewer had not read was deleted meanwhile.
|
|
68
|
+
*/
|
|
69
|
+
async send(segs, write) {
|
|
70
|
+
let trimmed = false;
|
|
71
|
+
if (this.seq !== 0 && this.off < this.known && !segs.some((sg) => sg.seq === this.seq))
|
|
72
|
+
trimmed = true;
|
|
73
|
+
for (const sg of segs) {
|
|
74
|
+
if (sg.seq < this.seq)
|
|
75
|
+
continue;
|
|
76
|
+
if (sg.seq > this.seq) {
|
|
77
|
+
this.seq = sg.seq;
|
|
78
|
+
this.off = 0;
|
|
79
|
+
}
|
|
80
|
+
this.known = sg.size;
|
|
81
|
+
if (sg.size <= this.off)
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
for await (const chunk of (0, segment_1.readSegment)(sg.path, this.off, sg.size - this.off)) {
|
|
85
|
+
const pending = write(chunk);
|
|
86
|
+
if (pending)
|
|
87
|
+
await pending;
|
|
88
|
+
this.off += chunk.length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if ((0, errors_1.isNotExist)(err)) {
|
|
93
|
+
trimmed = true; // deleted after the snapshot
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return trimmed;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.Follower = Follower;
|
|
103
|
+
/**
|
|
104
|
+
* Streams session id to out until it ends, is deleted, the viewer leaves or
|
|
105
|
+
* stop resolves. The caller checks that the session exists and acquires a
|
|
106
|
+
* follower slot.
|
|
107
|
+
*/
|
|
108
|
+
async function follow(st, id, out, stop, keepAliveMs = exports.KEEP_ALIVE_MS) {
|
|
109
|
+
const f = new Follower();
|
|
110
|
+
let live = false;
|
|
111
|
+
// One listener each for the whole stream (not one per wait): a long live
|
|
112
|
+
// session waits thousands of times.
|
|
113
|
+
let quit = null;
|
|
114
|
+
let wake = null;
|
|
115
|
+
void out.left.then(() => {
|
|
116
|
+
quit = quit ?? 'left';
|
|
117
|
+
wake?.();
|
|
118
|
+
});
|
|
119
|
+
void stop.then(() => {
|
|
120
|
+
quit = quit ?? 'stopped';
|
|
121
|
+
wake?.();
|
|
122
|
+
});
|
|
123
|
+
const write = (chunk) => out.write(chunk);
|
|
124
|
+
try {
|
|
125
|
+
for (;;) {
|
|
126
|
+
if (quit)
|
|
127
|
+
return quit;
|
|
128
|
+
let changed;
|
|
129
|
+
let snap;
|
|
130
|
+
try {
|
|
131
|
+
changed = st.watch(id); // before reading: no change is missed
|
|
132
|
+
snap = st.snapshot(id);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return 'gone'; // deleted
|
|
136
|
+
}
|
|
137
|
+
const trimmed = await f.send(snap.segments, write);
|
|
138
|
+
if (trimmed)
|
|
139
|
+
await out.write(controlLine('trimmed'));
|
|
140
|
+
if (!live) {
|
|
141
|
+
live = true;
|
|
142
|
+
await out.write(controlLine('live'));
|
|
143
|
+
}
|
|
144
|
+
if (snap.session.endedAt) {
|
|
145
|
+
await out.write(controlLine('ended'));
|
|
146
|
+
await out.flush?.();
|
|
147
|
+
return 'ended';
|
|
148
|
+
}
|
|
149
|
+
await out.flush?.();
|
|
150
|
+
if (quit)
|
|
151
|
+
return quit;
|
|
152
|
+
let ticked = false;
|
|
153
|
+
await new Promise((resolve) => {
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
ticked = true;
|
|
156
|
+
resolve();
|
|
157
|
+
}, keepAliveMs);
|
|
158
|
+
wake = () => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
resolve();
|
|
161
|
+
};
|
|
162
|
+
void changed.then(() => wake?.());
|
|
163
|
+
});
|
|
164
|
+
wake = null;
|
|
165
|
+
if (quit)
|
|
166
|
+
return quit;
|
|
167
|
+
if (ticked)
|
|
168
|
+
await out.keepAlive();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return 'left'; // a failed write: the viewer is gone
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
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.TooLargeError = exports.ClientGoneError = void 0;
|
|
7
|
+
exports.sendText = sendText;
|
|
8
|
+
exports.sendJSON = sendJSON;
|
|
9
|
+
exports.sendStatus = sendStatus;
|
|
10
|
+
exports.readBody = readBody;
|
|
11
|
+
exports.readJSON = readJSON;
|
|
12
|
+
exports.msParam = msParam;
|
|
13
|
+
exports.queryOf = queryOf;
|
|
14
|
+
exports.writableSink = writableSink;
|
|
15
|
+
exports.loopbackHost = loopbackHost;
|
|
16
|
+
exports.viaTunnel = viaTunnel;
|
|
17
|
+
/** Small HTTP helpers for the tabtape routes: plain Node responses, no Express magic (ETags, 304s). */
|
|
18
|
+
const net_1 = __importDefault(require("net"));
|
|
19
|
+
/** The client went away (or stopped reading) while a response was being streamed. */
|
|
20
|
+
class ClientGoneError extends Error {
|
|
21
|
+
constructor(message = 'client left') {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ClientGoneError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.ClientGoneError = ClientGoneError;
|
|
27
|
+
/** A request body over the limit. */
|
|
28
|
+
class TooLargeError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super('request body too large');
|
|
31
|
+
this.name = 'TooLargeError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
exports.TooLargeError = TooLargeError;
|
|
35
|
+
/** Like Go's http.Error: a plain-text message with a trailing newline. */
|
|
36
|
+
function sendText(res, status, message) {
|
|
37
|
+
if (res.headersSent) {
|
|
38
|
+
res.destroy();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const body = message + '\n';
|
|
42
|
+
res.writeHead(status, {
|
|
43
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
44
|
+
'X-Content-Type-Options': 'nosniff',
|
|
45
|
+
'Content-Length': Buffer.byteLength(body),
|
|
46
|
+
});
|
|
47
|
+
res.end(body);
|
|
48
|
+
}
|
|
49
|
+
function sendJSON(res, status, v, headers = {}) {
|
|
50
|
+
const body = JSON.stringify(v) + '\n';
|
|
51
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers });
|
|
52
|
+
res.end(body);
|
|
53
|
+
}
|
|
54
|
+
function sendStatus(res, status) {
|
|
55
|
+
res.writeHead(status);
|
|
56
|
+
res.end();
|
|
57
|
+
}
|
|
58
|
+
/** Reads the whole request body, failing with TooLargeError past limit bytes. */
|
|
59
|
+
function readBody(req, limit) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const parts = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
let done = false;
|
|
64
|
+
const finish = (err) => {
|
|
65
|
+
if (done)
|
|
66
|
+
return;
|
|
67
|
+
done = true;
|
|
68
|
+
req.off('data', onData);
|
|
69
|
+
req.off('end', onEnd);
|
|
70
|
+
req.off('error', onError);
|
|
71
|
+
if (err) {
|
|
72
|
+
req.resume(); // drain the rest; the response says why
|
|
73
|
+
reject(err);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
resolve(Buffer.concat(parts, size));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const onData = (chunk) => {
|
|
80
|
+
size += chunk.length;
|
|
81
|
+
if (size > limit) {
|
|
82
|
+
finish(new TooLargeError());
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
parts.push(chunk);
|
|
86
|
+
};
|
|
87
|
+
const onEnd = () => finish(null);
|
|
88
|
+
const onError = (err) => finish(err);
|
|
89
|
+
req.on('data', onData);
|
|
90
|
+
req.on('end', onEnd);
|
|
91
|
+
req.on('error', onError);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Control bodies (start, end, save, verify) are small. */
|
|
95
|
+
const MAX_CONTROL_BYTES = 64 << 10;
|
|
96
|
+
/**
|
|
97
|
+
* Reads a small JSON body. Answers 400 "invalid JSON body: …" (or 413) and
|
|
98
|
+
* returns undefined when it cannot.
|
|
99
|
+
*/
|
|
100
|
+
async function readJSON(req, res) {
|
|
101
|
+
let body;
|
|
102
|
+
try {
|
|
103
|
+
body = await readBody(req, MAX_CONTROL_BYTES);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (err instanceof TooLargeError)
|
|
107
|
+
sendText(res, 413, 'invalid JSON body: http: request body too large');
|
|
108
|
+
else
|
|
109
|
+
sendText(res, 400, `invalid JSON body: ${err.message}`);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const text = body.toString('utf8');
|
|
113
|
+
if (text.trim() === '') {
|
|
114
|
+
sendText(res, 400, 'invalid JSON body: EOF');
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(text);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
sendText(res, 400, `invalid JSON body: ${err.message}`);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Reads an optional non-negative integer of milliseconds from the query.
|
|
127
|
+
* Answers 400 and returns null when it is not one; 0 when absent.
|
|
128
|
+
*/
|
|
129
|
+
function msParam(query, res, name) {
|
|
130
|
+
const raw = query.get(name);
|
|
131
|
+
if (raw === null || raw === '')
|
|
132
|
+
return 0;
|
|
133
|
+
if (!/^[+-]?\d+$/.test(raw) || Number(raw) < 0 || !Number.isSafeInteger(Number(raw))) {
|
|
134
|
+
sendText(res, 400, `${name} must be a non-negative number of milliseconds`);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return Number(raw);
|
|
138
|
+
}
|
|
139
|
+
/** The request's query string. */
|
|
140
|
+
function queryOf(req) {
|
|
141
|
+
const url = req.url ?? '';
|
|
142
|
+
const i = url.indexOf('?');
|
|
143
|
+
return new URLSearchParams(i < 0 ? '' : url.slice(i + 1));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A sink writing to a stream with backpressure. `watch` is the response whose
|
|
147
|
+
* closing means the client left (it differs from target when a gzip stream
|
|
148
|
+
* sits in between). A client that stops reading for timeoutMs is dropped.
|
|
149
|
+
*/
|
|
150
|
+
function writableSink(target, watch = target, timeoutMs = 60000) {
|
|
151
|
+
return (chunk) => {
|
|
152
|
+
if (watch.destroyed || target.destroyed)
|
|
153
|
+
throw new ClientGoneError();
|
|
154
|
+
if (target.write(chunk))
|
|
155
|
+
return;
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
const cleanup = () => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
target.off('drain', onDrain);
|
|
160
|
+
watch.off('close', onClose);
|
|
161
|
+
target.off('error', onClose);
|
|
162
|
+
};
|
|
163
|
+
const onDrain = () => {
|
|
164
|
+
cleanup();
|
|
165
|
+
resolve();
|
|
166
|
+
};
|
|
167
|
+
const onClose = () => {
|
|
168
|
+
cleanup();
|
|
169
|
+
reject(new ClientGoneError());
|
|
170
|
+
};
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
cleanup();
|
|
173
|
+
watch.destroy();
|
|
174
|
+
reject(new ClientGoneError('client stopped reading'));
|
|
175
|
+
}, timeoutMs);
|
|
176
|
+
target.once('drain', onDrain);
|
|
177
|
+
watch.once('close', onClose);
|
|
178
|
+
target.once('error', onClose);
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Whether a Host header names a loopback host (localhost, 127.0.0.0/8, ::1). */
|
|
183
|
+
function loopbackHost(hostport) {
|
|
184
|
+
if (!hostport)
|
|
185
|
+
return false;
|
|
186
|
+
let host = hostport;
|
|
187
|
+
const v6 = /^\[([^\]]+)\](?::\d*)?$/.exec(hostport);
|
|
188
|
+
if (v6) {
|
|
189
|
+
host = v6[1];
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const i = hostport.lastIndexOf(':');
|
|
193
|
+
if (i >= 0 && hostport.indexOf(':') === i)
|
|
194
|
+
host = hostport.slice(0, i);
|
|
195
|
+
}
|
|
196
|
+
if (host.toLowerCase() === 'localhost')
|
|
197
|
+
return true;
|
|
198
|
+
if (net_1.default.isIPv4(host))
|
|
199
|
+
return host.split('.')[0] === '127';
|
|
200
|
+
if (net_1.default.isIPv6(host)) {
|
|
201
|
+
let normalized;
|
|
202
|
+
try {
|
|
203
|
+
normalized = new URL(`http://[${host}]/`).hostname.toLowerCase(); // "[::1]"
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (normalized === '[::1]')
|
|
209
|
+
return true;
|
|
210
|
+
// IPv4-mapped loopback: ::ffff:127.x.y.z → [::ffff:7fxx:yyzz]
|
|
211
|
+
return /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(normalized);
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Whether the request came through Cloudflare (the Repoburg remote-access
|
|
217
|
+
* tunnel). Defense in depth next to the Host check: tabtape is local-only.
|
|
218
|
+
*/
|
|
219
|
+
function viaTunnel(req) {
|
|
220
|
+
return req.headers['cf-ray'] !== undefined || req.headers['cf-connecting-ip'] !== undefined;
|
|
221
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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.newRecordToken = newRecordToken;
|
|
7
|
+
exports.validRecordToken = validRecordToken;
|
|
8
|
+
exports.recordHTML = recordHTML;
|
|
9
|
+
exports.recordJS = recordJS;
|
|
10
|
+
exports.sendRecordAsset = sendRecordAsset;
|
|
11
|
+
/**
|
|
12
|
+
* Record links start and stop recordings in browsers that can only be told
|
|
13
|
+
* to open a URL, such as one driven by Playwright MCP:
|
|
14
|
+
*
|
|
15
|
+
* <mount>/record?token=…&name=…&url=… start recording this tab, then open url
|
|
16
|
+
* <mount>/record/stop?token=… stop the recording
|
|
17
|
+
*
|
|
18
|
+
* The extension notices the tab opening one of them, asks
|
|
19
|
+
* <mount>/api/record/verify whether the token is this daemon's, and only
|
|
20
|
+
* then starts or stops. Web pages can open these URLs too, but without the
|
|
21
|
+
* token nothing happens. The extension then sends the tab back to
|
|
22
|
+
* <mount>/record?started=… (or stopped, error), which says what happened.
|
|
23
|
+
*/
|
|
24
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
25
|
+
/** The page keeps to its own script: it shows query parameters. */
|
|
26
|
+
const RECORD_CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'";
|
|
27
|
+
/** A new record token: links from an earlier daemon run stop working. */
|
|
28
|
+
function newRecordToken() {
|
|
29
|
+
return crypto_1.default.randomBytes(20).toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
/** Constant-time comparison; an empty expected token turns record links off. */
|
|
32
|
+
function validRecordToken(want, got) {
|
|
33
|
+
if (!want || typeof got !== 'string')
|
|
34
|
+
return false;
|
|
35
|
+
const a = Buffer.from(got);
|
|
36
|
+
const b = Buffer.from(want);
|
|
37
|
+
return a.length === b.length && crypto_1.default.timingSafeEqual(a, b);
|
|
38
|
+
}
|
|
39
|
+
function recordHTML(mount) {
|
|
40
|
+
return `<!doctype html>
|
|
41
|
+
<html lang="en">
|
|
42
|
+
<head>
|
|
43
|
+
<meta charset="utf-8">
|
|
44
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
45
|
+
<meta name="referrer" content="no-referrer">
|
|
46
|
+
<title>tabtape</title>
|
|
47
|
+
<style>
|
|
48
|
+
:root { color-scheme: light dark; font: 15px/1.5 system-ui, -apple-system, sans-serif; }
|
|
49
|
+
body { margin: 0; display: grid; place-items: center; min-height: 100vh; }
|
|
50
|
+
main { max-width: 38rem; padding: 2rem; }
|
|
51
|
+
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
|
|
52
|
+
h1::before { content: ''; display: inline-block; width: 0.7em; height: 0.7em; margin-right: 0.5em; border-radius: 50%; background: #e5484d; vertical-align: 0.02em; }
|
|
53
|
+
p { margin: 0 0 0.6rem; }
|
|
54
|
+
code { font-size: 0.92em; }
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<main>
|
|
59
|
+
<h1 id="title">tabtape</h1>
|
|
60
|
+
<div id="body"></div>
|
|
61
|
+
</main>
|
|
62
|
+
<script src="${mount}/record/record.js"></script>
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
function recordJS(mount) {
|
|
68
|
+
return `// Says what a record link did. The tabtape extension, not this page, starts
|
|
69
|
+
// and stops recordings; it sends the tab back here with the outcome.
|
|
70
|
+
const MOUNT = ${JSON.stringify(mount)};
|
|
71
|
+
const q = new URLSearchParams(location.search);
|
|
72
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
73
|
+
|
|
74
|
+
/** Each line is a string or an array of strings and nodes. */
|
|
75
|
+
function show(title, ...lines) {
|
|
76
|
+
document.title = \`tabtape · \${title}\`;
|
|
77
|
+
document.getElementById('title').textContent = title;
|
|
78
|
+
document.getElementById('body').replaceChildren(
|
|
79
|
+
...lines.map((line) => {
|
|
80
|
+
const p = document.createElement('p');
|
|
81
|
+
p.append(...[].concat(line));
|
|
82
|
+
return p;
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function el(tag, text, attrs = {}) {
|
|
88
|
+
const e = Object.assign(document.createElement(tag), attrs);
|
|
89
|
+
e.textContent = text;
|
|
90
|
+
return e;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const started = q.get('started');
|
|
94
|
+
const stopped = q.get('stopped');
|
|
95
|
+
const error = q.get('error');
|
|
96
|
+
const name = q.get('name');
|
|
97
|
+
|
|
98
|
+
if (error) {
|
|
99
|
+
show('Record link failed', error);
|
|
100
|
+
} else if (started && ID.test(started)) {
|
|
101
|
+
show(
|
|
102
|
+
'Recording',
|
|
103
|
+
['Recording ', el('code', started), name ? \` “\${name}”\` : '', '.'],
|
|
104
|
+
'Open the page to test in this tab. Open the stop link when you are done, or close the tab.',
|
|
105
|
+
);
|
|
106
|
+
} else if (stopped && ID.test(stopped)) {
|
|
107
|
+
show(
|
|
108
|
+
'Recording saved',
|
|
109
|
+
['Saved recording ', el('code', stopped), '.'],
|
|
110
|
+
[el('a', 'Watch the replay', { href: \`\${MOUNT}/#/s/\${stopped}\` })],
|
|
111
|
+
['Ask the tabtape MCP tools about it with session_id ', el('code', stopped), '.'],
|
|
112
|
+
);
|
|
113
|
+
} else if (q.has('used') || (q.get('token') && performance.getEntriesByType('navigation')[0]?.type === 'back_forward')) {
|
|
114
|
+
// The extension ignores record links reached with Back or Forward. The
|
|
115
|
+
// token leaves this history entry too: the extension also looks at open
|
|
116
|
+
// tabs when it starts, and must not take this one for a new link.
|
|
117
|
+
if (q.get('token')) history.replaceState(null, '', \`\${location.pathname}?used\`);
|
|
118
|
+
show(
|
|
119
|
+
'Record link already used',
|
|
120
|
+
'Going back or forward to a record link starts and stops nothing; the recording, if any, goes on.',
|
|
121
|
+
'To use the link again, open it anew, or ask get_record_link for a new one.',
|
|
122
|
+
);
|
|
123
|
+
} else if (q.get('token')) {
|
|
124
|
+
const stopping = location.pathname === MOUNT + '/record/stop';
|
|
125
|
+
show(stopping ? 'Stopping the recording…' : 'Starting a recording…', 'The tabtape extension in this browser takes it from here.');
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
show(
|
|
128
|
+
'Nothing happened',
|
|
129
|
+
'No tabtape extension answered in this browser, so nothing is recorded.',
|
|
130
|
+
['Load the extension (', el('code', 'tabtape/extension/dist'), ' of the Repoburg repository, chrome://extensions → Load unpacked) and check that its server is ', el('code', location.origin + MOUNT), '.'],
|
|
131
|
+
);
|
|
132
|
+
}, 5000);
|
|
133
|
+
} else {
|
|
134
|
+
show('Record links', 'This page starts and stops tabtape recordings from links. The tabtape MCP tool get_record_link makes them.');
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
function sendRecordAsset(res, contentType, body) {
|
|
139
|
+
res.writeHead(200, {
|
|
140
|
+
'Content-Type': contentType,
|
|
141
|
+
'Cache-Control': 'no-store',
|
|
142
|
+
// The token is in the URL: never send it on.
|
|
143
|
+
'Referrer-Policy': 'no-referrer',
|
|
144
|
+
'Content-Security-Policy': RECORD_CSP,
|
|
145
|
+
'X-Content-Type-Options': 'nosniff',
|
|
146
|
+
'Content-Length': Buffer.byteLength(body),
|
|
147
|
+
});
|
|
148
|
+
res.end(body);
|
|
149
|
+
}
|