@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.
- package/README.md +11 -0
- package/dist/src/cli.js +244 -41
- package/dist/src/git.js +85 -23
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +293 -0
- package/dist/src/install.js +131 -0
- package/dist/src/server.js +320 -94
- package/dist/src/session.js +213 -6
- package/dist/src/types.js +2 -1
- package/dist/ui/app.js +28 -1
- package/dist/ui/index.html +1 -0
- package/dist/ui/style.css +35 -0
- package/package.json +1 -1
- package/skills/follow-me/SKILL.md +3 -13
package/dist/src/server.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
const
|
|
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
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
}
|