@shiyuphan/follow-me 0.1.0 → 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.
@@ -1,104 +1,106 @@
1
- import { spawn } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
3
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
3
  import { createServer } from "node:http";
5
- import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { basename, isAbsolute, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { isReplyId, parseComment, parseDraft, parseNewDecision, parseNewReply, } from "./comments.js";
8
+ import { isConcernId, isPlanVersion } from "./plan.js";
9
+ import { renderReport } from "./prompt.js";
10
+ import { isLockToken } from "./session.js";
11
+ /** The requested port is taken; host.ts decides whether that is our server or a stranger. */
12
+ export class PortInUseError extends Error {
13
+ name = "PortInUseError";
14
+ port;
15
+ constructor(port) {
16
+ super(`port ${port} is in use`);
17
+ this.port = port;
18
+ }
19
+ }
20
+ /** A session the server cannot host, with the reason the CLI shows. */
21
+ export class RegistrationError extends Error {
22
+ name = "RegistrationError";
23
+ }
24
+ /** GET /api/health answers with this, so a CLI can tell our server from another program. */
25
+ export const HEALTH_APP = "follow-me";
7
26
  const UI_DIR = new URL("../ui/", import.meta.url);
8
- const STATIC_FILES = new Map([
9
- ["/", { file: "index.html", type: "text/html; charset=utf-8" }],
10
- ["/app.js", { file: "app.js", type: "text/javascript; charset=utf-8" }],
11
- ["/playback.js", { file: "playback.js", type: "text/javascript; charset=utf-8" }],
12
- ["/style.css", { file: "style.css", type: "text/css; charset=utf-8" }],
27
+ /** Every module and the stylesheet in ui/ is served flat, so a new module needs no route. */
28
+ const STATIC_FILE = /^\/([a-z][a-z-]*\.(?:js|css))$/;
29
+ const STATIC_TYPES = new Map([
30
+ ["js", "text/javascript; charset=utf-8"],
31
+ ["css", "text/css; charset=utf-8"],
13
32
  ]);
33
+ /** Mermaid's ESM entry imports its chunks by relative path, so they are served beside it. */
34
+ const MERMAID_ENTRY = new URL(import.meta.resolve("mermaid/dist/mermaid.esm.min.mjs"));
35
+ const MERMAID_CHUNK = /^\/vendor\/chunks\/mermaid\.esm\.min\/([\w-]+\.mjs)$/;
36
+ /** Prism is a plain script per component, loaded one at a time by the page as languages need. */
37
+ const PRISM_CORE = new URL(import.meta.resolve("prismjs/components/prism-core.min.js"));
38
+ const PRISM_PATH = /^\/vendor\/prism\/([a-z][a-z-]*)\.js$/;
14
39
  const MAX_BODY_BYTES = 1_000_000;
15
40
  const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
16
41
  const COMMENT_ID = /^c_[a-z2-7]{6}$/;
17
- const UNIT_ID = /^[hfm]_[0-9a-f]{12}(~\d+)?$/;
42
+ const SESSION_ID = /^[0-9a-f]{12}$/;
18
43
  function isRecord(value) {
19
44
  return typeof value === "object" && value !== null && !Array.isArray(value);
20
45
  }
21
46
  function isCommentId(value) {
22
47
  return typeof value === "string" && COMMENT_ID.test(value);
23
48
  }
24
- function isUnitId(value) {
25
- return typeof value === "string" && UNIT_ID.test(value);
26
- }
27
- function isRepoPath(value) {
28
- return typeof value === "string" && value.length > 0;
49
+ export function isSessionId(value) {
50
+ return typeof value === "string" && SESSION_ID.test(value);
29
51
  }
30
- function isIndex(value) {
31
- return typeof value === "number" && Number.isInteger(value) && value >= 0;
52
+ /** Derived from the session directory, so registering one session twice yields one id. */
53
+ export function sessionIdOf(dir) {
54
+ const id = createHash("sha256").update(dir).digest("hex").slice(0, 12);
55
+ if (!isSessionId(id))
56
+ throw new Error(`sha256 produced ${id}`);
57
+ return id;
32
58
  }
33
- function parseAnchor(raw) {
34
- if (!isRecord(raw))
35
- return null;
36
- const commit = raw["commit"];
37
- switch (raw["kind"]) {
38
- case "plan":
39
- return { kind: "plan" };
40
- case "message":
41
- return isIndex(commit) ? { kind: "message", commit } : null;
42
- case "position":
43
- return isIndex(commit) ? { kind: "position", commit } : null;
44
- case "hunk": {
45
- const unit = raw["unit"];
46
- const changes = raw["changes"];
47
- if (!isIndex(commit) || !isUnitId(unit))
48
- return null;
49
- if (changes === null)
50
- return { kind: "hunk", unit, commit, changes: null };
51
- if (!Array.isArray(changes) || !changes.every(isIndex))
52
- return null;
53
- return { kind: "hunk", unit, commit, changes };
54
- }
55
- default:
56
- return null;
57
- }
59
+ export function sessionPath(id) {
60
+ return `/s/${id}/`;
58
61
  }
59
- /** The one place untrusted JSON becomes a Comment. Returns null when any field is wrong. */
60
- export function parseComment(raw) {
62
+ /** The one place untrusted JSON becomes a Registration. */
63
+ export function parseRegistration(raw) {
61
64
  if (!isRecord(raw))
62
65
  return null;
63
- const { id, body, createdAt } = raw;
64
- const context = raw["context"];
65
- const anchor = parseAnchor(raw["anchor"]);
66
- if (!isCommentId(id) || !anchor || !isRecord(context))
66
+ const { dir, mode, version, hash, lockToken } = raw;
67
+ if (typeof dir !== "string" || !isAbsolute(dir))
67
68
  return null;
68
- const { commitMessage, path } = context;
69
- if (typeof commitMessage !== "string" || typeof body !== "string")
69
+ if (mode !== "split" && mode !== "walk")
70
70
  return null;
71
- if (typeof createdAt !== "string")
71
+ if (!isPlanVersion(version) || typeof hash !== "string" || hash === "")
72
72
  return null;
73
- if (path !== null && !isRepoPath(path))
73
+ if (lockToken !== null && !isLockToken(lockToken))
74
74
  return null;
75
- return { id, anchor, context: { commitMessage, path }, body, createdAt };
75
+ return { dir, mode, version, hash, lockToken };
76
76
  }
77
77
  function draftPath(reviewsDir, version) {
78
78
  return join(reviewsDir, `v${version}.draft.json`);
79
79
  }
80
- /** Comments autosaved by an unfinished review of this version, or none. */
81
- export async function loadDraftComments(reviewsDir, version) {
80
+ /** What an unfinished review of this version autosaved, or nothing. */
81
+ export async function loadDraft(reviewsDir, version) {
82
82
  let text;
83
83
  try {
84
84
  text = await readFile(draftPath(reviewsDir, version), "utf8");
85
85
  }
86
86
  catch (error) {
87
- if (isRecord(error) && error["code"] === "ENOENT")
88
- return [];
87
+ if (isRecord(error) && error["code"] === "ENOENT") {
88
+ return { comments: [], concerns: [], resolved: [] };
89
+ }
89
90
  throw error;
90
91
  }
91
- const raw = JSON.parse(text);
92
- const list = isRecord(raw) ? raw["comments"] : null;
93
- if (!Array.isArray(list))
92
+ const draft = parseDraft(JSON.parse(text));
93
+ if (typeof draft === "string")
94
94
  throw new Error(`malformed review draft for v${version}`);
95
- return list.flatMap((item) => {
96
- const comment = parseComment(item);
97
- return comment ? [comment] : [];
98
- });
95
+ return draft;
96
+ }
97
+ /** Comments autosaved by an unfinished review of this version, or none. */
98
+ export async function loadDraftComments(reviewsDir, version) {
99
+ return (await loadDraft(reviewsDir, version)).comments;
99
100
  }
100
101
  async function writeAtomic(path, text) {
101
- const temp = `${path}.${randomBytes(4).toString("hex")}.tmp`;
102
+ // The pid lets `follow-me clean` tell an abandoned temp file from one being written.
103
+ const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
102
104
  await writeFile(temp, text);
103
105
  await rename(temp, path);
104
106
  }
@@ -139,134 +141,682 @@ function sendJson(res, status, body) {
139
141
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
140
142
  res.end(JSON.stringify(body));
141
143
  }
142
- function openBrowser(url) {
143
- const command = process.platform === "darwin" ? "open" : "xdg-open";
144
- const child = spawn(command, [url], { stdio: "ignore", detached: true });
145
- child.on("error", (error) => console.error(`could not open a browser: ${error.message}`));
146
- child.unref();
144
+ /** An integer query parameter within [min, max], else the default when absent; bad is a 400. */
145
+ function queryInt(url, name, min, max, fallback) {
146
+ const raw = url.searchParams.get(name);
147
+ if (raw === null)
148
+ return fallback;
149
+ const n = Number(raw);
150
+ if (raw === "" || !Number.isInteger(n) || n < min) {
151
+ throw new HttpError(400, `${name} must be an integer of at least ${min}`);
152
+ }
153
+ return Math.min(n, max);
147
154
  }
148
- export function serveReview(state, opts) {
149
- const { reviewsDir } = opts;
150
- const comments = new Map(state.comments.map((c) => [c.id, c]));
151
- const unitIds = new Set(state.units.map((u) => u.id));
152
- const draftFile = draftPath(reviewsDir, state.version);
153
- const reviewFile = join(reviewsDir, `v${state.version}.json`);
154
- let boundPort = 0;
155
+ function sendHtml(res, status, html) {
156
+ res.writeHead(status, {
157
+ "content-type": "text/html; charset=utf-8",
158
+ "cache-control": "no-store",
159
+ });
160
+ res.end(html);
161
+ }
162
+ function escapeHtml(text) {
163
+ return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
164
+ }
165
+ /** "just now", "5 min ago", "2 h ago" or "3 days ago". */
166
+ export function ago(ms) {
167
+ const minutes = Math.floor(ms / 60_000);
168
+ if (minutes < 1)
169
+ return "just now";
170
+ if (minutes < 60)
171
+ return `${minutes} min ago`;
172
+ const hours = Math.floor(minutes / 60);
173
+ if (hours < 24)
174
+ return `${hours} h ago`;
175
+ const days = Math.floor(hours / 24);
176
+ return `${days} day${days === 1 ? "" : "s"} ago`;
177
+ }
178
+ function tildePath(path) {
179
+ const home = homedir();
180
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
181
+ }
182
+ function indexPage(rows, now) {
183
+ const items = rows.map(({ summary: s, openedAt }) => {
184
+ const mode = s.mode === "walk"
185
+ ? '<span class="mode walk">code review</span>'
186
+ : `<span class="mode split">plan review v${s.version}</span>`;
187
+ const count = `${s.comments} comment${s.comments === 1 ? "" : "s"}`;
188
+ const repo = escapeHtml(basename(s.repo));
189
+ return ` <li>
190
+ <a class="session" href="${s.url}"><span class="repo">${repo}</span> <span class="branch">${escapeHtml(s.branch)}</span></a>
191
+ ${mode}
192
+ <span class="muted">${count} · opened ${ago(now - openedAt)}</span>
193
+ <span class="path muted">${escapeHtml(tildePath(s.repo))}</span>
194
+ </li>`;
195
+ });
196
+ const body = items.length === 0
197
+ ? ` <section class="empty">
198
+ <h1>No sessions yet</h1>
199
+ <p>Run <code>follow-me review</code> to review a plan, or <code>follow-me walk</code> to read a branch someone else wrote. Each one opens here.</p>
200
+ <p><a href="https://github.com/phanshiyu/follow-me#readme">How follow-me works</a></p>
201
+ </section>`
202
+ : ` <h1>Sessions</h1>
203
+ <ul class="session-index">
204
+ ${items.join("\n")}
205
+ </ul>`;
206
+ return `<!doctype html>
207
+ <html lang="en">
208
+ <head>
209
+ <meta charset="utf-8" />
210
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
211
+ <meta name="color-scheme" content="light dark" />
212
+ <title>follow-me sessions</title>
213
+ <link rel="stylesheet" href="/style.css" />
214
+ </head>
215
+ <body class="index">
216
+ <header class="topbar"><strong>follow-me</strong></header>
217
+ <main class="index-main">
218
+ ${body}
219
+ </main>
220
+ </body>
221
+ </html>
222
+ `;
223
+ }
224
+ /** The file behind a /vendor/ path, or null. Chunk names are checked, so no path escapes. */
225
+ function vendorFile(path) {
226
+ const part = PRISM_PATH.exec(path)?.[1];
227
+ if (part !== undefined) {
228
+ return part === "core" ? PRISM_CORE : new URL(`prism-${part}.min.js`, PRISM_CORE);
229
+ }
230
+ if (path === "/vendor/mermaid.mjs")
231
+ return MERMAID_ENTRY;
232
+ const chunk = MERMAID_CHUNK.exec(path)?.[1];
233
+ return chunk === undefined ? null : new URL(`chunks/mermaid.esm.min/${chunk}`, MERMAID_ENTRY);
234
+ }
235
+ async function readUi(name) {
236
+ try {
237
+ return await readFile(fileURLToPath(new URL(name, UI_DIR)));
238
+ }
239
+ catch (error) {
240
+ if (isRecord(error) && error["code"] === "ENOENT")
241
+ throw new HttpError(404, "not found");
242
+ throw error;
243
+ }
244
+ }
245
+ async function sendVendor(res, file) {
246
+ let bytes;
247
+ try {
248
+ bytes = await readFile(fileURLToPath(file));
249
+ }
250
+ catch (error) {
251
+ if (isRecord(error) && error["code"] === "ENOENT")
252
+ throw new HttpError(404, "not found");
253
+ throw error;
254
+ }
255
+ // The bundle only changes with the installed package, and it is several megabytes.
256
+ res.writeHead(200, {
257
+ "content-type": "text/javascript; charset=utf-8",
258
+ "cache-control": "max-age=86400",
259
+ });
260
+ res.end(bytes);
261
+ }
262
+ function viewOf(registration, loaded, draft) {
263
+ const { state, reviewsDir } = loaded;
264
+ return {
265
+ registration,
266
+ loaded,
267
+ comments: new Map(state.comments.map((c) => [c.id, c])),
268
+ decisions: new Map(draft.concerns.map((d) => [d.concern, d])),
269
+ resolved: new Set(draft.resolved),
270
+ unitIds: new Set(state.units.map((u) => u.id)),
271
+ concernIds: new Set(state.commits.flatMap((c) => (c.concerns ?? []).map((k) => k.id))),
272
+ draftFile: draftPath(reviewsDir, state.version),
273
+ reviewFile: join(reviewsDir, `v${state.version}.json`),
274
+ };
275
+ }
276
+ function withReplies(comment, replies) {
277
+ const { replies: _dropped, ...rest } = comment;
278
+ return replies === undefined || replies.length === 0 ? rest : { ...rest, replies };
279
+ }
280
+ function mintReplyId(taken) {
281
+ for (;;) {
282
+ let id = "r_";
283
+ for (const byte of randomBytes(6))
284
+ id += BASE32.charAt(byte % 32);
285
+ if (isReplyId(id) && !taken.has(id))
286
+ return id;
287
+ }
288
+ }
289
+ const DEFAULT_POLL_MS = 25_000;
290
+ const MAX_POLL_MS = 60_000;
291
+ const STREAM_PING_MS = 15_000;
292
+ /** One hosted review: its comments, its event log, its autosave chain, and how it ends. */
293
+ function hostSession(id, registration, loaded, draft, load) {
294
+ const openedAt = Date.now();
295
+ let view = viewOf(registration, loaded, draft);
296
+ let finish = () => { };
297
+ const done = new Promise((resolve) => {
298
+ finish = resolve;
299
+ });
300
+ // The append-only log a watcher long-polls. seq is the position, counted from 1.
301
+ const events = [];
302
+ const waiters = new Set();
303
+ const listeners = new Set();
304
+ const emit = (body) => {
305
+ events.push({ ...body, seq: events.length + 1 });
306
+ for (const wake of [...waiters])
307
+ wake();
308
+ for (const listener of [...listeners])
309
+ listener();
310
+ };
311
+ const waitForEvents = (since, timeoutMs, signal) => new Promise((resolve) => {
312
+ const answer = () => resolve({ seq: events.length, events: events.slice(since) });
313
+ if (events.length > since || timeoutMs === 0 || signal.aborted)
314
+ return answer();
315
+ const wake = () => {
316
+ clearTimeout(timer);
317
+ waiters.delete(wake);
318
+ signal.removeEventListener("abort", wake);
319
+ answer();
320
+ };
321
+ const timer = setTimeout(wake, timeoutMs);
322
+ waiters.add(wake);
323
+ signal.addEventListener("abort", wake);
324
+ });
325
+ let ending = false;
326
+ let swapping = false;
327
+ const open = () => {
328
+ if (ending)
329
+ throw new HttpError(409, "this review is already ending");
330
+ if (swapping)
331
+ throw new HttpError(409, "the session is switching plan versions; try again");
332
+ };
155
333
  // Writes are chained so two rapid edits never race on the same file.
156
334
  let writes = Promise.resolve();
157
335
  const saveDraft = () => {
336
+ const { state } = view.loaded;
337
+ const file = view.draftFile;
338
+ const concerns = [...view.decisions.values()];
339
+ const resolved = [...view.resolved];
158
340
  const snapshot = JSON.stringify({
159
341
  schema: "follow-me/review-draft@1",
160
342
  planVersion: state.version,
161
- comments: [...comments.values()],
343
+ comments: [...view.comments.values()],
344
+ ...(concerns.length === 0 ? {} : { concerns }),
345
+ ...(resolved.length === 0 ? {} : { resolved }),
162
346
  }, null, 2);
347
+ const dir = view.loaded.reviewsDir;
163
348
  writes = writes.then(async () => {
164
- await mkdir(reviewsDir, { recursive: true });
165
- await writeAtomic(draftFile, `${snapshot}\n`);
349
+ await mkdir(dir, { recursive: true });
350
+ await writeAtomic(file, `${snapshot}\n`);
166
351
  });
167
352
  return writes;
168
353
  };
169
- const accept = (comment) => {
170
- if (!comment)
171
- throw new HttpError(400, "malformed comment");
172
- const { anchor } = comment;
173
- if (anchor.kind !== "plan" && anchor.commit >= state.commits.length) {
354
+ const accept = (parsed) => {
355
+ if (typeof parsed === "string")
356
+ throw new HttpError(400, `malformed comment: ${parsed}`);
357
+ if (!COMMENT_ID.test(parsed.id))
358
+ throw new HttpError(400, "malformed comment: bad id");
359
+ const { anchor } = parsed;
360
+ if (anchor.kind !== "plan" && anchor.commit >= view.loaded.state.commits.length) {
174
361
  throw new HttpError(400, "anchor commit out of range");
175
362
  }
176
- if (anchor.kind === "hunk" && !unitIds.has(anchor.unit)) {
363
+ if (anchor.kind === "hunk" && !view.unitIds.has(anchor.unit)) {
177
364
  throw new HttpError(400, "anchor names an unknown unit");
178
365
  }
179
- return comment;
366
+ return parsed;
180
367
  };
181
- return new Promise((resolve, reject) => {
182
- const server = createServer((req, res) => {
183
- route(req, res).catch((error) => {
184
- if (error instanceof HttpError)
185
- return sendJson(res, error.status, { error: error.message });
186
- console.error(error);
187
- sendJson(res, 500, { error: "internal error" });
188
- });
368
+ const review = () => {
369
+ const concerns = [...view.decisions.values()];
370
+ const resolved = [...view.resolved];
371
+ return {
372
+ schema: "follow-me/review@1",
373
+ mode: view.loaded.state.mode,
374
+ planVersion: view.loaded.state.version,
375
+ planHash: view.loaded.state.planHash,
376
+ endedAt: new Date().toISOString(),
377
+ comments: [...view.comments.values()],
378
+ ...(concerns.length === 0 ? {} : { concerns }),
379
+ ...(resolved.length === 0 ? {} : { resolved }),
380
+ };
381
+ };
382
+ const report = (finished) => {
383
+ const { state } = view.loaded;
384
+ const units = new Map(state.units.map((u) => [u.id, u]));
385
+ return renderReport(finished, state.commits, units, {
386
+ branch: state.anchor.branch,
387
+ responses: [],
189
388
  });
190
- async function route(req, res) {
191
- const host = req.headers.host ?? "";
192
- // Blocks DNS rebinding and cross-site writes: only our own origin may talk to the API.
193
- if (host !== `127.0.0.1:${boundPort}` && host !== `localhost:${boundPort}`) {
194
- throw new HttpError(403, "unexpected host");
389
+ };
390
+ /** Writes reviews/vN.json and its report once, and removes the autosave. */
391
+ const writeReview = async (finished) => {
392
+ await writes;
393
+ await mkdir(view.loaded.reviewsDir, { recursive: true });
394
+ await writeAtomic(view.reviewFile, `${JSON.stringify(finished, null, 2)}\n`);
395
+ await writeAtomic(view.reviewFile.replace(/\.json$/, ".md"), report(finished));
396
+ await rm(view.draftFile, { force: true });
397
+ return view.reviewFile;
398
+ };
399
+ const hasWork = () => view.comments.size > 0 || view.decisions.size > 0;
400
+ return {
401
+ id,
402
+ done,
403
+ openedAt,
404
+ get registration() {
405
+ return view.registration;
406
+ },
407
+ summary: () => ({
408
+ id,
409
+ url: sessionPath(id),
410
+ repo: view.loaded.repo,
411
+ branch: view.loaded.state.anchor.branch,
412
+ mode: view.loaded.state.mode,
413
+ version: view.loaded.state.version,
414
+ comments: view.comments.size,
415
+ }),
416
+ snapshot: () => ({
417
+ ...view.loaded.state,
418
+ comments: [...view.comments.values()],
419
+ concerns: [...view.decisions.values()],
420
+ resolved: [...view.resolved],
421
+ seq: events.length,
422
+ previous: view.loaded.previous,
423
+ }),
424
+ /** Events after `since`, now and as they happen, until unfollowed or the review ends. */
425
+ latest: () => events.length,
426
+ follow: (since, send, stop) => {
427
+ if (since > events.length) {
428
+ throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
195
429
  }
196
- const origin = req.headers.origin;
197
- if (origin !== undefined && origin !== `http://${host}`)
198
- throw new HttpError(403, "bad origin");
199
- const method = req.method ?? "GET";
200
- const path = new URL(req.url ?? "/", "http://localhost").pathname;
201
- const asset = method === "GET" ? STATIC_FILES.get(path) : undefined;
202
- if (asset) {
203
- const bytes = await readFile(fileURLToPath(new URL(asset.file, UI_DIR)));
204
- res.writeHead(200, { "content-type": asset.type, "cache-control": "no-store" });
205
- res.end(bytes);
206
- return;
430
+ let last = since;
431
+ const flush = () => {
432
+ for (const event of events.slice(last)) {
433
+ send(event);
434
+ last = event.seq;
435
+ if (event.type === "ended") {
436
+ listeners.delete(flush);
437
+ stop();
438
+ return;
439
+ }
440
+ }
441
+ };
442
+ listeners.add(flush);
443
+ flush();
444
+ return () => listeners.delete(flush);
445
+ },
446
+ poll: (since, timeoutMs, signal) => {
447
+ if (since > events.length) {
448
+ throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
207
449
  }
208
- if (method === "GET" && path === "/api/state") {
209
- return sendJson(res, 200, { ...state, comments: [...comments.values()] });
450
+ return waitForEvents(since, timeoutMs, signal);
451
+ },
452
+ report: () => report(review()),
453
+ add: async (raw) => {
454
+ open();
455
+ if (!isRecord(raw))
456
+ throw new HttpError(400, "malformed comment");
457
+ const commentId = mintCommentId(new Set(view.comments.keys()));
458
+ const comment = withReplies(accept(parseComment({ ...raw, id: commentId, createdAt: new Date().toISOString() })), undefined);
459
+ view.comments.set(commentId, comment);
460
+ emit({ type: "comment", comment });
461
+ await saveDraft();
462
+ return commentId;
463
+ },
464
+ put: async (target, raw) => {
465
+ open();
466
+ const parsed = withReplies(accept(parseComment(raw)), undefined);
467
+ if (parsed.id !== target)
468
+ throw new HttpError(400, "comment id does not match the URL");
469
+ // Replies belong to the conversation, not to the page's copy of the comment.
470
+ const comment = withReplies(parsed, view.comments.get(target)?.replies);
471
+ view.comments.set(comment.id, comment);
472
+ emit({ type: "comment-updated", comment });
473
+ await saveDraft();
474
+ return comment;
475
+ },
476
+ remove: async (target) => {
477
+ open();
478
+ const gone = view.comments.get(target);
479
+ if (gone === undefined)
480
+ throw new HttpError(404, "no such comment");
481
+ view.comments.delete(target);
482
+ emit({ type: "comment-deleted", id: gone.id });
483
+ await saveDraft();
484
+ },
485
+ reply: async (target, raw) => {
486
+ open();
487
+ const comment = view.comments.get(target);
488
+ if (comment === undefined)
489
+ throw new HttpError(404, "no such comment");
490
+ const parsed = parseNewReply(raw);
491
+ if (typeof parsed === "string")
492
+ throw new HttpError(400, `malformed reply: ${parsed}`);
493
+ const taken = new Set([...view.comments.values()].flatMap((c) => c.replies ?? []).map((r) => r.id));
494
+ const reply = {
495
+ id: mintReplyId(taken),
496
+ ...parsed,
497
+ createdAt: new Date().toISOString(),
498
+ };
499
+ view.comments.set(comment.id, { ...comment, replies: [...(comment.replies ?? []), reply] });
500
+ emit({ type: "reply", commentId: comment.id, reply });
501
+ await saveDraft();
502
+ return reply.id;
503
+ },
504
+ decide: async (target, raw) => {
505
+ open();
506
+ if (!isConcernId(target) || !view.concernIds.has(target)) {
507
+ throw new HttpError(404, "no such concern in this plan");
210
508
  }
211
- if (method === "POST" && path === "/api/comments") {
212
- const raw = await readJson(req);
213
- if (!isRecord(raw))
214
- throw new HttpError(400, "malformed comment");
215
- const id = mintCommentId(new Set(comments.keys()));
216
- const comment = accept(parseComment({ ...raw, id, createdAt: new Date().toISOString() }));
217
- comments.set(id, comment);
218
- await saveDraft();
219
- return sendJson(res, 201, { id });
509
+ const decision = parseNewDecision(target, raw);
510
+ if (typeof decision === "string")
511
+ throw new HttpError(400, `malformed decision: ${decision}`);
512
+ view.decisions.set(target, decision);
513
+ const { concern: concernId, ...rest } = decision;
514
+ emit({ type: "concern", concernId, ...rest });
515
+ await saveDraft();
516
+ return decision;
517
+ },
518
+ resolve: async (raw) => {
519
+ open();
520
+ const body = isRecord(raw) ? raw : {};
521
+ const target = body["comment"];
522
+ const asked = view.loaded.previous?.comments.find((c) => c.id === target);
523
+ if (asked === undefined || typeof body["resolved"] !== "boolean") {
524
+ throw new HttpError(400, "expected { comment: <id of a question of the previous version>, resolved }");
220
525
  }
221
- const target = /^\/api\/comments\/([^/]+)$/.exec(path)?.[1];
222
- if (target !== undefined && method === "PUT") {
223
- const comment = accept(parseComment(await readJson(req)));
224
- if (comment.id !== target)
225
- throw new HttpError(400, "comment id does not match the URL");
226
- comments.set(comment.id, comment);
227
- await saveDraft();
228
- return sendJson(res, 200, comment);
526
+ if (body["resolved"])
527
+ view.resolved.add(asked.id);
528
+ else
529
+ view.resolved.delete(asked.id);
530
+ emit({ type: "resolved", commentId: asked.id, resolved: body["resolved"] });
531
+ await saveDraft();
532
+ return [...view.resolved];
533
+ },
534
+ /**
535
+ * Moves the session to a newer frozen plan under the same id and URL. The new version loads
536
+ * first, so a plan that no longer fits leaves the old one untouched; then the old version's
537
+ * review is finalized if anything was said, and the comment set starts fresh.
538
+ */
539
+ swap: async (version, hash) => {
540
+ open();
541
+ const { state } = view.loaded;
542
+ if (version === state.version) {
543
+ if (hash === state.planHash)
544
+ return;
545
+ throw new HttpError(409, `plan v${version} is already open with another hash`);
229
546
  }
230
- if (target !== undefined && method === "DELETE") {
231
- if (!comments.delete(target))
232
- throw new HttpError(404, "no such comment");
233
- await saveDraft();
234
- return sendJson(res, 200, { deleted: target });
547
+ if (version < state.version) {
548
+ throw new HttpError(409, `plan v${version} is older than the open v${state.version}`);
235
549
  }
236
- if (method === "POST" && path === "/api/end") {
550
+ swapping = true;
551
+ try {
237
552
  await writes;
238
- const review = {
239
- schema: "follow-me/review@1",
240
- mode: state.mode,
241
- planVersion: state.version,
242
- planHash: state.planHash,
243
- endedAt: new Date().toISOString(),
244
- comments: [...comments.values()],
245
- };
246
- await mkdir(reviewsDir, { recursive: true });
247
- await writeAtomic(reviewFile, `${JSON.stringify(review, null, 2)}\n`);
248
- await rm(draftFile, { force: true });
249
- sendJson(res, 200, { saved: reviewFile });
250
- res.on("close", () => {
251
- server.close(() => resolve(review));
252
- server.closeAllConnections();
253
- });
254
- return;
553
+ const next = await load({ ...view.registration, version, hash, lockToken: null }, view.loaded.release);
554
+ if (hasWork())
555
+ await writeReview(review());
556
+ else
557
+ await rm(view.draftFile, { force: true });
558
+ view = viewOf({ ...view.registration, version, hash }, next, await loadDraft(next.reviewsDir, version));
559
+ emit({ type: "version", version, hash });
560
+ }
561
+ finally {
562
+ swapping = false;
563
+ }
564
+ },
565
+ /** Writes reviews/vN.json and vN.md exactly once and releases the session's lock. */
566
+ end: async () => {
567
+ open();
568
+ ending = true;
569
+ const finished = review();
570
+ const file = await writeReview(finished);
571
+ view.loaded.release();
572
+ emit({ type: "ended", review: file });
573
+ return { review: finished, file };
574
+ },
575
+ finish: (finished) => finish(finished),
576
+ abandon: () => {
577
+ view.loaded.release();
578
+ finish(null);
579
+ },
580
+ };
581
+ }
582
+ function sameRegistration(a, b) {
583
+ return a.dir === b.dir && a.mode === b.mode && a.version === b.version && a.hash === b.hash;
584
+ }
585
+ /**
586
+ * Starts the server that hosts every review of this user. Sessions live in a registry keyed by
587
+ * SessionId; each one is served at /s/<id>/ with its API under /s/<id>/api/. The server stops
588
+ * itself once it has had no session for idleMs.
589
+ */
590
+ export function startServer(opts) {
591
+ const registry = new Map();
592
+ const pending = new Map();
593
+ let boundPort = 0;
594
+ let idleTimer;
595
+ let stopped = false;
596
+ let markClosed = () => { };
597
+ const closed = new Promise((resolve) => {
598
+ markClosed = resolve;
599
+ });
600
+ const server = createServer((req, res) => {
601
+ route(req, res).catch((error) => {
602
+ if (error instanceof HttpError)
603
+ return sendJson(res, error.status, { error: error.message });
604
+ if (error instanceof RegistrationError)
605
+ return sendJson(res, 409, { error: error.message });
606
+ console.error(error);
607
+ sendJson(res, 500, { error: "internal error" });
608
+ });
609
+ });
610
+ const close = () => {
611
+ if (!stopped) {
612
+ stopped = true;
613
+ clearTimeout(idleTimer);
614
+ for (const session of registry.values())
615
+ session.abandon();
616
+ registry.clear();
617
+ server.close(() => markClosed());
618
+ server.closeAllConnections();
619
+ }
620
+ return closed;
621
+ };
622
+ const settle = () => {
623
+ clearTimeout(idleTimer);
624
+ if (stopped || registry.size > 0 || pending.size > 0)
625
+ return;
626
+ idleTimer = setTimeout(() => void close(), opts.idleMs);
627
+ };
628
+ const sessions = () => [...registry.values()].map((s) => s.summary());
629
+ const register = async (registration) => {
630
+ if (stopped)
631
+ throw new RegistrationError("the server is stopping");
632
+ const id = sessionIdOf(registration.dir);
633
+ const existing = registry.get(id) ?? (await pending.get(id));
634
+ if (existing) {
635
+ if (!sameRegistration(existing.registration, registration)) {
636
+ const { mode, version } = existing.registration;
637
+ throw new RegistrationError(`this session is already open as a ${mode === "walk" ? "walk" : "review"} of plan v${version}; end it first`);
255
638
  }
639
+ return { summary: existing.summary(), done: existing.done };
640
+ }
641
+ clearTimeout(idleTimer);
642
+ const loading = opts
643
+ .load(registration, null)
644
+ .then(async (loaded) => hostSession(id, registration, loaded, await loadDraft(loaded.reviewsDir, loaded.state.version), opts.load));
645
+ pending.set(id, loading);
646
+ try {
647
+ const session = await loading;
648
+ registry.set(id, session);
649
+ return { summary: session.summary(), done: session.done };
650
+ }
651
+ finally {
652
+ pending.delete(id);
653
+ settle();
654
+ }
655
+ };
656
+ const hosted = (raw) => {
657
+ const session = isSessionId(raw) ? registry.get(raw) : undefined;
658
+ if (!session)
659
+ throw new HttpError(404, "no such session; it ended or never existed");
660
+ return session;
661
+ };
662
+ async function route(req, res) {
663
+ const host = req.headers.host ?? "";
664
+ // Blocks DNS rebinding and cross-site writes: only our own origin may talk to the API.
665
+ if (host !== `127.0.0.1:${boundPort}` && host !== `localhost:${boundPort}`) {
666
+ throw new HttpError(403, "unexpected host");
667
+ }
668
+ const origin = req.headers.origin;
669
+ if (origin !== undefined && origin !== `http://${host}`)
670
+ throw new HttpError(403, "bad origin");
671
+ const method = req.method ?? "GET";
672
+ const url = new URL(req.url ?? "/", "http://localhost");
673
+ const path = url.pathname;
674
+ const asset = method === "GET" ? STATIC_FILE.exec(path)?.[1] : undefined;
675
+ if (asset !== undefined) {
676
+ const bytes = await readUi(asset);
677
+ const type = STATIC_TYPES.get(asset.slice(asset.lastIndexOf(".") + 1));
678
+ res.writeHead(200, { "content-type": type ?? "text/plain", "cache-control": "no-store" });
679
+ res.end(bytes);
680
+ return;
681
+ }
682
+ const vendor = method === "GET" ? vendorFile(path) : null;
683
+ if (vendor)
684
+ return sendVendor(res, vendor);
685
+ if (method === "GET" && path === "/") {
686
+ const rows = [...registry.values()].map((h) => ({
687
+ summary: h.summary(),
688
+ openedAt: h.openedAt,
689
+ }));
690
+ return sendHtml(res, 200, indexPage(rows, Date.now()));
691
+ }
692
+ if (method === "GET" && path === "/api/health") {
693
+ return sendJson(res, 200, { app: HEALTH_APP, pid: process.pid });
694
+ }
695
+ if (method === "GET" && path === "/api/sessions")
696
+ return sendJson(res, 200, sessions());
697
+ if (method === "POST" && path === "/api/sessions") {
698
+ const registration = parseRegistration(await readJson(req));
699
+ if (!registration)
700
+ throw new HttpError(400, "malformed registration");
701
+ const before = registry.has(sessionIdOf(registration.dir));
702
+ const { summary } = await register(registration);
703
+ return sendJson(res, before ? 200 : 201, summary);
704
+ }
705
+ const match = /^\/s\/([^/]+)(\/.*)?$/.exec(path);
706
+ if (match === null)
256
707
  throw new HttpError(404, "not found");
708
+ const [, rawId = "", rest] = match;
709
+ if (rest === undefined && method === "GET") {
710
+ res.writeHead(308, { location: `/s/${encodeURIComponent(rawId)}/` });
711
+ res.end();
712
+ return;
713
+ }
714
+ if (method === "GET" && rest === "/") {
715
+ if (!isSessionId(rawId) || !registry.has(rawId)) {
716
+ return sendHtml(res, 404, '<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="color-scheme" content="light dark" /><title>follow-me</title><link rel="stylesheet" href="/style.css" /></head><body class="index"><header class="topbar"><strong>follow-me</strong></header><main class="index-main"><section class="empty"><h1>This session has ended</h1><p>It finished, or it never existed. <a href="/">All sessions</a></p></section></main></body></html>\n');
717
+ }
718
+ return sendHtml(res, 200, await readFile(fileURLToPath(new URL("index.html", UI_DIR)), "utf8"));
719
+ }
720
+ const session = hosted(rawId);
721
+ if (method === "GET" && rest === "/api/state")
722
+ return sendJson(res, 200, session.snapshot());
723
+ if (method === "GET" && rest === "/api/events/stream") {
724
+ const header = Number(req.headers["last-event-id"]);
725
+ const resume = Number.isInteger(header) && header > 0 ? header : 0;
726
+ const since = Math.max(queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0), resume);
727
+ if (since > session.latest())
728
+ throw new HttpError(409, "since is ahead of this session's log");
729
+ res.writeHead(200, {
730
+ "content-type": "text/event-stream; charset=utf-8",
731
+ "cache-control": "no-cache",
732
+ connection: "keep-alive",
733
+ });
734
+ res.write(": follow-me\n\n");
735
+ const ping = setInterval(() => res.write(": ping\n\n"), STREAM_PING_MS);
736
+ const unfollow = session.follow(since, (event) => res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`), () => res.end());
737
+ res.on("close", () => {
738
+ clearInterval(ping);
739
+ unfollow();
740
+ });
741
+ return;
742
+ }
743
+ if (method === "GET" && rest === "/api/events") {
744
+ const abort = new AbortController();
745
+ res.on("close", () => abort.abort());
746
+ const since = queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0);
747
+ const timeout = queryInt(url, "timeout", 0, MAX_POLL_MS, DEFAULT_POLL_MS);
748
+ return sendJson(res, 200, await session.poll(since, timeout, abort.signal));
749
+ }
750
+ if (method === "GET" && rest === "/api/report") {
751
+ res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
752
+ res.end(session.report());
753
+ return;
754
+ }
755
+ if (method === "POST" && rest === "/api/version") {
756
+ const body = await readJson(req);
757
+ const version = isRecord(body) ? body["version"] : undefined;
758
+ const hash = isRecord(body) ? body["hash"] : undefined;
759
+ if (!isPlanVersion(version) || typeof hash !== "string" || hash === "") {
760
+ throw new HttpError(400, "expected { version, hash }");
761
+ }
762
+ await session.swap(version, hash);
763
+ return sendJson(res, 200, session.summary());
764
+ }
765
+ if (method === "POST" && rest === "/api/resolved") {
766
+ return sendJson(res, 200, { resolved: await session.resolve(await readJson(req)) });
767
+ }
768
+ const replies = /^\/api\/comments\/([^/]+)\/replies$/.exec(rest ?? "")?.[1];
769
+ if (replies !== undefined && method === "POST") {
770
+ return sendJson(res, 201, { id: await session.reply(replies, await readJson(req)) });
771
+ }
772
+ const concern = /^\/api\/concerns\/([^/]+)$/.exec(rest ?? "")?.[1];
773
+ if (concern !== undefined && method === "POST") {
774
+ return sendJson(res, 200, await session.decide(concern, await readJson(req)));
775
+ }
776
+ if (method === "POST" && rest === "/api/comments") {
777
+ return sendJson(res, 201, { id: await session.add(await readJson(req)) });
778
+ }
779
+ const target = /^\/api\/comments\/([^/]+)$/.exec(rest ?? "")?.[1];
780
+ if (target !== undefined && method === "PUT") {
781
+ return sendJson(res, 200, await session.put(target, await readJson(req)));
782
+ }
783
+ if (target !== undefined && method === "DELETE") {
784
+ await session.remove(target);
785
+ return sendJson(res, 200, { deleted: target });
257
786
  }
258
- server.on("error", reject);
259
- server.listen(opts.port ?? 0, "127.0.0.1", () => {
787
+ if (method === "POST" && rest === "/api/end") {
788
+ const { review, file } = await session.end();
789
+ registry.delete(session.id);
790
+ sendJson(res, 200, { saved: file });
791
+ res.on("close", () => {
792
+ session.finish(review);
793
+ settle();
794
+ });
795
+ return;
796
+ }
797
+ throw new HttpError(404, "not found");
798
+ }
799
+ return new Promise((resolve, reject) => {
800
+ server.once("error", (error) => {
801
+ if (isRecord(error) && error["code"] === "EADDRINUSE")
802
+ reject(new PortInUseError(opts.port));
803
+ else
804
+ reject(error);
805
+ });
806
+ server.listen(opts.port, "127.0.0.1", () => {
260
807
  const address = server.address();
261
808
  if (!address || typeof address === "string")
262
809
  return reject(new Error("server has no port"));
263
810
  boundPort = address.port;
264
- const url = `http://127.0.0.1:${boundPort}/`;
265
- const label = state.mode === "walk" ? "code review" : "review";
266
- process.stdout.write(`follow-me ${label}: ${url}\n`);
267
- opts.onListen?.(url);
268
- if (opts.open)
269
- openBrowser(url);
811
+ settle();
812
+ resolve({
813
+ port: boundPort,
814
+ url: `http://127.0.0.1:${boundPort}/`,
815
+ register,
816
+ sessions,
817
+ close,
818
+ closed,
819
+ });
270
820
  });
271
821
  });
272
822
  }