wisp-agents 0.15.0 → 0.16.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +28 -12
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5237
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1910
@@ -1,22 +1,13 @@
1
- // Per-turn git checkpoints, single-file revert, turn rollback, and action command execution.
1
+ // Per-turn git checkpoints, single-file revert and turn rollback.
2
2
 
3
- import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
4
3
  import fs from 'node:fs';
5
4
  import path from 'node:path';
6
- import { killTree } from './proc.js';
7
- import { MAX_SNAPSHOT, rel } from './adapters/common.js';
5
+ import { gitSync } from './git.js';
6
+ import { MAX_SNAPSHOT, rel } from './paths.js';
7
+ import { bad } from './util.js';
8
8
 
9
- const MAX_EXEC_OUTPUT = 32 * 1024;
10
- const EXEC_TIMEOUT_MS = 60_000;
11
9
 
12
- function git(cwd, args, opts = {}) {
13
- return execFileSync('git', args, {
14
- cwd,
15
- stdio: ['ignore', 'pipe', 'ignore'],
16
- timeout: 5000,
17
- ...opts,
18
- });
19
- }
10
+ const git = gitSync;
20
11
 
21
12
  function tryGit(cwd, args, opts) {
22
13
  try {
@@ -100,12 +91,12 @@ function writeDiskSnapshot(abs, content) {
100
91
 
101
92
  function resolveInsideCwd(cwd, file) {
102
93
  if (!file || typeof file !== 'string') {
103
- throw Object.assign(new Error('file path required'), { status: 400 });
94
+ throw bad('file path required');
104
95
  }
105
96
  const abs = path.resolve(cwd, file);
106
97
  const relPath = path.relative(cwd, abs);
107
98
  if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
108
- throw Object.assign(new Error(`File lies outside the project folder: ${file}`), { status: 400 });
99
+ throw bad(`File lies outside the project folder: ${file}`);
109
100
  }
110
101
  return { abs, relPath: rel(file, cwd) || relPath };
111
102
  }
@@ -128,7 +119,7 @@ export function revertFileChange(session, turn, file, { restore = false } = {})
128
119
  const entry = findChangeEntry(turn, file, cwd);
129
120
  const commit = turn.checkpoint?.commit;
130
121
  if (!entry && !commit) {
131
- throw Object.assign(new Error(`No captured changes or checkpoint for ${relPath}`), { status: 404 });
122
+ throw bad(`No captured changes or checkpoint for ${relPath}`, 404);
132
123
  }
133
124
 
134
125
  if (restore) {
@@ -145,7 +136,7 @@ export function revertFileChange(session, turn, file, { restore = false } = {})
145
136
  writeDiskSnapshot(abs, cur);
146
137
  }
147
138
  } else {
148
- throw Object.assign(new Error(`Nothing to re-apply for ${relPath}`), { status: 400 });
139
+ throw bad(`Nothing to re-apply for ${relPath}`);
149
140
  }
150
141
  if (entry) entry.reverted = false;
151
142
  if (turn.changes?.every((c) => !c.reverted)) delete turn.rolledBackAt;
@@ -200,7 +191,7 @@ function reverseEditsOnDisk(abs, entry) {
200
191
  export function rollbackToTurn(session, turn, { restore = false } = {}) {
201
192
  const cwd = sessionDir(session);
202
193
  const idx = session.turns.indexOf(turn);
203
- if (idx < 0) throw Object.assign(new Error('No such turn'), { status: 404 });
194
+ if (idx < 0) throw bad('No such turn', 404);
204
195
  const affected = session.turns.slice(idx);
205
196
  const touchedFiles = [...new Set(affected.flatMap((t) => (t.changes || []).map((c) => c.file)))];
206
197
 
@@ -297,52 +288,5 @@ export function rollbackToTurn(session, turn, { restore = false } = {}) {
297
288
  return { files: touchedFiles, rolledBack: true, commit: cp?.head || cp?.commit || null };
298
289
  }
299
290
 
300
- // Run a shell command from an actionable presentation block inside `cwd`.
301
- export function execCommand(cwd, command, { timeoutMs = EXEC_TIMEOUT_MS } = {}) {
302
- const cmd = String(command || '').trim();
303
- if (!cmd) return Promise.reject(Object.assign(new Error('command required'), { status: 400 }));
304
- const t0 = Date.now();
305
-
306
- return new Promise((resolve) => {
307
- const child = nodeSpawn(cmd, {
308
- cwd,
309
- env: { ...process.env, PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', CI: '1' },
310
- shell: true,
311
- windowsHide: true,
312
- stdio: ['ignore', 'pipe', 'pipe'],
313
- });
314
- let out = '';
315
- const append = (chunk) => {
316
- if (out.length < MAX_EXEC_OUTPUT) out += chunk.toString('utf8').slice(0, MAX_EXEC_OUTPUT - out.length);
317
- };
318
- child.stdout.on('data', append);
319
- child.stderr.on('data', append);
320
-
321
- let timedOut = false;
322
- const timer = setTimeout(() => {
323
- timedOut = true;
324
- killTree(child);
325
- }, timeoutMs);
326
-
327
- child.on('error', (err) => {
328
- clearTimeout(timer);
329
- resolve({
330
- command: cmd,
331
- output: (out ? `${out}\n` : '') + err.message,
332
- exitCode: 1,
333
- durationMs: Date.now() - t0,
334
- });
335
- });
336
-
337
- child.on('close', (code) => {
338
- clearTimeout(timer);
339
- const suffix = timedOut ? `\n[timed out after ${Math.round(timeoutMs / 1000)}s]` : '';
340
- resolve({
341
- command: cmd,
342
- output: (out.replace(/\s+$/, '') + suffix).trim(),
343
- exitCode: timedOut ? 124 : (code ?? 1),
344
- durationMs: Date.now() - t0,
345
- });
346
- });
347
- });
348
- }
291
+ // Moved to proc.js; kept here for anything that still imports it from checkpoint.
292
+ export { execCommand } from './proc.js';
package/server/client.js CHANGED
@@ -13,8 +13,9 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { DEFAULT_PORT } from './util.js';
16
17
 
17
- export const DEFAULT_PORT = 4477;
18
+ export { DEFAULT_PORT };
18
19
  const BIN = fileURLToPath(new URL('../bin/wisp.js', import.meta.url));
19
20
  const MAX_WAIT_CALL = 280; // seconds per long-poll; fetch gives up on headers after 300s
20
21
 
@@ -154,8 +155,9 @@ export class WispClient {
154
155
  usage = ({ days } = {}) => this.request('GET', `/usage${days ? `?days=${days}` : ''}`);
155
156
 
156
157
  // A follow-up. If the agent is blocked on ask_user, this answers it. queue: wait for the
157
- // current run instead of interrupting it (default: the user's setting).
158
- reply = (id, text, { attachments, queue, mode } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode });
158
+ // current run instead of interrupting it (default: the user's setting). agent / model / effort:
159
+ // run it, and the follow-ups after it, on another harness, model or thinking level.
160
+ reply = (id, text, { attachments, queue, mode, agent, model, effort } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode, agent, model, effort });
159
161
 
160
162
  // Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
161
163
  async upload(file, { name, type } = {}) {
package/server/git.js CHANGED
@@ -3,7 +3,9 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
6
7
  import { execFile } from './proc.js';
8
+ import { bad } from './util.js';
7
9
 
8
10
  const GIT = () => process.env.WISP_GIT_BIN || 'git';
9
11
  const GH = () => process.env.WISP_GH_BIN || 'gh';
@@ -22,6 +24,9 @@ export function run(bin, args, { cwd, timeout = 60_000 } = {}) {
22
24
  });
23
25
  }
24
26
  export const git = (cwd, args, opts) => run(GIT(), args, { cwd, ...opts });
27
+ // Synchronous, for the few places that must not yield (checkpoints taken right before a turn
28
+ // starts, file snapshots while a harness streams). Returns a Buffer; throws on failure.
29
+ export const gitSync = (cwd, args, opts = {}) => execFileSync(GIT(), args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, env: { ...process.env, ...QUIET_ENV }, ...opts });
25
30
  export const gh = (cwd, args, opts) => run(GH(), args, { cwd, timeout: 120_000, ...opts });
26
31
  const quiet = (p) => p.catch(() => '');
27
32
 
@@ -161,13 +166,13 @@ export async function ship(wt, { title, body = '', draft = false, pr }) {
161
166
  await ensureWorktree(wt);
162
167
  const dir = wt.path;
163
168
  if (await quiet(git(dir, ['status', '--porcelain']))) {
164
- if (!String(title || '').trim()) throw Object.assign(new Error('A commit message is required.'), { status: 400 });
169
+ if (!String(title || '').trim()) throw bad('A commit message is required.');
165
170
  await git(dir, ['add', '-A']);
166
171
  await git(dir, ['commit', '--quiet', '--no-verify', '-m', title.trim(), ...(body.trim() ? ['-m', body.trim()] : [])]);
167
172
  }
168
173
  const { ahead } = await worktreeStatus(wt);
169
- if (!ahead) throw Object.assign(new Error('Nothing to ship: this branch has no changes.'), { status: 400 });
170
- if (!wt.remote) throw Object.assign(new Error('This repo has no origin remote to push to.'), { status: 400 });
174
+ if (!ahead) throw bad('Nothing to ship: this branch has no changes.');
175
+ if (!wt.remote) throw bad('This repo has no origin remote to push to.');
171
176
  await git(dir, ['push', '--quiet', '-u', wt.remote, `HEAD:refs/heads/${wt.branch}`], { timeout: 120_000 });
172
177
  if (pr?.state === 'OPEN') return prView(dir, pr.number);
173
178
  const url = (await gh(dir, ['pr', 'create', '--base', wt.base, '--head', wt.branch, '--title', title.trim(), '--body', body.trim() || ' ', ...(draft ? ['--draft'] : [])])).split('\n').pop();
@@ -178,7 +183,7 @@ export async function ship(wt, { title, body = '', draft = false, pr }) {
178
183
  export async function merge(wt, number) {
179
184
  const dir = fs.existsSync(wt.path) ? wt.path : wt.root;
180
185
  let pr = await prView(dir, number);
181
- if (pr.state !== 'OPEN') throw Object.assign(new Error(`PR #${number} is ${pr.state.toLowerCase()}.`), { status: 400 });
186
+ if (pr.state !== 'OPEN') throw bad(`PR #${number} is ${pr.state.toLowerCase()}.`);
182
187
  if (pr.draft) await gh(dir, ['pr', 'ready', String(number)]);
183
188
  await gh(dir, ['pr', 'merge', String(number), '--squash', '--match-head-commit', pr.head]);
184
189
  pr = await prView(dir, number);
@@ -0,0 +1,70 @@
1
+ // Switching harness in a follow-up. Each harness keeps its own conversation (native session id),
2
+ // so a harness picked up mid-task hasn't seen the turns that ran elsewhere. handoffNote() writes
3
+ // those turns out (requests, reports, files touched) and goes ahead of the follow-up's prompt.
4
+ // A harness that ran earlier in the task resumes its own session and only hears what it missed.
5
+
6
+ import { blocksToMarkdown } from './render.js';
7
+ import { clip, truncate } from './util.js';
8
+
9
+ const BUDGET = 24_000; // characters of earlier turns, newest kept first
10
+ const PER_TURN = 6_000;
11
+ // The parts of a report worth passing on. Diffs, code and terminal output are on disk or in git.
12
+ const KEEP = new Set(['hero', 'markdown', 'steps', 'stats', 'files', 'tests', 'commits', 'checklist', 'callout', 'question', 'next']);
13
+
14
+ // The turns before the current one that `agent` hasn't seen: everything after the last turn it
15
+ // ran, or all of them when it has no conversation of its own to resume (resumeId). Only a gap
16
+ // that another harness filled counts: on its own, a harness has nothing to catch up on.
17
+ export function missedTurns(session, agent, resumeId) {
18
+ const prior = session.turns.slice(0, -1);
19
+ const missed = resumeId ? prior.slice(prior.findLastIndex((t) => (t.agent || agent) === agent) + 1) : prior;
20
+ return missed.some((t) => (t.agent || agent) !== agent) ? missed : [];
21
+ }
22
+
23
+ function turnText(t, n, name) {
24
+ const blocks = (t.presentation?.blocks || []).filter((b) => KEEP.has(b?.type));
25
+ const report = t.status === 'running' ? '' : blocksToMarkdown(blocks, t) || clip(t.raw, 1500);
26
+ const files = (t.changes || []).filter((c) => !c.reverted).map((c) => `\`${c.file}\``);
27
+ return truncate(
28
+ [
29
+ `### Turn ${n} · ${name(t)}${t.model ? ` (${t.model})` : ''}${t.status === 'stopped' ? ' · stopped' : t.status === 'error' ? ' · failed' : ''}${t.rolledBackAt ? ' · rolled back, its edits were undone' : ''}`,
30
+ `**User:** ${t.prompt}`,
31
+ report && `**Report:**\n\n${report}`,
32
+ files.length && `**Files it edited:** ${files.join(', ')}`,
33
+ ]
34
+ .filter(Boolean)
35
+ .join('\n\n'),
36
+ PER_TURN,
37
+ );
38
+ }
39
+
40
+ // The note for `agent` taking the next turn, or '' when it has seen everything.
41
+ // name(turn) → the harness's display name for that turn.
42
+ export function handoffNote(session, agent, resumeId, name) {
43
+ const missed = missedTurns(session, agent, resumeId);
44
+ if (!missed.length) return '';
45
+ const all = session.turns.slice(0, -1);
46
+ const parts = [];
47
+ let used = 0;
48
+ for (let i = missed.length - 1; i >= 0; i--) {
49
+ const text = turnText(missed[i], all.indexOf(missed[i]) + 1, name);
50
+ if (used + text.length > BUDGET && parts.length) break;
51
+ parts.unshift(text);
52
+ used += text.length;
53
+ }
54
+ const dropped = missed.length - parts.length;
55
+ const fresh = !resumeId;
56
+ const others = [...new Set(missed.filter((t) => t.agent !== agent).map(name))].join(', ');
57
+ const intro = fresh
58
+ ? `You are taking over a task that another coding agent (${others}) has been working on. Its work is already in this folder. Below is what happened so far, oldest first; read the files and \`git diff\` for the details, then carry on with the follow-up.`
59
+ : `While you were away, this task continued on another coding agent (${others}). Its work is already in this folder. Below is what happened since your last turn, oldest first; check the files and \`git diff\` before you rely on what you remember.`;
60
+ return [
61
+ '## Earlier in this task',
62
+ intro,
63
+ dropped ? `_${dropped} earlier turn${dropped > 1 ? 's' : ''} left out for length._` : '',
64
+ ...parts,
65
+ '---',
66
+ '## Follow-up',
67
+ ]
68
+ .filter(Boolean)
69
+ .join('\n\n');
70
+ }
@@ -0,0 +1,59 @@
1
+ // Writing responses and reading request bodies. The API speaks JSON; a few routes stream.
2
+
3
+ const MAX_BODY = 2e6;
4
+
5
+ export function send(res, status, body) {
6
+ res.writeHead(status, { 'content-type': 'application/json' });
7
+ res.end(JSON.stringify(body));
8
+ }
9
+
10
+ export function readJson(req) {
11
+ return new Promise((resolve, reject) => {
12
+ let data = '';
13
+ let tooLarge = false;
14
+ req.on('data', (c) => {
15
+ if (tooLarge) return;
16
+ data += c;
17
+ if (data.length > MAX_BODY) {
18
+ // Stop buffering: the rest of the body is dropped, not held in memory.
19
+ tooLarge = true;
20
+ data = '';
21
+ reject(Object.assign(new Error('body too large'), { status: 413 }));
22
+ }
23
+ });
24
+ req.on('end', () => {
25
+ if (tooLarge) return;
26
+ try {
27
+ resolve(data ? JSON.parse(data) : {});
28
+ } catch {
29
+ reject(Object.assign(new Error('invalid json'), { status: 400 }));
30
+ }
31
+ });
32
+ });
33
+ }
34
+
35
+ // Pass a machine's raw response (images, markdown, uploads) straight through.
36
+ export function pipeFrom(res, r) {
37
+ const headers = { 'content-type': r.headers['content-type'] || 'application/octet-stream' };
38
+ if (r.headers['content-length']) headers['content-length'] = r.headers['content-length'];
39
+ res.writeHead(r.statusCode, headers);
40
+ r.pipe(res);
41
+ }
42
+
43
+ // Server-sent events: every open /api/events stream gets every broadcast.
44
+ export function eventStream() {
45
+ const clients = new Set();
46
+ return {
47
+ clients,
48
+ attach(req, res) {
49
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
50
+ res.write(`event: hello\ndata: {}\n\n`);
51
+ clients.add(res);
52
+ req.on('close', () => clients.delete(res));
53
+ },
54
+ broadcast(event, data) {
55
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
56
+ for (const res of clients) res.write(frame);
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,64 @@
1
+ // A small route table: `route('PATCH', '/sessions/:id', handler)`. Patterns match whole path
2
+ // segments under /api; `:name` captures one (URI-decoded). Routes are tried in the order they
3
+ // were added, so a fixed segment (`/projects/discover`) goes before a capture (`/projects/:id`).
4
+ //
5
+ // A handler gets one context object and answers through it:
6
+ // { req, res, url, query, params, body(), send(status, data) }
7
+ // body() reads and parses the JSON request body (once). Options: `doc: false` marks a route that
8
+ // is not part of the public API (left out of openapi.json on purpose); `when(params)` narrows a
9
+ // pattern further (a route whose `when` says no is skipped, as if it didn't match).
10
+
11
+ import { readJson, send } from './respond.js';
12
+
13
+ export class Routes {
14
+ #list = [];
15
+
16
+ route(method, pattern, handler, { doc = true, when = null } = {}) {
17
+ const segs = pattern.split('/').filter(Boolean);
18
+ for (const m of [method].flat()) this.#list.push({ method: m, pattern, segs, handler, doc, when });
19
+ return this;
20
+ }
21
+
22
+ // Every route, for the docs check in the tests.
23
+ list() {
24
+ return this.#list.map(({ method, pattern, doc }) => ({ method, pattern, doc }));
25
+ }
26
+
27
+ // Handles the request and returns true, or returns false when nothing matched.
28
+ async handle(req, res, url) {
29
+ const parts = url.pathname.split('/').filter(Boolean).slice(1); // drop "api"
30
+ for (const r of this.#list) {
31
+ if (r.method !== req.method) continue;
32
+ const params = match(r.segs, parts);
33
+ if (!params || (r.when && !r.when(params))) continue;
34
+ let parsed;
35
+ const ctx = {
36
+ req,
37
+ res,
38
+ url,
39
+ params,
40
+ query: url.searchParams,
41
+ body: () => (parsed ??= readJson(req)),
42
+ send: (status, data) => send(res, status, data),
43
+ };
44
+ await r.handler(ctx);
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function match(segs, parts) {
52
+ if (segs.length !== parts.length) return null;
53
+ const params = {};
54
+ for (let i = 0; i < segs.length; i++) {
55
+ if (segs[i].startsWith(':')) {
56
+ try {
57
+ params[segs[i].slice(1)] = decodeURIComponent(parts[i]);
58
+ } catch {
59
+ return null;
60
+ }
61
+ } else if (segs[i] !== parts[i]) return null;
62
+ }
63
+ return params;
64
+ }
@@ -0,0 +1,36 @@
1
+ // The web UI: files under web/, with index.html for every other path (the app routes itself).
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'web');
8
+
9
+ export const MIME = {
10
+ '.html': 'text/html; charset=utf-8',
11
+ '.js': 'text/javascript; charset=utf-8',
12
+ '.css': 'text/css; charset=utf-8',
13
+ '.svg': 'image/svg+xml',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ '.ico': 'image/x-icon',
20
+ '.mp4': 'video/mp4',
21
+ '.webm': 'video/webm',
22
+ '.mov': 'video/quicktime',
23
+ '.pdf': 'application/pdf',
24
+ };
25
+
26
+ export function serveStatic(res, pathname) {
27
+ const p = path.normalize(path.join(WEB, pathname === '/' ? 'index.html' : pathname));
28
+ // Inside web/ only: a sibling folder whose name starts with "web" doesn't count.
29
+ if (!p.startsWith(WEB + path.sep) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) {
30
+ // SPA fallback
31
+ res.writeHead(200, { 'content-type': MIME['.html'] });
32
+ return fs.createReadStream(path.join(WEB, 'index.html')).pipe(res);
33
+ }
34
+ res.writeHead(200, { 'content-type': MIME[path.extname(p)] || 'application/octet-stream', 'cache-control': 'no-cache' });
35
+ fs.createReadStream(p).pipe(res);
36
+ }