@dorsk/yubisashi 0.1.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/src/cli.ts ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
3
+ import { randomBytes } from 'node:crypto';
4
+ import {
5
+ appendFileSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { parseArgs } from 'node:util';
14
+ import { formatAnnotation } from './format.ts';
15
+ import { PREFIX, startServer } from './server.ts';
16
+ import { type Annotation, Store } from './store.ts';
17
+ import { Transcript } from './transcript.ts';
18
+
19
+ const DIR = '.yubisashi';
20
+
21
+ type ServerInfo = { url: string; shellUrl: string; token: string; pid: number; cwd: string };
22
+
23
+ const HELP = `yubi — point at your running app, comment, and hand it to your coding agent
24
+
25
+ yubi up [--target URL] [--port N] [--host H] [--session ID] [-- <dev command>]
26
+ Start <dev command> (optional), proxy --target (default http://localhost:5173) and serve
27
+ the review shell at /__yubi/. Binds 127.0.0.1:4780 by default.
28
+ yubi wait [--timeout SECONDS] Block until the user sends messages, print them, exit 0.
29
+ yubi reply <id> <text> Answer in the comment thread.
30
+ yubi resolve <id> [text] Close a comment with a summary of the change.
31
+ yubi dismiss <id> <reason> Close a comment without acting on it.
32
+ yubi list [--all] Show open (or all) comments.
33
+ yubi url Print the shell URL.
34
+
35
+ Agent loop: run \`yubi up\` and \`yubi wait\` as background commands. When wait exits the user has
36
+ commented; act, answer with reply/resolve, then start \`yubi wait\` in the background again.`;
37
+
38
+ function fail(message: string, code = 1): never {
39
+ process.stderr.write(`yubi: ${message}\n`);
40
+ process.exit(code);
41
+ }
42
+
43
+ function findInfo(): ServerInfo {
44
+ const explicit = process.env.YUBI_DIR;
45
+ let dir = resolve(process.cwd());
46
+ for (;;) {
47
+ const file = explicit ? join(explicit, 'server.json') : join(dir, DIR, 'server.json');
48
+ if (existsSync(file)) return JSON.parse(readFileSync(file, 'utf8'));
49
+ const parent = dirname(dir);
50
+ if (explicit || parent === dir) fail('no running server found; start one with `yubi up`');
51
+ dir = parent;
52
+ }
53
+ }
54
+
55
+ async function call<T>(
56
+ info: ServerInfo,
57
+ path: string,
58
+ init: RequestInit = {},
59
+ ): Promise<Response & { data?: T }> {
60
+ let res: Response;
61
+ try {
62
+ res = await fetch(`${info.url}${PREFIX}/api${path}`, {
63
+ ...init,
64
+ headers: { 'content-type': 'application/json', 'x-yubi-token': info.token },
65
+ });
66
+ } catch {
67
+ fail(`server at ${info.url} is not responding; is \`yubi up\` still running?`);
68
+ }
69
+ if (res.status >= 400) fail(`${path}: ${res.status} ${await res.text()}`);
70
+ return res;
71
+ }
72
+
73
+ function excludeFromGit(cwd: string) {
74
+ try {
75
+ const exclude = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], {
76
+ cwd,
77
+ encoding: 'utf8',
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ }).trim();
80
+ const path = resolve(cwd, exclude);
81
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : '';
82
+ if (!current.split('\n').includes(`${DIR}/`)) {
83
+ mkdirSync(dirname(path), { recursive: true });
84
+ appendFileSync(path, `${current && !current.endsWith('\n') ? '\n' : ''}${DIR}/\n`);
85
+ }
86
+ } catch {
87
+ // not a git checkout
88
+ }
89
+ }
90
+
91
+ async function reachable(url: URL): Promise<boolean> {
92
+ try {
93
+ await fetch(url, { signal: AbortSignal.timeout(2000) });
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ async function up(argv: string[]) {
101
+ const split = argv.indexOf('--');
102
+ const command = split >= 0 ? argv.slice(split + 1) : [];
103
+ const { values } = parseArgs({
104
+ args: split >= 0 ? argv.slice(0, split) : argv,
105
+ options: {
106
+ target: { type: 'string', default: 'http://localhost:5173' },
107
+ port: { type: 'string', default: '4780' },
108
+ host: { type: 'string', default: '127.0.0.1' },
109
+ session: { type: 'string' },
110
+ },
111
+ });
112
+ const cwd = process.cwd();
113
+ const dir = join(cwd, DIR);
114
+ mkdirSync(dir, { recursive: true });
115
+ excludeFromGit(cwd);
116
+
117
+ const tokenFile = join(dir, 'token');
118
+ if (!existsSync(tokenFile))
119
+ writeFileSync(tokenFile, randomBytes(18).toString('base64url'), { mode: 0o600 });
120
+ const token = readFileSync(tokenFile, 'utf8').trim();
121
+
122
+ const sessionId = values.session ?? process.env.CLAUDE_CODE_SESSION_ID;
123
+ const transcript = sessionId && sessionId !== 'none' ? new Transcript(sessionId) : undefined;
124
+ const target = new URL(values.target);
125
+
126
+ let child: ChildProcess | undefined;
127
+ if (command.length) {
128
+ const [cmd, ...args] = command as [string, ...string[]];
129
+ child = spawn(cmd, args, { cwd, stdio: ['ignore', 'inherit', 'inherit'], detached: true });
130
+ child.on('exit', (code) => shutdown(code ?? 1));
131
+ }
132
+
133
+ const running = await startServer({
134
+ target,
135
+ store: new Store(join(dir, 'annotations.json')),
136
+ token,
137
+ transcript,
138
+ host: values.host,
139
+ port: Number(values.port),
140
+ }).catch((err: Error) => {
141
+ child?.pid && process.kill(-child.pid, 'SIGTERM');
142
+ fail(`cannot listen on ${values.host}:${values.port}: ${err.message}`);
143
+ });
144
+
145
+ const infoFile = join(dir, 'server.json');
146
+ const info: ServerInfo = {
147
+ url: running.url,
148
+ shellUrl: running.shellUrl,
149
+ token,
150
+ pid: process.pid,
151
+ cwd,
152
+ };
153
+ writeFileSync(infoFile, JSON.stringify(info, null, '\t'), { mode: 0o600 });
154
+
155
+ let stopping = false;
156
+ async function shutdown(code: number) {
157
+ if (stopping) return;
158
+ stopping = true;
159
+ if (child?.pid && child.exitCode === null) {
160
+ try {
161
+ process.kill(-child.pid, 'SIGTERM');
162
+ } catch {
163
+ // already gone
164
+ }
165
+ }
166
+ transcript?.close();
167
+ rmSync(infoFile, { force: true });
168
+ await running.close();
169
+ process.exit(code);
170
+ }
171
+ process.on('SIGINT', () => shutdown(130));
172
+ process.on('SIGTERM', () => shutdown(143));
173
+
174
+ for (let i = 0; i < 240 && !(await reachable(target)); i++) {
175
+ if (i === 0) console.log(`yubi: waiting for ${target.origin}…`);
176
+ await new Promise((r) => setTimeout(r, 500));
177
+ }
178
+ console.log(
179
+ [
180
+ '',
181
+ `yubi: open ${running.shellUrl}`,
182
+ ` proxying ${target.origin}${transcript ? `, following Claude session ${transcript.sessionId}` : ''}`,
183
+ ' agent: run `yubi wait` in the background to receive comments',
184
+ '',
185
+ ].join('\n'),
186
+ );
187
+ }
188
+
189
+ async function wait(argv: string[]) {
190
+ const { values } = parseArgs({
191
+ args: argv,
192
+ options: { timeout: { type: 'string', default: '0' } },
193
+ });
194
+ const info = findInfo();
195
+ const limit = Number(values.timeout) * 1000;
196
+ const start = Date.now();
197
+ while (!limit || Date.now() - start < limit) {
198
+ const left = limit ? Math.ceil((limit - (Date.now() - start)) / 1000) : 240;
199
+ const res = await call(info, `/wait?timeout=${Math.max(1, Math.min(240, left))}`);
200
+ if (res.status === 200) {
201
+ const body = (await res.json()) as { text: string };
202
+ console.log(body.text);
203
+ return;
204
+ }
205
+ }
206
+ fail('no messages before --timeout', 2);
207
+ }
208
+
209
+ async function post(id: string | undefined, text: string, status?: string) {
210
+ if (!id || !/^\d+$/.test(id)) fail('expected a comment id');
211
+ const info = findInfo();
212
+ const res = await call(info, `/annotations/${id}/messages`, {
213
+ method: 'POST',
214
+ body: JSON.stringify({ from: 'agent', text, status }),
215
+ });
216
+ const a = (await res.json()) as Annotation;
217
+ console.log(`#${a.id} ${a.status}`);
218
+ }
219
+
220
+ async function list(argv: string[]) {
221
+ const { values } = parseArgs({
222
+ args: argv,
223
+ options: { all: { type: 'boolean', default: false } },
224
+ });
225
+ const info = findInfo();
226
+ const res = await call(info, '/state');
227
+ const { annotations } = (await res.json()) as { annotations: Annotation[] };
228
+ const shown = annotations.filter((a) => values.all || a.status === 'open');
229
+ if (!shown.length) return console.log(values.all ? 'no comments yet' : 'no open comments');
230
+ for (const a of shown) console.log(`[${a.status}] ${formatAnnotation({ ...a, delivered: 0 })}\n`);
231
+ }
232
+
233
+ const [command, ...rest] = process.argv.slice(2);
234
+ switch (command) {
235
+ case 'up':
236
+ await up(rest);
237
+ break;
238
+ case 'wait':
239
+ await wait(rest);
240
+ break;
241
+ case 'reply':
242
+ await post(rest[0], rest.slice(1).join(' ') || fail('reply needs text'));
243
+ break;
244
+ case 'resolve':
245
+ await post(rest[0], rest.slice(1).join(' '), 'resolved');
246
+ break;
247
+ case 'dismiss':
248
+ await post(rest[0], rest.slice(1).join(' ') || fail('dismiss needs a reason'), 'dismissed');
249
+ break;
250
+ case 'list':
251
+ await list(rest);
252
+ break;
253
+ case 'url':
254
+ console.log(findInfo().shellUrl);
255
+ break;
256
+ default:
257
+ console.log(HELP);
258
+ if (command && command !== 'help' && command !== '--help') process.exit(1);
259
+ }
package/src/format.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Annotation, Frame, Target } from './store.ts';
2
+
3
+ const loc = (f: { file?: string; line?: number; column?: number }) =>
4
+ f.file ? `${f.file}${f.line ? `:${f.line}` : ''}${f.column ? `:${f.column}` : ''}` : '';
5
+
6
+ const frame = (f: Frame) => [f.name ?? f.type, loc(f)].filter(Boolean).join(' ');
7
+
8
+ function target(t: Target, i: number): string {
9
+ const label = `<${t.tag}>${t.text ? ` "${t.text}"` : ''}`;
10
+ const lines = [`- target ${i + 1}: ${label}`];
11
+ if (t.source) lines.push(` source: ${loc(t.source)}`);
12
+ if (t.stack.length) lines.push(` rendered by: ${t.stack.map(frame).join(' ← ')}`);
13
+ lines.push(` selector: ${t.selector}`);
14
+ lines.push(` html: ${t.html.replace(/\s+/g, ' ')}`);
15
+ return lines.join('\n');
16
+ }
17
+
18
+ export function formatAnnotation(a: Annotation): string {
19
+ const fresh = a.thread.slice(a.delivered);
20
+ const earlier = a.thread.slice(0, a.delivered);
21
+ const head = `## #${a.id} on ${a.route} (viewport ${a.viewport.width}x${a.viewport.height})`;
22
+ const out = [head];
23
+ if (earlier.length) {
24
+ out.push('Earlier in this thread:');
25
+ for (const m of earlier) out.push(` ${m.from}: ${m.text}`);
26
+ }
27
+ for (const m of fresh) {
28
+ out.push(m.from === 'user' ? `> ${m.text.split('\n').join('\n> ')}` : ` agent: ${m.text}`);
29
+ }
30
+ out.push(a.targets.length ? a.targets.map(target).join('\n') : '(general message, no element)');
31
+ return out.join('\n');
32
+ }
33
+
34
+ export function formatDelivery(items: Annotation[], shellUrl: string): string {
35
+ return [
36
+ `yubisashi: ${items.length} new message${items.length === 1 ? '' : 's'} from ${shellUrl}`,
37
+ '',
38
+ items.map(formatAnnotation).join('\n\n'),
39
+ '',
40
+ 'Answer in the browser: yubi reply <id> "…" close it: yubi resolve <id> "what changed"',
41
+ 'Then run `yubi wait` in the background again to keep listening.',
42
+ ].join('\n');
43
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { type IncomingMessage, request, type ServerResponse } from 'node:http';
2
+ import { request as httpsRequest } from 'node:https';
3
+ import { connect, type Socket } from 'node:net';
4
+ import type { Duplex } from 'node:stream';
5
+ import { connect as tlsConnect } from 'node:tls';
6
+
7
+ /** Makes the proxied app look same-origin to itself and embeddable in the shell iframe. */
8
+ function rewriteRequestHeaders(req: IncomingMessage, target: URL, self: string) {
9
+ const headers = { ...req.headers, host: target.host };
10
+ if (headers.origin === self) headers.origin = target.origin;
11
+ if (typeof headers.referer === 'string' && headers.referer.startsWith(self)) {
12
+ headers.referer = target.origin + headers.referer.slice(self.length);
13
+ }
14
+ return headers;
15
+ }
16
+
17
+ function rewriteResponseHeaders(headers: IncomingMessage['headers'], target: URL, self: string) {
18
+ const out = { ...headers };
19
+ delete out['x-frame-options'];
20
+ const csp = out['content-security-policy'];
21
+ if (typeof csp === 'string') {
22
+ out['content-security-policy'] = csp
23
+ .split(';')
24
+ .filter((d) => !d.trim().startsWith('frame-ancestors'))
25
+ .join(';');
26
+ }
27
+ if (typeof out.location === 'string' && out.location.startsWith(target.origin)) {
28
+ out.location = self + out.location.slice(target.origin.length);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ export function proxyHttp(req: IncomingMessage, res: ServerResponse, target: URL, self: string) {
34
+ const send = target.protocol === 'https:' ? httpsRequest : request;
35
+ const upstream = send(
36
+ {
37
+ protocol: target.protocol,
38
+ hostname: target.hostname,
39
+ port: target.port,
40
+ method: req.method,
41
+ path: req.url,
42
+ headers: rewriteRequestHeaders(req, target, self),
43
+ rejectUnauthorized: false,
44
+ },
45
+ (up) => {
46
+ res.writeHead(up.statusCode ?? 502, rewriteResponseHeaders(up.headers, target, self));
47
+ up.pipe(res);
48
+ },
49
+ );
50
+ upstream.on('error', (err) => {
51
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' });
52
+ res.end(`yubisashi: ${target.origin} is not reachable (${err.message})`);
53
+ });
54
+ req.pipe(upstream);
55
+ }
56
+
57
+ export function proxyUpgrade(
58
+ req: IncomingMessage,
59
+ client: Duplex,
60
+ head: Buffer,
61
+ target: URL,
62
+ self: string,
63
+ ) {
64
+ const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80));
65
+ const upstream: Socket =
66
+ target.protocol === 'https:'
67
+ ? tlsConnect({ host: target.hostname, port, rejectUnauthorized: false })
68
+ : connect(port, target.hostname);
69
+ const ready = target.protocol === 'https:' ? 'secureConnect' : 'connect';
70
+ upstream.once(ready, () => {
71
+ const headers = rewriteRequestHeaders(req, target, self);
72
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
73
+ for (const [key, value] of Object.entries(headers)) {
74
+ for (const v of Array.isArray(value) ? value : [value])
75
+ if (v !== undefined) lines.push(`${key}: ${v}`);
76
+ }
77
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
78
+ if (head.length) upstream.write(head);
79
+ upstream.pipe(client).pipe(upstream);
80
+ });
81
+ const close = () => {
82
+ upstream.destroy();
83
+ client.destroy();
84
+ };
85
+ for (const socket of [upstream, client]) {
86
+ socket.on('error', close);
87
+ socket.on('close', close);
88
+ }
89
+ }
package/src/server.ts ADDED
@@ -0,0 +1,245 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
4
+ import type { AddressInfo } from 'node:net';
5
+ import { extname } from 'node:path';
6
+ import type { Duplex } from 'node:stream';
7
+ import { formatDelivery } from './format.ts';
8
+ import { proxyHttp, proxyUpgrade } from './proxy.ts';
9
+ import type { Annotation, NewAnnotation, Status, Store } from './store.ts';
10
+ import type { Transcript } from './transcript.ts';
11
+
12
+ export const PREFIX = '/__yubi';
13
+
14
+ export type Options = {
15
+ target: URL;
16
+ store: Store;
17
+ token: string;
18
+ transcript?: Transcript;
19
+ host?: string;
20
+ port?: number;
21
+ /** How long to keep collecting messages after the first one before waking the agent. */
22
+ batchMs?: number;
23
+ };
24
+
25
+ export type Running = { server: Server; url: string; shellUrl: string; close: () => Promise<void> };
26
+
27
+ const SHELL = new URL('../shell/', import.meta.url);
28
+ const MIME: Record<string, string> = {
29
+ '.html': 'text/html; charset=utf-8',
30
+ '.js': 'text/javascript; charset=utf-8',
31
+ '.css': 'text/css; charset=utf-8',
32
+ };
33
+ const STATUSES: Status[] = ['open', 'resolved', 'dismissed'];
34
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
35
+
36
+ function json(res: ServerResponse, status: number, body: unknown) {
37
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
38
+ res.end(JSON.stringify(body));
39
+ }
40
+
41
+ async function readJson<T>(req: IncomingMessage): Promise<T> {
42
+ let size = 0;
43
+ const chunks: Buffer[] = [];
44
+ for await (const chunk of req) {
45
+ size += chunk.length;
46
+ if (size > 2_000_000) throw new Error('body too large');
47
+ chunks.push(chunk);
48
+ }
49
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
50
+ }
51
+
52
+ export async function startServer(opts: Options): Promise<Running> {
53
+ const { store, target, transcript } = opts;
54
+ const batchMs = opts.batchMs ?? 1500;
55
+ const expected = Buffer.from(opts.token);
56
+ const streams = new Set<ServerResponse>();
57
+ let waiters = 0;
58
+ let shellBase = '';
59
+
60
+ const authorized = (req: IncomingMessage, url: URL) => {
61
+ const given = Buffer.from(
62
+ String(req.headers['x-yubi-token'] ?? url.searchParams.get('token') ?? ''),
63
+ );
64
+ return given.length === expected.length && timingSafeEqual(given, expected);
65
+ };
66
+
67
+ const broadcast = (event: string, data: unknown) => {
68
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
69
+ for (const res of streams) res.write(frame);
70
+ };
71
+ const setWaiters = (delta: number) => {
72
+ waiters += delta;
73
+ broadcast('listening', waiters > 0);
74
+ };
75
+ store.subscribe((items) => broadcast('annotations', items));
76
+ transcript?.subscribe((entries) => broadcast('transcript', entries));
77
+
78
+ async function waitForMessages(
79
+ res: ServerResponse,
80
+ timeoutMs: number,
81
+ ): Promise<{ items: Annotation[]; text: string } | undefined> {
82
+ let gone = false;
83
+ res.once('close', () => {
84
+ gone = true;
85
+ });
86
+ const deadline = Date.now() + timeoutMs;
87
+ while (!gone && Date.now() < deadline) {
88
+ if (store.undelivered().length) {
89
+ await sleep(batchMs);
90
+ const items = store.undelivered();
91
+ if (gone || !items.length) continue;
92
+ const text = formatDelivery(items, shellBase);
93
+ store.markDelivered(items);
94
+ return { items, text };
95
+ }
96
+ await new Promise<void>((resolve) => {
97
+ const done = () => {
98
+ off();
99
+ clearTimeout(timer);
100
+ res.off('close', done);
101
+ resolve();
102
+ };
103
+ const off = store.subscribe(() => store.undelivered().length && done());
104
+ const timer = setTimeout(done, Math.max(0, deadline - Date.now()));
105
+ res.once('close', done);
106
+ });
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ async function api(req: IncomingMessage, res: ServerResponse, url: URL, path: string) {
112
+ if (!authorized(req, url)) return json(res, 401, { error: 'bad or missing token' });
113
+
114
+ if (req.method === 'GET' && path === '/state') {
115
+ return json(res, 200, {
116
+ target: target.origin,
117
+ session: transcript ? { id: transcript.sessionId, file: transcript.file } : null,
118
+ listening: waiters > 0,
119
+ annotations: store.all(),
120
+ transcript: transcript?.entries() ?? [],
121
+ });
122
+ }
123
+
124
+ if (req.method === 'GET' && path === '/events') {
125
+ res.writeHead(200, {
126
+ 'content-type': 'text/event-stream',
127
+ 'cache-control': 'no-store',
128
+ connection: 'keep-alive',
129
+ });
130
+ res.write(`event: listening\ndata: ${waiters > 0}\n\n`);
131
+ streams.add(res);
132
+ const ping = setInterval(() => res.write(': ping\n\n'), 20_000);
133
+ req.once('close', () => {
134
+ clearInterval(ping);
135
+ streams.delete(res);
136
+ });
137
+ return;
138
+ }
139
+
140
+ if (req.method === 'GET' && path === '/wait') {
141
+ const timeoutMs = Math.min(Number(url.searchParams.get('timeout') ?? 240), 280) * 1000;
142
+ setWaiters(1);
143
+ try {
144
+ const delivery = await waitForMessages(res, timeoutMs);
145
+ if (!delivery) {
146
+ res.writeHead(204).end();
147
+ return;
148
+ }
149
+ return json(res, 200, delivery);
150
+ } finally {
151
+ setWaiters(-1);
152
+ }
153
+ }
154
+
155
+ if (req.method === 'POST' && path === '/annotations') {
156
+ const body = await readJson<NewAnnotation>(req);
157
+ if (!body.comment?.trim()) return json(res, 400, { error: 'comment is required' });
158
+ return json(
159
+ res,
160
+ 201,
161
+ store.add({
162
+ comment: body.comment.trim(),
163
+ route: body.route ?? '/',
164
+ viewport: body.viewport ?? { width: 0, height: 0 },
165
+ targets: body.targets ?? [],
166
+ }),
167
+ );
168
+ }
169
+
170
+ const reply = path.match(/^\/annotations\/(\d+)\/messages$/);
171
+ if (req.method === 'POST' && reply) {
172
+ const body = await readJson<{ text?: string; from?: 'user' | 'agent'; status?: Status }>(req);
173
+ if (body.status && !STATUSES.includes(body.status)) {
174
+ return json(res, 400, { error: `status must be one of ${STATUSES.join(', ')}` });
175
+ }
176
+ try {
177
+ const from = body.from === 'agent' ? 'agent' : 'user';
178
+ return json(
179
+ res,
180
+ 200,
181
+ store.post(Number(reply[1]), from, body.text?.trim() ?? '', body.status),
182
+ );
183
+ } catch (err) {
184
+ return json(res, 404, { error: (err as Error).message });
185
+ }
186
+ }
187
+
188
+ return json(res, 404, { error: `no route ${req.method} ${path}` });
189
+ }
190
+
191
+ async function shell(res: ServerResponse, path: string) {
192
+ const name = path === '/' || path === '' ? 'index.html' : path.slice(1);
193
+ if (!/^[\w.-]+$/.test(name)) return json(res, 404, { error: 'not found' });
194
+ try {
195
+ const body = await readFile(new URL(name, SHELL));
196
+ res.writeHead(200, {
197
+ 'content-type': MIME[extname(name)] ?? 'application/octet-stream',
198
+ 'cache-control': 'no-store',
199
+ });
200
+ res.end(body);
201
+ } catch {
202
+ json(res, 404, { error: 'not found' });
203
+ }
204
+ }
205
+
206
+ const server = createServer((req, res) => {
207
+ const url = new URL(req.url ?? '/', 'http://local');
208
+ const self = `http://${req.headers.host}`;
209
+ if (!url.pathname.startsWith(PREFIX)) return proxyHttp(req, res, target, self);
210
+ const rest = url.pathname.slice(PREFIX.length);
211
+ const handle = rest.startsWith('/api/') ? api(req, res, url, rest.slice(4)) : shell(res, rest);
212
+ handle.catch((err: Error) => {
213
+ if (!res.headersSent) json(res, 500, { error: err.message });
214
+ else res.end();
215
+ });
216
+ });
217
+ const sockets = new Set<Duplex>();
218
+ server.on('connection', (socket) => {
219
+ sockets.add(socket);
220
+ socket.once('close', () => sockets.delete(socket));
221
+ });
222
+ server.on('upgrade', (req, socket, head) =>
223
+ proxyUpgrade(req, socket, head, target, `http://${req.headers.host}`),
224
+ );
225
+
226
+ await new Promise<void>((resolve, reject) => {
227
+ server.once('error', reject);
228
+ server.listen(opts.port ?? 0, opts.host ?? '127.0.0.1', resolve);
229
+ });
230
+ const { port } = server.address() as AddressInfo;
231
+ const host = opts.host && opts.host !== '0.0.0.0' && opts.host !== '::' ? opts.host : '127.0.0.1';
232
+ const url = `http://${host.includes(':') ? `[${host}]` : host}:${port}`;
233
+ shellBase = `${url}${PREFIX}/`;
234
+
235
+ return {
236
+ server,
237
+ url,
238
+ shellUrl: `${shellBase}#token=${opts.token}`,
239
+ close: () =>
240
+ new Promise<void>((resolve) => {
241
+ server.close(() => resolve());
242
+ for (const socket of sockets) socket.destroy();
243
+ }),
244
+ };
245
+ }