@shiyuphan/follow-me 0.1.0 → 0.1.1

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,12 +1,27 @@
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 { isAbsolute, join } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
6
+ import { isPlanVersion } from "./plan.js";
7
+ import { isLockToken } from "./session.js";
8
+ /** The requested port is taken; host.ts decides whether that is our server or a stranger. */
9
+ export class PortInUseError extends Error {
10
+ name = "PortInUseError";
11
+ port;
12
+ constructor(port) {
13
+ super(`port ${port} is in use`);
14
+ this.port = port;
15
+ }
16
+ }
17
+ /** A session the server cannot host, with the reason the CLI shows. */
18
+ export class RegistrationError extends Error {
19
+ name = "RegistrationError";
20
+ }
21
+ /** GET /api/health answers with this, so a CLI can tell our server from another program. */
22
+ export const HEALTH_APP = "follow-me";
7
23
  const UI_DIR = new URL("../ui/", import.meta.url);
8
24
  const STATIC_FILES = new Map([
9
- ["/", { file: "index.html", type: "text/html; charset=utf-8" }],
10
25
  ["/app.js", { file: "app.js", type: "text/javascript; charset=utf-8" }],
11
26
  ["/playback.js", { file: "playback.js", type: "text/javascript; charset=utf-8" }],
12
27
  ["/style.css", { file: "style.css", type: "text/css; charset=utf-8" }],
@@ -15,6 +30,7 @@ const MAX_BODY_BYTES = 1_000_000;
15
30
  const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
16
31
  const COMMENT_ID = /^c_[a-z2-7]{6}$/;
17
32
  const UNIT_ID = /^[hfm]_[0-9a-f]{12}(~\d+)?$/;
33
+ const SESSION_ID = /^[0-9a-f]{12}$/;
18
34
  function isRecord(value) {
19
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
20
36
  }
@@ -30,6 +46,19 @@ function isRepoPath(value) {
30
46
  function isIndex(value) {
31
47
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
32
48
  }
49
+ export function isSessionId(value) {
50
+ return typeof value === "string" && SESSION_ID.test(value);
51
+ }
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;
58
+ }
59
+ export function sessionPath(id) {
60
+ return `/s/${id}/`;
61
+ }
33
62
  function parseAnchor(raw) {
34
63
  if (!isRecord(raw))
35
64
  return null;
@@ -74,6 +103,21 @@ export function parseComment(raw) {
74
103
  return null;
75
104
  return { id, anchor, context: { commitMessage, path }, body, createdAt };
76
105
  }
106
+ /** The one place untrusted JSON becomes a Registration. */
107
+ export function parseRegistration(raw) {
108
+ if (!isRecord(raw))
109
+ return null;
110
+ const { dir, mode, version, hash, lockToken } = raw;
111
+ if (typeof dir !== "string" || !isAbsolute(dir))
112
+ return null;
113
+ if (mode !== "split" && mode !== "walk")
114
+ return null;
115
+ if (!isPlanVersion(version) || typeof hash !== "string" || hash === "")
116
+ return null;
117
+ if (lockToken !== null && !isLockToken(lockToken))
118
+ return null;
119
+ return { dir, mode, version, hash, lockToken };
120
+ }
77
121
  function draftPath(reviewsDir, version) {
78
122
  return join(reviewsDir, `v${version}.draft.json`);
79
123
  }
@@ -98,7 +142,8 @@ export async function loadDraftComments(reviewsDir, version) {
98
142
  });
99
143
  }
100
144
  async function writeAtomic(path, text) {
101
- const temp = `${path}.${randomBytes(4).toString("hex")}.tmp`;
145
+ // The pid lets `follow-me clean` tell an abandoned temp file from one being written.
146
+ const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
102
147
  await writeFile(temp, text);
103
148
  await rename(temp, path);
104
149
  }
@@ -139,19 +184,53 @@ function sendJson(res, status, body) {
139
184
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
140
185
  res.end(JSON.stringify(body));
141
186
  }
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();
187
+ function sendHtml(res, status, html) {
188
+ res.writeHead(status, {
189
+ "content-type": "text/html; charset=utf-8",
190
+ "cache-control": "no-store",
191
+ });
192
+ res.end(html);
193
+ }
194
+ function escapeHtml(text) {
195
+ return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
147
196
  }
148
- export function serveReview(state, opts) {
149
- const { reviewsDir } = opts;
197
+ function indexPage(sessions) {
198
+ const rows = sessions.map((s) => {
199
+ const what = s.mode === "walk" ? "code review" : `review of plan v${s.version}`;
200
+ const count = `${s.comments} comment${s.comments === 1 ? "" : "s"}`;
201
+ return ` <li><a href="${s.url}">${escapeHtml(s.branch)}</a> <span class="muted">${what} · ${count} · ${escapeHtml(s.repo)}</span></li>`;
202
+ });
203
+ const body = rows.length === 0
204
+ ? ' <p class="muted">No sessions. Run follow-me review or follow-me walk in a repo.</p>'
205
+ : ` <ul class="session-index">\n${rows.join("\n")}\n </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
+ <h1>follow-me sessions</h1>
217
+ ${body}
218
+ </body>
219
+ </html>
220
+ `;
221
+ }
222
+ /** One hosted review: its comments, its autosave chain, and how it ends. */
223
+ function hostSession(id, registration, loaded) {
224
+ const { state, reviewsDir } = loaded;
150
225
  const comments = new Map(state.comments.map((c) => [c.id, c]));
151
226
  const unitIds = new Set(state.units.map((u) => u.id));
152
227
  const draftFile = draftPath(reviewsDir, state.version);
153
228
  const reviewFile = join(reviewsDir, `v${state.version}.json`);
154
- let boundPort = 0;
229
+ let finish = () => { };
230
+ const done = new Promise((resolve) => {
231
+ finish = resolve;
232
+ });
233
+ let ending = false;
155
234
  // Writes are chained so two rapid edits never race on the same file.
156
235
  let writes = Promise.resolve();
157
236
  const saveDraft = () => {
@@ -178,95 +257,242 @@ export function serveReview(state, opts) {
178
257
  }
179
258
  return comment;
180
259
  };
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
- });
260
+ return {
261
+ id,
262
+ registration,
263
+ done,
264
+ summary: () => ({
265
+ id,
266
+ url: sessionPath(id),
267
+ repo: loaded.repo,
268
+ branch: state.anchor.branch,
269
+ mode: state.mode,
270
+ version: state.version,
271
+ comments: comments.size,
272
+ }),
273
+ snapshot: () => ({ ...state, comments: [...comments.values()] }),
274
+ add: async (raw) => {
275
+ if (!isRecord(raw))
276
+ throw new HttpError(400, "malformed comment");
277
+ const commentId = mintCommentId(new Set(comments.keys()));
278
+ const comment = accept(parseComment({ ...raw, id: commentId, createdAt: new Date().toISOString() }));
279
+ comments.set(commentId, comment);
280
+ await saveDraft();
281
+ return commentId;
282
+ },
283
+ put: async (target, raw) => {
284
+ const comment = accept(parseComment(raw));
285
+ if (comment.id !== target)
286
+ throw new HttpError(400, "comment id does not match the URL");
287
+ comments.set(comment.id, comment);
288
+ await saveDraft();
289
+ return comment;
290
+ },
291
+ remove: async (target) => {
292
+ if (!comments.delete(target))
293
+ throw new HttpError(404, "no such comment");
294
+ await saveDraft();
295
+ },
296
+ /** Writes reviews/vN.json exactly once and releases the session's lock. */
297
+ end: async () => {
298
+ if (ending)
299
+ throw new HttpError(409, "this review is already ending");
300
+ ending = true;
301
+ await writes;
302
+ const review = {
303
+ schema: "follow-me/review@1",
304
+ mode: state.mode,
305
+ planVersion: state.version,
306
+ planHash: state.planHash,
307
+ endedAt: new Date().toISOString(),
308
+ comments: [...comments.values()],
309
+ };
310
+ await mkdir(reviewsDir, { recursive: true });
311
+ await writeAtomic(reviewFile, `${JSON.stringify(review, null, 2)}\n`);
312
+ await rm(draftFile, { force: true });
313
+ loaded.release();
314
+ return { review, file: reviewFile };
315
+ },
316
+ finish: (review) => finish(review),
317
+ abandon: () => {
318
+ loaded.release();
319
+ finish(null);
320
+ },
321
+ };
322
+ }
323
+ function sameRegistration(a, b) {
324
+ return a.dir === b.dir && a.mode === b.mode && a.version === b.version && a.hash === b.hash;
325
+ }
326
+ /**
327
+ * Starts the server that hosts every review of this user. Sessions live in a registry keyed by
328
+ * SessionId; each one is served at /s/<id>/ with its API under /s/<id>/api/. The server stops
329
+ * itself once it has had no session for idleMs.
330
+ */
331
+ export function startServer(opts) {
332
+ const registry = new Map();
333
+ const pending = new Map();
334
+ let boundPort = 0;
335
+ let idleTimer;
336
+ let stopped = false;
337
+ let markClosed = () => { };
338
+ const closed = new Promise((resolve) => {
339
+ markClosed = resolve;
340
+ });
341
+ const server = createServer((req, res) => {
342
+ route(req, res).catch((error) => {
343
+ if (error instanceof HttpError)
344
+ return sendJson(res, error.status, { error: error.message });
345
+ if (error instanceof RegistrationError)
346
+ return sendJson(res, 409, { error: error.message });
347
+ console.error(error);
348
+ sendJson(res, 500, { error: "internal error" });
189
349
  });
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");
195
- }
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;
207
- }
208
- if (method === "GET" && path === "/api/state") {
209
- return sendJson(res, 200, { ...state, comments: [...comments.values()] });
210
- }
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 });
220
- }
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);
229
- }
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 });
235
- }
236
- if (method === "POST" && path === "/api/end") {
237
- 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;
350
+ });
351
+ const close = () => {
352
+ if (!stopped) {
353
+ stopped = true;
354
+ clearTimeout(idleTimer);
355
+ for (const session of registry.values())
356
+ session.abandon();
357
+ registry.clear();
358
+ server.close(() => markClosed());
359
+ server.closeAllConnections();
360
+ }
361
+ return closed;
362
+ };
363
+ const settle = () => {
364
+ clearTimeout(idleTimer);
365
+ if (stopped || registry.size > 0 || pending.size > 0)
366
+ return;
367
+ idleTimer = setTimeout(() => void close(), opts.idleMs);
368
+ };
369
+ const sessions = () => [...registry.values()].map((s) => s.summary());
370
+ const register = async (registration) => {
371
+ if (stopped)
372
+ throw new RegistrationError("the server is stopping");
373
+ const id = sessionIdOf(registration.dir);
374
+ const existing = registry.get(id) ?? (await pending.get(id));
375
+ if (existing) {
376
+ if (!sameRegistration(existing.registration, registration)) {
377
+ const { mode, version } = existing.registration;
378
+ throw new RegistrationError(`this session is already open as a ${mode === "walk" ? "walk" : "review"} of plan v${version}; end it first`);
255
379
  }
380
+ return { summary: existing.summary(), done: existing.done };
381
+ }
382
+ clearTimeout(idleTimer);
383
+ const loading = opts.load(registration).then((loaded) => hostSession(id, registration, loaded));
384
+ pending.set(id, loading);
385
+ try {
386
+ const session = await loading;
387
+ registry.set(id, session);
388
+ return { summary: session.summary(), done: session.done };
389
+ }
390
+ finally {
391
+ pending.delete(id);
392
+ settle();
393
+ }
394
+ };
395
+ const hosted = (raw) => {
396
+ const session = isSessionId(raw) ? registry.get(raw) : undefined;
397
+ if (!session)
398
+ throw new HttpError(404, "no such session; it ended or never existed");
399
+ return session;
400
+ };
401
+ async function route(req, res) {
402
+ const host = req.headers.host ?? "";
403
+ // Blocks DNS rebinding and cross-site writes: only our own origin may talk to the API.
404
+ if (host !== `127.0.0.1:${boundPort}` && host !== `localhost:${boundPort}`) {
405
+ throw new HttpError(403, "unexpected host");
406
+ }
407
+ const origin = req.headers.origin;
408
+ if (origin !== undefined && origin !== `http://${host}`)
409
+ throw new HttpError(403, "bad origin");
410
+ const method = req.method ?? "GET";
411
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
412
+ const asset = method === "GET" ? STATIC_FILES.get(path) : undefined;
413
+ if (asset) {
414
+ const bytes = await readFile(fileURLToPath(new URL(asset.file, UI_DIR)));
415
+ res.writeHead(200, { "content-type": asset.type, "cache-control": "no-store" });
416
+ res.end(bytes);
417
+ return;
418
+ }
419
+ if (method === "GET" && path === "/")
420
+ return sendHtml(res, 200, indexPage(sessions()));
421
+ if (method === "GET" && path === "/api/health") {
422
+ return sendJson(res, 200, { app: HEALTH_APP, pid: process.pid });
423
+ }
424
+ if (method === "GET" && path === "/api/sessions")
425
+ return sendJson(res, 200, sessions());
426
+ if (method === "POST" && path === "/api/sessions") {
427
+ const registration = parseRegistration(await readJson(req));
428
+ if (!registration)
429
+ throw new HttpError(400, "malformed registration");
430
+ const before = registry.has(sessionIdOf(registration.dir));
431
+ const { summary } = await register(registration);
432
+ return sendJson(res, before ? 200 : 201, summary);
433
+ }
434
+ const match = /^\/s\/([^/]+)(\/.*)?$/.exec(path);
435
+ if (match === null)
256
436
  throw new HttpError(404, "not found");
437
+ const [, rawId = "", rest] = match;
438
+ if (rest === undefined && method === "GET") {
439
+ res.writeHead(308, { location: `/s/${encodeURIComponent(rawId)}/` });
440
+ res.end();
441
+ return;
442
+ }
443
+ if (method === "GET" && rest === "/") {
444
+ if (!isSessionId(rawId) || !registry.has(rawId)) {
445
+ return sendHtml(res, 404, '<!doctype html><title>follow-me</title><p>This session ended or never existed. <a href="/">All sessions</a></p>\n');
446
+ }
447
+ return sendHtml(res, 200, await readFile(fileURLToPath(new URL("index.html", UI_DIR)), "utf8"));
448
+ }
449
+ const session = hosted(rawId);
450
+ if (method === "GET" && rest === "/api/state")
451
+ return sendJson(res, 200, session.snapshot());
452
+ if (method === "POST" && rest === "/api/comments") {
453
+ return sendJson(res, 201, { id: await session.add(await readJson(req)) });
454
+ }
455
+ const target = /^\/api\/comments\/([^/]+)$/.exec(rest ?? "")?.[1];
456
+ if (target !== undefined && method === "PUT") {
457
+ return sendJson(res, 200, await session.put(target, await readJson(req)));
257
458
  }
258
- server.on("error", reject);
259
- server.listen(opts.port ?? 0, "127.0.0.1", () => {
459
+ if (target !== undefined && method === "DELETE") {
460
+ await session.remove(target);
461
+ return sendJson(res, 200, { deleted: target });
462
+ }
463
+ if (method === "POST" && rest === "/api/end") {
464
+ const { review, file } = await session.end();
465
+ registry.delete(session.id);
466
+ sendJson(res, 200, { saved: file });
467
+ res.on("close", () => {
468
+ session.finish(review);
469
+ settle();
470
+ });
471
+ return;
472
+ }
473
+ throw new HttpError(404, "not found");
474
+ }
475
+ return new Promise((resolve, reject) => {
476
+ server.once("error", (error) => {
477
+ if (isRecord(error) && error["code"] === "EADDRINUSE")
478
+ reject(new PortInUseError(opts.port));
479
+ else
480
+ reject(error);
481
+ });
482
+ server.listen(opts.port, "127.0.0.1", () => {
260
483
  const address = server.address();
261
484
  if (!address || typeof address === "string")
262
485
  return reject(new Error("server has no port"));
263
486
  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);
487
+ settle();
488
+ resolve({
489
+ port: boundPort,
490
+ url: `http://127.0.0.1:${boundPort}/`,
491
+ register,
492
+ sessions,
493
+ close,
494
+ closed,
495
+ });
270
496
  });
271
497
  });
272
498
  }