@dikshanty94/webnav 0.2.0
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/LICENSE +202 -0
- package/README.md +283 -0
- package/bin/webnav +18 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.js +267 -0
- package/dist/agent/server.d.ts +60 -0
- package/dist/agent/server.js +254 -0
- package/dist/cli-help.d.ts +3 -0
- package/dist/cli-help.js +111 -0
- package/dist/cli-spec.d.ts +24 -0
- package/dist/cli-spec.js +460 -0
- package/dist/cli.d.ts +208 -0
- package/dist/cli.js +2558 -0
- package/dist/contract.d.ts +20 -0
- package/dist/contract.js +15 -0
- package/dist/creds.d.ts +46 -0
- package/dist/creds.js +126 -0
- package/dist/dashboard/server.d.ts +137 -0
- package/dist/dashboard/server.js +395 -0
- package/dist/dashboard/shell.d.ts +1 -0
- package/dist/dashboard/shell.js +1343 -0
- package/dist/explorer/analyse.d.ts +25 -0
- package/dist/explorer/analyse.js +39 -0
- package/dist/explorer/diff.d.ts +11 -0
- package/dist/explorer/diff.js +22 -0
- package/dist/explorer/draft.d.ts +81 -0
- package/dist/explorer/draft.js +1658 -0
- package/dist/explorer/fingerprint-page.d.ts +11 -0
- package/dist/explorer/fingerprint-page.js +19 -0
- package/dist/explorer/fingerprint.d.ts +14 -0
- package/dist/explorer/fingerprint.js +18 -0
- package/dist/explorer/infer.d.ts +85 -0
- package/dist/explorer/infer.js +370 -0
- package/dist/explorer/pattern-propose.d.ts +27 -0
- package/dist/explorer/pattern-propose.js +81 -0
- package/dist/explorer/patterns.d.ts +49 -0
- package/dist/explorer/patterns.js +290 -0
- package/dist/explorer/shadow.d.ts +3 -0
- package/dist/explorer/shadow.js +95 -0
- package/dist/graph/coverage.d.ts +33 -0
- package/dist/graph/coverage.js +155 -0
- package/dist/graph/edit.d.ts +47 -0
- package/dist/graph/edit.js +211 -0
- package/dist/graph/frontier.d.ts +24 -0
- package/dist/graph/frontier.js +95 -0
- package/dist/graph/seed.d.ts +23 -0
- package/dist/graph/seed.js +29 -0
- package/dist/graph/show.d.ts +8 -0
- package/dist/graph/show.js +6 -0
- package/dist/graph/teach.d.ts +8 -0
- package/dist/graph/teach.js +10 -0
- package/dist/hosted.d.ts +36 -0
- package/dist/hosted.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/mapstore/record.d.ts +128 -0
- package/dist/mapstore/record.js +224 -0
- package/dist/mapstore/schema.sql +47 -0
- package/dist/mapstore/store.d.ts +108 -0
- package/dist/mapstore/store.js +399 -0
- package/dist/mapstore/types.d.ts +87 -0
- package/dist/mapstore/types.js +29 -0
- package/dist/mcp/server.d.ts +46 -0
- package/dist/mcp/server.js +163 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +33 -0
- package/dist/playwright/adapter.d.ts +62 -0
- package/dist/playwright/adapter.js +145 -0
- package/dist/playwright/ax-adapter.d.ts +33 -0
- package/dist/playwright/ax-adapter.js +76 -0
- package/dist/playwright/capture.d.ts +2 -0
- package/dist/playwright/capture.js +10 -0
- package/dist/playwright/fingerprint.d.ts +40 -0
- package/dist/playwright/fingerprint.js +139 -0
- package/dist/playwright/profile-lock.d.ts +15 -0
- package/dist/playwright/profile-lock.js +51 -0
- package/dist/playwright/sessions.d.ts +72 -0
- package/dist/playwright/sessions.js +229 -0
- package/dist/playwright/snapshot.d.ts +10 -0
- package/dist/playwright/snapshot.js +52 -0
- package/dist/playwright/throttle.d.ts +14 -0
- package/dist/playwright/throttle.js +76 -0
- package/dist/playwright/video.d.ts +13 -0
- package/dist/playwright/video.js +32 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +3 -0
- package/dist/recorder/agent-session.d.ts +59 -0
- package/dist/recorder/agent-session.js +230 -0
- package/dist/recorder/capture-loop.d.ts +19 -0
- package/dist/recorder/capture-loop.js +40 -0
- package/dist/recorder/coverage.d.ts +22 -0
- package/dist/recorder/coverage.js +49 -0
- package/dist/recorder/hover-probe.d.ts +35 -0
- package/dist/recorder/hover-probe.js +100 -0
- package/dist/recorder/ingest.d.ts +39 -0
- package/dist/recorder/ingest.js +138 -0
- package/dist/recorder/live-record.d.ts +41 -0
- package/dist/recorder/live-record.js +255 -0
- package/dist/recorder/live.d.ts +53 -0
- package/dist/recorder/live.js +400 -0
- package/dist/recorder/probe.d.ts +14 -0
- package/dist/recorder/probe.js +33 -0
- package/dist/recorder/replay.d.ts +60 -0
- package/dist/recorder/replay.js +401 -0
- package/dist/recorder/review.d.ts +73 -0
- package/dist/recorder/review.js +171 -0
- package/dist/recorder/snapshot-dom.d.ts +7 -0
- package/dist/recorder/snapshot-dom.js +25 -0
- package/dist/router/auth-status.d.ts +20 -0
- package/dist/router/auth-status.js +55 -0
- package/dist/router/browse.d.ts +108 -0
- package/dist/router/browse.js +214 -0
- package/dist/router/catalog.d.ts +18 -0
- package/dist/router/catalog.js +6 -0
- package/dist/router/extract-content.d.ts +13 -0
- package/dist/router/extract-content.js +87 -0
- package/dist/router/live-extension-browser.d.ts +35 -0
- package/dist/router/live-extension-browser.js +175 -0
- package/dist/router/path.d.ts +7 -0
- package/dist/router/path.js +55 -0
- package/dist/router/read.d.ts +26 -0
- package/dist/router/read.js +30 -0
- package/dist/router/readiness.d.ts +9 -0
- package/dist/router/readiness.js +55 -0
- package/dist/router/replay.d.ts +14 -0
- package/dist/router/replay.js +23 -0
- package/dist/router/resolve.d.ts +21 -0
- package/dist/router/resolve.js +33 -0
- package/dist/router/search-live.d.ts +21 -0
- package/dist/router/search-live.js +98 -0
- package/dist/router/search-providers.d.ts +5 -0
- package/dist/router/search-providers.js +12 -0
- package/dist/router/search.d.ts +6 -0
- package/dist/router/search.js +62 -0
- package/dist/router/suite.d.ts +69 -0
- package/dist/router/suite.js +226 -0
- package/dist/router/tokens.d.ts +13 -0
- package/dist/router/tokens.js +26 -0
- package/dist/router/walk-live.d.ts +57 -0
- package/dist/router/walk-live.js +308 -0
- package/dist/router/walk-session.d.ts +49 -0
- package/dist/router/walk-session.js +90 -0
- package/dist/router/walk.d.ts +48 -0
- package/dist/router/walk.js +384 -0
- package/package.json +59 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { SHELL_HTML } from './shell.js';
|
|
4
|
+
const HTML = 'text/html; charset=utf-8';
|
|
5
|
+
const VALID_CATEGORIES = ['login', 'personal', 'other'];
|
|
6
|
+
export function startDashboard(store, creds, opts = {}, rec) {
|
|
7
|
+
const port = opts.port ?? 7777;
|
|
8
|
+
const server = createServer(async (req, res) => {
|
|
9
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
10
|
+
const path = url.pathname;
|
|
11
|
+
const method = req.method ?? 'GET';
|
|
12
|
+
const sendJson = (code, body) => res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
13
|
+
try {
|
|
14
|
+
// The pill in the RECORDED page POSTs its toggle here — the one deliberately
|
|
15
|
+
// cross-origin route (final-review posture: single-user localhost; response
|
|
16
|
+
// carries only {recording:bool}). Its JSON body triggers a browser PREFLIGHT,
|
|
17
|
+
// and Chrome's Private Network Access additionally requires the
|
|
18
|
+
// allow-private-network header for public→localhost calls (live failure:
|
|
19
|
+
// preflight CORS error → the stop POST was never sent).
|
|
20
|
+
const toggleRoute = /^\/api\/recordings\/[^/]+\/toggle$/.test(path);
|
|
21
|
+
if (toggleRoute) {
|
|
22
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
23
|
+
if (method === 'OPTIONS') {
|
|
24
|
+
return res.writeHead(204, {
|
|
25
|
+
'access-control-allow-methods': 'POST',
|
|
26
|
+
'access-control-allow-headers': 'content-type',
|
|
27
|
+
'access-control-allow-private-network': 'true',
|
|
28
|
+
'access-control-max-age': '600',
|
|
29
|
+
}).end();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ---- the dashboard shell (sites + credentials tabs) ----
|
|
33
|
+
if (path === '/' && method === 'GET')
|
|
34
|
+
return res.writeHead(200, { 'content-type': HTML }).end(SHELL_HTML);
|
|
35
|
+
// ---- SITES (read-only) ----
|
|
36
|
+
if (path === '/api/sites' && method === 'GET') {
|
|
37
|
+
const sites = store.allNodes().map((n) => ({
|
|
38
|
+
id: n.id, homeUrl: n.homeUrl,
|
|
39
|
+
stateCount: store.statesForNode(n.id).length,
|
|
40
|
+
}));
|
|
41
|
+
return sendJson(200, sites);
|
|
42
|
+
}
|
|
43
|
+
const siteM = path.match(/^\/api\/sites\/([^/]+)$/);
|
|
44
|
+
if (siteM && method === 'GET') {
|
|
45
|
+
const id = decodeURIComponent(siteM[1]);
|
|
46
|
+
const node = store.getNode(id);
|
|
47
|
+
if (!node)
|
|
48
|
+
return sendJson(404, { error: 'unknown site' });
|
|
49
|
+
return sendJson(200, {
|
|
50
|
+
node,
|
|
51
|
+
states: store.statesForNode(id),
|
|
52
|
+
interiorEdges: store.interiorEdges(id),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// ---- CREDENTIALS (read masked+categorized · reveal one · write/remove/recategorize) ----
|
|
56
|
+
if (path === '/api/creds' && method === 'GET') {
|
|
57
|
+
return sendJson(200, creds.listDetailed()); // key names + categories — never values
|
|
58
|
+
}
|
|
59
|
+
// recategorize: PATCH /api/creds/:site/:key/category body { category }
|
|
60
|
+
const catM = path.match(/^\/api\/creds\/([^/]+)\/([^/]+)\/category$/);
|
|
61
|
+
if (catM && method === 'POST') {
|
|
62
|
+
const site = decodeURIComponent(catM[1]);
|
|
63
|
+
const key = decodeURIComponent(catM[2]);
|
|
64
|
+
const body = await readBody(req);
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(body || '{}');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return sendJson(400, { error: 'invalid JSON body' });
|
|
71
|
+
}
|
|
72
|
+
if (!parsed.category || !VALID_CATEGORIES.includes(parsed.category)) {
|
|
73
|
+
return sendJson(400, { error: 'category must be one of ' + VALID_CATEGORIES.join(', ') });
|
|
74
|
+
}
|
|
75
|
+
const ok = creds.setCategory(site, key, parsed.category);
|
|
76
|
+
return sendJson(ok ? 200 : 404, { site, key, category: parsed.category, ok });
|
|
77
|
+
}
|
|
78
|
+
const credKeyM = path.match(/^\/api\/creds\/([^/]+)\/([^/]+)$/);
|
|
79
|
+
if (credKeyM && method === 'GET') {
|
|
80
|
+
// reveal-on-demand: the ONLY route that returns a plaintext value (localhost only)
|
|
81
|
+
const site = decodeURIComponent(credKeyM[1]);
|
|
82
|
+
const key = decodeURIComponent(credKeyM[2]);
|
|
83
|
+
const value = creds.get(site)[key];
|
|
84
|
+
if (value === undefined)
|
|
85
|
+
return sendJson(404, { error: 'unknown credential' });
|
|
86
|
+
return sendJson(200, { site, key, value });
|
|
87
|
+
}
|
|
88
|
+
if (credKeyM && method === 'DELETE') {
|
|
89
|
+
const site = decodeURIComponent(credKeyM[1]);
|
|
90
|
+
const key = decodeURIComponent(credKeyM[2]);
|
|
91
|
+
const removed = creds.remove(site, key);
|
|
92
|
+
return sendJson(removed ? 200 : 404, { site, key, removed });
|
|
93
|
+
}
|
|
94
|
+
const credSiteM = path.match(/^\/api\/creds\/([^/]+)$/);
|
|
95
|
+
if (credSiteM && method === 'POST') {
|
|
96
|
+
// set/update one slot's VALUE (also used by inline-edit). Optional category;
|
|
97
|
+
// omitted → CredStore preserves an existing key's category or infers a default.
|
|
98
|
+
const site = decodeURIComponent(credSiteM[1]);
|
|
99
|
+
const body = await readBody(req);
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(body || '{}');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return sendJson(400, { error: 'invalid JSON body' });
|
|
106
|
+
}
|
|
107
|
+
if (!parsed.key || typeof parsed.value !== 'string') {
|
|
108
|
+
return sendJson(400, { error: 'body must be { key, value, category? }' });
|
|
109
|
+
}
|
|
110
|
+
if (parsed.category && !VALID_CATEGORIES.includes(parsed.category)) {
|
|
111
|
+
return sendJson(400, { error: 'category must be one of ' + VALID_CATEGORIES.join(', ') });
|
|
112
|
+
}
|
|
113
|
+
const keys = creds.set(site, { [parsed.key]: parsed.value }, parsed.category);
|
|
114
|
+
return sendJson(200, { site, keys });
|
|
115
|
+
}
|
|
116
|
+
if (credSiteM && method === 'DELETE') {
|
|
117
|
+
const site = decodeURIComponent(credSiteM[1]);
|
|
118
|
+
const removed = creds.remove(site);
|
|
119
|
+
return sendJson(removed ? 200 : 404, { site, removed });
|
|
120
|
+
}
|
|
121
|
+
// ---- RECORDINGS + REPLAY (human-session recorder; injected — 503 when not wired) ----
|
|
122
|
+
if (path.startsWith('/api/recordings') || path.startsWith('/api/replay') || path.startsWith('/replays/') || path.startsWith('/recordings-media/') || path.startsWith('/review-media/') || path === '/api/events' || path === '/api/logs' || path === '/api/notify' || path === '/api/review-config' || path === '/api/profiles' || path.startsWith('/api/profiles/')) {
|
|
123
|
+
if (!rec)
|
|
124
|
+
return sendJson(503, { error: 'recordings not wired' });
|
|
125
|
+
if (path === '/api/recordings' && method === 'GET')
|
|
126
|
+
return sendJson(200, await rec.list());
|
|
127
|
+
if (path === '/api/recordings/window' && method === 'GET')
|
|
128
|
+
return sendJson(200, { session: rec.activeWindow() });
|
|
129
|
+
if (path === '/api/logs' && method === 'GET')
|
|
130
|
+
return sendJson(200, rec.logs());
|
|
131
|
+
if (path === '/api/notify' && method === 'POST') {
|
|
132
|
+
// cross-process realtime: a separate `use session` process POSTs here after
|
|
133
|
+
// each step/log so the dashboard pushes it over SSE (localhost-only, same
|
|
134
|
+
// posture as the toggle route). Body: { kind?, line? }.
|
|
135
|
+
let body = {};
|
|
136
|
+
try {
|
|
137
|
+
body = JSON.parse((await readBody(req)) || '{}');
|
|
138
|
+
}
|
|
139
|
+
catch { /* */ }
|
|
140
|
+
rec.notify(body.kind ?? 'sessions', body.line);
|
|
141
|
+
return sendJson(200, { ok: true });
|
|
142
|
+
}
|
|
143
|
+
if (path === '/api/review-config' && method === 'GET')
|
|
144
|
+
return sendJson(200, rec.reviewConfig());
|
|
145
|
+
if (path === '/api/profiles' && method === 'GET')
|
|
146
|
+
return sendJson(200, rec.profiles());
|
|
147
|
+
if (path === '/api/profiles' && method === 'POST') { // create a named profile
|
|
148
|
+
let body = {};
|
|
149
|
+
try {
|
|
150
|
+
body = JSON.parse((await readBody(req)) || '{}');
|
|
151
|
+
}
|
|
152
|
+
catch { /* */ }
|
|
153
|
+
if (!body.name)
|
|
154
|
+
return sendJson(400, { ok: false, error: 'name required' });
|
|
155
|
+
const r = rec.profileNew(body.name);
|
|
156
|
+
return sendJson(r.ok ? 200 : 409, r);
|
|
157
|
+
}
|
|
158
|
+
const profOpenM = path.match(/^\/api\/profiles\/([^/]+)\/open$/);
|
|
159
|
+
if (profOpenM && method === 'POST') {
|
|
160
|
+
const r = await rec.profileOpen(decodeURIComponent(profOpenM[1]));
|
|
161
|
+
return sendJson(r.ok ? 200 : 409, r);
|
|
162
|
+
}
|
|
163
|
+
const profRenameM = path.match(/^\/api\/profiles\/([^/]+)\/rename$/);
|
|
164
|
+
if (profRenameM && method === 'POST') {
|
|
165
|
+
let body = {};
|
|
166
|
+
try {
|
|
167
|
+
body = JSON.parse((await readBody(req)) || '{}');
|
|
168
|
+
}
|
|
169
|
+
catch { /* */ }
|
|
170
|
+
if (!body.to)
|
|
171
|
+
return sendJson(400, { ok: false, error: 'new name required' });
|
|
172
|
+
const r = rec.profileRename(decodeURIComponent(profRenameM[1]), body.to);
|
|
173
|
+
return sendJson(r.ok ? 200 : 409, r);
|
|
174
|
+
}
|
|
175
|
+
const profM = path.match(/^\/api\/profiles\/([^/]+)$/);
|
|
176
|
+
if (profM && method === 'DELETE') {
|
|
177
|
+
const r = rec.profileDelete(decodeURIComponent(profM[1]));
|
|
178
|
+
return sendJson(r.ok ? 200 : 404, r);
|
|
179
|
+
}
|
|
180
|
+
const profStatusM = path.match(/^\/api\/profiles\/([^/]+)\/status$/);
|
|
181
|
+
if (profStatusM && method === 'POST') {
|
|
182
|
+
let body = {};
|
|
183
|
+
try {
|
|
184
|
+
body = JSON.parse((await readBody(req)) || '{}');
|
|
185
|
+
}
|
|
186
|
+
catch { /* */ }
|
|
187
|
+
if (!body.site)
|
|
188
|
+
return sendJson(400, { ok: false, error: 'site required' });
|
|
189
|
+
const r = await rec.profileStatus(decodeURIComponent(profStatusM[1]), body.site);
|
|
190
|
+
return sendJson(r.ok ? 200 : 409, r);
|
|
191
|
+
}
|
|
192
|
+
const profResetM = path.match(/^\/api\/profiles\/([^/]+)\/reset$/);
|
|
193
|
+
if (profResetM && method === 'POST') {
|
|
194
|
+
const r = rec.profileReset(decodeURIComponent(profResetM[1]));
|
|
195
|
+
return sendJson(r.ok ? 200 : 409, r);
|
|
196
|
+
}
|
|
197
|
+
const revM = path.match(/^\/api\/recordings\/([^/]+)\/review$/);
|
|
198
|
+
if (revM && method === 'POST') {
|
|
199
|
+
let opts = {};
|
|
200
|
+
try {
|
|
201
|
+
opts = JSON.parse((await readBody(req)) || '{}');
|
|
202
|
+
}
|
|
203
|
+
catch { /* defaults */ }
|
|
204
|
+
const r = rec.review(decodeURIComponent(revM[1]), opts);
|
|
205
|
+
return sendJson(r.ok ? 200 : 409, r);
|
|
206
|
+
}
|
|
207
|
+
if (revM && method === 'GET') {
|
|
208
|
+
const id = decodeURIComponent(revM[1]);
|
|
209
|
+
const rep = rec.reviewReport(id);
|
|
210
|
+
const running = rec.reviewRunning() === id;
|
|
211
|
+
return rep === null
|
|
212
|
+
? sendJson(running ? 200 : 404, running ? { running: true } : { error: 'no review yet' })
|
|
213
|
+
: sendJson(200, { ...rep, running });
|
|
214
|
+
}
|
|
215
|
+
const rfM = path.match(/^\/review-media\/([^/]+)\/([^/]+\.png)$/);
|
|
216
|
+
if (rfM && method === 'GET') {
|
|
217
|
+
const fp = rec.reviewFramePath(decodeURIComponent(rfM[1]), decodeURIComponent(rfM[2]));
|
|
218
|
+
if (!fp || !existsSync(fp))
|
|
219
|
+
return sendJson(404, { error: 'not found' });
|
|
220
|
+
const fs2 = createReadStream(fp);
|
|
221
|
+
fs2.on('error', () => { if (!res.headersSent)
|
|
222
|
+
sendJson(404, { error: 'not found' });
|
|
223
|
+
else
|
|
224
|
+
res.destroy(); });
|
|
225
|
+
res.writeHead(200, { 'content-type': 'image/png' });
|
|
226
|
+
return fs2.pipe(res);
|
|
227
|
+
}
|
|
228
|
+
// realtime push: Server-Sent Events. The shell's EventSource replaces polling.
|
|
229
|
+
if (path === '/api/events' && method === 'GET') {
|
|
230
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
231
|
+
res.write(': connected\n\n');
|
|
232
|
+
const un = rec.subscribe((type) => res.write('data: ' + type + '\n\n'));
|
|
233
|
+
const beat = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
234
|
+
req.on('close', () => { clearInterval(beat); un(); });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const toggleM = path.match(/^\/api\/recordings\/([^/]+)\/toggle$/);
|
|
238
|
+
if (toggleM && method === 'POST') {
|
|
239
|
+
// optional {recording: boolean} = DESIRED state (idempotent; the page pill
|
|
240
|
+
// sends this so a stale visual can't double-toggle). No body → flip.
|
|
241
|
+
let desired;
|
|
242
|
+
try {
|
|
243
|
+
const b = JSON.parse((await readBody(req)) || '{}');
|
|
244
|
+
if (typeof b.recording === 'boolean')
|
|
245
|
+
desired = b.recording;
|
|
246
|
+
}
|
|
247
|
+
catch { /* flip */ }
|
|
248
|
+
return sendJson(200, rec.toggle(decodeURIComponent(toggleM[1]), desired));
|
|
249
|
+
}
|
|
250
|
+
const vidsM = path.match(/^\/api\/recordings\/([^/]+)\/videos$/);
|
|
251
|
+
if (vidsM && method === 'GET')
|
|
252
|
+
return sendJson(200, rec.videos(decodeURIComponent(vidsM[1])));
|
|
253
|
+
const stepsM = path.match(/^\/api\/recordings\/([^/]+)\/steps$/);
|
|
254
|
+
if (stepsM && method === 'GET')
|
|
255
|
+
return sendJson(200, rec.steps(decodeURIComponent(stepsM[1])));
|
|
256
|
+
const evM = path.match(/^\/api\/recordings\/([^/]+)\/events$/);
|
|
257
|
+
if (evM && method === 'GET')
|
|
258
|
+
return sendJson(200, rec.events(decodeURIComponent(evM[1])));
|
|
259
|
+
const draftM = path.match(/^\/api\/recordings\/([^/]+)\/draft$/);
|
|
260
|
+
if (draftM && method === 'GET')
|
|
261
|
+
return sendJson(200, rec.draft(decodeURIComponent(draftM[1])));
|
|
262
|
+
if (path === '/api/recordings/open' && method === 'POST') {
|
|
263
|
+
const body = await readBody(req);
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(body || '{}');
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return sendJson(400, { error: 'invalid JSON body' });
|
|
270
|
+
}
|
|
271
|
+
if (!parsed.url || !parsed.session)
|
|
272
|
+
return sendJson(400, { error: 'body must be { url, session, persistent? }' });
|
|
273
|
+
const result = await rec.open(parsed.url, parsed.session, !!parsed.persistent, !!parsed.armedOnly, parsed.profile);
|
|
274
|
+
return sendJson(result.ok ? 200 : 409, result);
|
|
275
|
+
}
|
|
276
|
+
const recordM = path.match(/^\/api\/recordings\/([^/]+)\/record$/);
|
|
277
|
+
if (recordM && method === 'POST') {
|
|
278
|
+
const ok = rec.record(decodeURIComponent(recordM[1]));
|
|
279
|
+
return sendJson(ok ? 200 : 404, { ok });
|
|
280
|
+
}
|
|
281
|
+
const stopM = path.match(/^\/api\/recordings\/([^/]+)\/stop$/);
|
|
282
|
+
if (stopM && method === 'POST') {
|
|
283
|
+
const ok = rec.stop(decodeURIComponent(stopM[1]));
|
|
284
|
+
return sendJson(ok ? 200 : 404, { ok });
|
|
285
|
+
}
|
|
286
|
+
const replayM = path.match(/^\/api\/recordings\/([^/]+)\/replay$/);
|
|
287
|
+
if (replayM && method === 'POST') {
|
|
288
|
+
let mode = 'steps';
|
|
289
|
+
try {
|
|
290
|
+
const b = JSON.parse(await readBody(req));
|
|
291
|
+
if (b?.mode === 'ledger')
|
|
292
|
+
mode = 'ledger';
|
|
293
|
+
}
|
|
294
|
+
catch { /* empty body = steps */ }
|
|
295
|
+
const result = await rec.replay(decodeURIComponent(replayM[1]), mode);
|
|
296
|
+
return sendJson(result.ok ? 200 : 409, result);
|
|
297
|
+
}
|
|
298
|
+
if (path === '/api/replay/status' && method === 'GET') {
|
|
299
|
+
return sendJson(200, rec.replayState() ?? { running: false });
|
|
300
|
+
}
|
|
301
|
+
if (path === '/api/replay/control' && method === 'POST') {
|
|
302
|
+
const body = await readBody(req);
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = JSON.parse(body || '{}');
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return sendJson(400, { error: 'invalid JSON body' });
|
|
309
|
+
}
|
|
310
|
+
if (!parsed.action)
|
|
311
|
+
return sendJson(400, { error: 'body must be { action, value?, save?, fire? }' });
|
|
312
|
+
const ok = rec.replayControl(parsed.action, { value: parsed.value, save: parsed.save, fire: parsed.fire });
|
|
313
|
+
return sendJson(ok ? 200 : 400, { ok });
|
|
314
|
+
}
|
|
315
|
+
const vidM = path.match(/^\/recordings-media\/([^/]+)\/([^/]+\.webm)$/);
|
|
316
|
+
if (vidM && method === 'GET') {
|
|
317
|
+
const vp = rec.videoPath(decodeURIComponent(vidM[1]), decodeURIComponent(vidM[2]));
|
|
318
|
+
if (!vp || !existsSync(vp))
|
|
319
|
+
return sendJson(404, { error: 'not found' });
|
|
320
|
+
// Seeking needs byte-range support: without Accept-Ranges/206 the browser
|
|
321
|
+
// can only play the stream forward (live finding: dead scrubber). Single
|
|
322
|
+
// range only — that's all <video> asks for.
|
|
323
|
+
const size = statSync(vp).size;
|
|
324
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ''));
|
|
325
|
+
let start = 0, end = size - 1, code = 200;
|
|
326
|
+
const head = { 'content-type': 'video/webm', 'accept-ranges': 'bytes' };
|
|
327
|
+
if (range && (range[1] || range[2])) {
|
|
328
|
+
start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
|
|
329
|
+
end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : end;
|
|
330
|
+
if (start > end || start >= size) {
|
|
331
|
+
res.writeHead(416, { 'content-range': 'bytes */' + size });
|
|
332
|
+
return res.end();
|
|
333
|
+
}
|
|
334
|
+
code = 206;
|
|
335
|
+
head['content-range'] = 'bytes ' + start + '-' + end + '/' + size;
|
|
336
|
+
}
|
|
337
|
+
head['content-length'] = end - start + 1;
|
|
338
|
+
const vs = createReadStream(vp, { start, end });
|
|
339
|
+
vs.on('error', () => { if (!res.headersSent)
|
|
340
|
+
sendJson(404, { error: 'not found' });
|
|
341
|
+
else
|
|
342
|
+
res.destroy(); });
|
|
343
|
+
res.writeHead(code, head);
|
|
344
|
+
return vs.pipe(res);
|
|
345
|
+
}
|
|
346
|
+
const shotM = path.match(/^\/replays\/([^/]+)\/([^/]+\.png)$/);
|
|
347
|
+
if (shotM && method === 'GET') {
|
|
348
|
+
const shotPath = rec.shotPath(decodeURIComponent(shotM[1]), decodeURIComponent(shotM[2]));
|
|
349
|
+
if (!shotPath || !existsSync(shotPath))
|
|
350
|
+
return sendJson(404, { error: 'not found' });
|
|
351
|
+
const stream = createReadStream(shotPath);
|
|
352
|
+
// file can vanish between existsSync and open (recording deleted mid-view) —
|
|
353
|
+
// without this handler that's an unhandled stream error (final-review #4).
|
|
354
|
+
stream.on('error', () => { if (!res.headersSent)
|
|
355
|
+
sendJson(404, { error: 'not found' });
|
|
356
|
+
else
|
|
357
|
+
res.destroy(); });
|
|
358
|
+
res.writeHead(200, { 'content-type': 'image/png' });
|
|
359
|
+
return stream.pipe(res);
|
|
360
|
+
}
|
|
361
|
+
const delM = path.match(/^\/api\/recordings\/([^/]+)$/);
|
|
362
|
+
if (delM && method === 'DELETE') {
|
|
363
|
+
rec.del(decodeURIComponent(delM[1]));
|
|
364
|
+
return sendJson(200, { ok: true });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (method !== 'GET' && method !== 'POST' && method !== 'DELETE') {
|
|
368
|
+
return sendJson(405, { error: 'method not allowed' });
|
|
369
|
+
}
|
|
370
|
+
return sendJson(404, { error: 'not found', path });
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
sendJson(500, { error: String(e) });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
// Clear message + nonzero exit on bind failure (e.g. port in use) instead of a raw stack.
|
|
377
|
+
server.on('error', (err) => {
|
|
378
|
+
const hint = err.code === 'EADDRINUSE'
|
|
379
|
+
? `port ${port} is already in use — set WEBNAV_PORT or pass --port <n>`
|
|
380
|
+
: err.message;
|
|
381
|
+
process.stderr.write(`webnav dashboard: ${hint}\n`);
|
|
382
|
+
process.exitCode = 2;
|
|
383
|
+
});
|
|
384
|
+
server.listen(port, '127.0.0.1');
|
|
385
|
+
return server;
|
|
386
|
+
}
|
|
387
|
+
function readBody(req) {
|
|
388
|
+
return new Promise((resolve, reject) => {
|
|
389
|
+
let data = '';
|
|
390
|
+
req.on('data', (c) => { data += c; if (data.length > 1_000_000)
|
|
391
|
+
req.destroy(); });
|
|
392
|
+
req.on('end', () => resolve(data));
|
|
393
|
+
req.on('error', reject);
|
|
394
|
+
});
|
|
395
|
+
}
|