@erdemtuna/doc-review 0.7.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/LICENSE +21 -0
- package/README.md +117 -0
- package/package.json +57 -0
- package/src/SKILL.md +143 -0
- package/src/anchor-text.js +115 -0
- package/src/chrome-client.js +986 -0
- package/src/chrome-session.js +15 -0
- package/src/chrome.css +458 -0
- package/src/chrome.html +83 -0
- package/src/cli.js +341 -0
- package/src/click-target.js +26 -0
- package/src/editing.js +99 -0
- package/src/frame-channel.js +44 -0
- package/src/frame-policy.js +16 -0
- package/src/html-transform.js +62 -0
- package/src/markdown.js +87 -0
- package/src/paths.js +87 -0
- package/src/sdk.js +1520 -0
- package/src/serialize.js +46 -0
- package/src/server-entry.js +26 -0
- package/src/server-lock.js +101 -0
- package/src/server.js +1268 -0
- package/src/setup.js +117 -0
- package/src/state.js +491 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { atomicWrite, Store, resolveAsset } from "./state.js";
|
|
7
|
+
import { injectSdk, stripSdk } from "./html-transform.js";
|
|
8
|
+
import { isMarkdown, renderMarkdownPage } from "./markdown.js";
|
|
9
|
+
import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath, stateDir, targetKey } from "./paths.js";
|
|
10
|
+
import { acquireServerLock, releaseServerLock, removeOwnedServerRecord } from "./server-lock.js";
|
|
11
|
+
import { invocation, shellQuote } from "./setup.js";
|
|
12
|
+
|
|
13
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
const MIME = {
|
|
16
|
+
".html": "text/html; charset=utf-8",
|
|
17
|
+
".js": "text/javascript; charset=utf-8",
|
|
18
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
19
|
+
".css": "text/css; charset=utf-8",
|
|
20
|
+
".json": "application/json; charset=utf-8",
|
|
21
|
+
".svg": "image/svg+xml",
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".jpg": "image/jpeg",
|
|
24
|
+
".jpeg": "image/jpeg",
|
|
25
|
+
".gif": "image/gif",
|
|
26
|
+
".webp": "image/webp",
|
|
27
|
+
".avif": "image/avif",
|
|
28
|
+
".ico": "image/x-icon",
|
|
29
|
+
".woff": "font/woff",
|
|
30
|
+
".woff2": "font/woff2",
|
|
31
|
+
".ttf": "font/ttf",
|
|
32
|
+
".otf": "font/otf",
|
|
33
|
+
".txt": "text/plain; charset=utf-8",
|
|
34
|
+
".md": "text/markdown; charset=utf-8",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const MAX_BODY = 24 * 1024 * 1024;
|
|
38
|
+
const POLL_HEARTBEAT_MS = 15000;
|
|
39
|
+
const WATCH_INTERVAL_MS = 400;
|
|
40
|
+
const IDLE_SHUTDOWN_MS = Number(process.env.DOC_REVIEW_IDLE_MS || 45 * 60 * 1000);
|
|
41
|
+
/** A window with no live connection this long is treated as closed for good. */
|
|
42
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
43
|
+
const MAX_LOCAL_REDIRECTS = 5;
|
|
44
|
+
const RENDER_TTL_MS = 60 * 1000;
|
|
45
|
+
/** Generous enough for a dev server's cold compile, but a wedged one can't hang us forever. */
|
|
46
|
+
const LOCAL_FETCH_TIMEOUT_MS = 30000;
|
|
47
|
+
const MAX_LOCAL_PAGE_BYTES = 24 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* File reviews may contain agent-generated or otherwise untrusted JavaScript.
|
|
51
|
+
* Only the nonce-bearing Doc Review SDK may execute in those artifacts;
|
|
52
|
+
* authored scripts and inline event handlers remain in the source but stay inert.
|
|
53
|
+
*/
|
|
54
|
+
const fileReviewCsp = (nonce) =>
|
|
55
|
+
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
|
|
56
|
+
|
|
57
|
+
const hash = (text) => crypto.createHash("sha1").update(text).digest("hex");
|
|
58
|
+
const uid = (prefix) => `${prefix}_${crypto.randomBytes(6).toString("hex")}`;
|
|
59
|
+
|
|
60
|
+
/** Read an HTML response with a hard size cap, since text() is unbounded. */
|
|
61
|
+
async function readCapped(response, url) {
|
|
62
|
+
const reader = response.body.getReader();
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
if (done) break;
|
|
68
|
+
size += value.byteLength;
|
|
69
|
+
if (size > MAX_LOCAL_PAGE_BYTES) {
|
|
70
|
+
await reader.cancel().catch(() => {});
|
|
71
|
+
throw new Error(`The page at ${url} is larger than ${MAX_LOCAL_PAGE_BYTES / (1024 * 1024)}MB.`);
|
|
72
|
+
}
|
|
73
|
+
chunks.push(value);
|
|
74
|
+
}
|
|
75
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function fetchLocalPage(target, redirects = 0) {
|
|
79
|
+
const url = localUrl(target);
|
|
80
|
+
if (!url) throw new Error("Localhost redirects must use HTTP or HTTPS.");
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetch(url, {
|
|
84
|
+
redirect: "manual",
|
|
85
|
+
headers: { accept: "text/html,application/xhtml+xml" },
|
|
86
|
+
signal: AbortSignal.timeout(LOCAL_FETCH_TIMEOUT_MS),
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err.name === "TimeoutError") {
|
|
90
|
+
throw new Error(`Localhost did not answer within ${LOCAL_FETCH_TIMEOUT_MS / 1000}s for ${url}`);
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
if (response.status >= 300 && response.status < 400) {
|
|
95
|
+
const location = response.headers.get("location");
|
|
96
|
+
if (!location) throw new Error(`Localhost returned redirect ${response.status} without a location.`);
|
|
97
|
+
if (redirects >= MAX_LOCAL_REDIRECTS) throw new Error("Too many redirects while loading the localhost page.");
|
|
98
|
+
return fetchLocalPage(new URL(location, url).href, redirects + 1);
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) throw new Error(`Localhost returned ${response.status} for ${url}`);
|
|
101
|
+
const contentType = response.headers.get("content-type") || "";
|
|
102
|
+
if (!/html|xhtml/i.test(contentType)) {
|
|
103
|
+
throw new Error(`Expected an HTML page from localhost, but received ${contentType || "an unknown content type"}.`);
|
|
104
|
+
}
|
|
105
|
+
return { html: await readCapped(response, url), resolvedUrl: response.url || url };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createServer({ store: suppliedStore, storeOptions, owner = null, renderTtlMs = RENDER_TTL_MS } = {}) {
|
|
109
|
+
const store = suppliedStore || new Store(storeOptions);
|
|
110
|
+
const cliInvocation = invocation();
|
|
111
|
+
const instanceId = owner?.instance_id || crypto.randomBytes(16).toString("hex");
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Random per-run secret. Every /api route requires it, so a malicious web
|
|
115
|
+
* page firing blind cross-origin POSTs at 127.0.0.1 cannot write files.
|
|
116
|
+
* The CLI reads it from server.json; the chrome page gets it injected.
|
|
117
|
+
*/
|
|
118
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
119
|
+
|
|
120
|
+
/** Browser windows. Ephemeral — nothing durable lives here. */
|
|
121
|
+
const sessions = new Map(); // sessionId -> { id, entryKey, activeKey, generation, renderId, visited, clients:Set<res>, lastSeen }
|
|
122
|
+
const renders = new Map(); // renderId -> current artifact/bootstrap record
|
|
123
|
+
/** Agent long-polls, keyed by the entry page they were started on. */
|
|
124
|
+
const pollers = new Map(); // entryKey -> Set<{ res, timer }>
|
|
125
|
+
const sseResponses = new Map(); // res -> heartbeat timer
|
|
126
|
+
const watched = new Map(); // key -> { file }
|
|
127
|
+
const lastWritten = new Map(); // key -> content hash doc-review itself wrote
|
|
128
|
+
const sockets = new Set();
|
|
129
|
+
let everListened = false;
|
|
130
|
+
let serverClosed = false;
|
|
131
|
+
|
|
132
|
+
let lastActivity = Date.now();
|
|
133
|
+
const touch = () => {
|
|
134
|
+
lastActivity = Date.now();
|
|
135
|
+
};
|
|
136
|
+
const seen = (session) => {
|
|
137
|
+
if (session) session.lastSeen = Date.now();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------- helpers
|
|
141
|
+
|
|
142
|
+
function sessionsForKey(key) {
|
|
143
|
+
return [...sessions.values()].filter((s) => s.activeKey === key);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sessionsForEntry(entryKey) {
|
|
147
|
+
return [...sessions.values()].filter((s) => s.entryKey === entryKey);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function expireRender(renderId) {
|
|
151
|
+
if (!renderId) return;
|
|
152
|
+
const render = renders.get(renderId);
|
|
153
|
+
renders.delete(renderId);
|
|
154
|
+
const session = render ? sessions.get(render.sessionId) : null;
|
|
155
|
+
if (session?.renderId === renderId) session.renderId = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function invalidateSessionRender(session) {
|
|
159
|
+
if (session?.renderId) expireRender(session.renderId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function currentRender(renderId) {
|
|
163
|
+
const render = renders.get(renderId);
|
|
164
|
+
if (!render) return null;
|
|
165
|
+
const session = sessions.get(render.sessionId);
|
|
166
|
+
if (
|
|
167
|
+
!session ||
|
|
168
|
+
session.renderId !== renderId ||
|
|
169
|
+
session.activeKey !== render.pageKey ||
|
|
170
|
+
session.generation !== render.generation
|
|
171
|
+
) {
|
|
172
|
+
expireRender(renderId);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (Date.now() - render.createdAt > renderTtlMs) {
|
|
176
|
+
if (render.documentState !== "served") {
|
|
177
|
+
expireRender(renderId);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
render.capability = null;
|
|
181
|
+
}
|
|
182
|
+
return render;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function emit(session, event, data) {
|
|
186
|
+
for (const res of session.clients) {
|
|
187
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data || {})}\n\n`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A pending batch only means "working" once an agent has actually taken it.
|
|
193
|
+
* Feedback sent with nothing listening is "stranded", and the browser says so.
|
|
194
|
+
*/
|
|
195
|
+
function agentState(entryKey) {
|
|
196
|
+
const pending = store.batch(entryKey);
|
|
197
|
+
if (pending?.delivery_state === "delivered") return "working";
|
|
198
|
+
const set = pollers.get(entryKey);
|
|
199
|
+
if (set && set.size) return "listening";
|
|
200
|
+
return pending ? "stranded" : "idle";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function broadcastAgent(entryKey) {
|
|
204
|
+
const state = agentState(entryKey);
|
|
205
|
+
for (const session of sessionsForEntry(entryKey)) emit(session, "agent", { state });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ------------------------------------------------------------- file watch
|
|
209
|
+
|
|
210
|
+
function watchPage(key) {
|
|
211
|
+
if (watched.has(key)) return;
|
|
212
|
+
const page = store.page(key);
|
|
213
|
+
if (!page || page.kind === "url") return;
|
|
214
|
+
watched.set(key, { file: page.file });
|
|
215
|
+
|
|
216
|
+
fs.watchFile(page.file, { interval: WATCH_INTERVAL_MS }, () => {
|
|
217
|
+
let html = "";
|
|
218
|
+
try {
|
|
219
|
+
html = fs.readFileSync(page.file, "utf8");
|
|
220
|
+
} catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const current = hash(html);
|
|
224
|
+
// Our own autosave must never bounce back as a reload.
|
|
225
|
+
if (lastWritten.get(key) === current) return;
|
|
226
|
+
lastWritten.set(key, current);
|
|
227
|
+
store.setPristine(key, html);
|
|
228
|
+
for (const session of sessionsForKey(key)) {
|
|
229
|
+
invalidateSessionRender(session);
|
|
230
|
+
emit(session, "reload", { key });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function writePage(key, html) {
|
|
236
|
+
const page = store.page(key);
|
|
237
|
+
if (!page) throw new Error("unknown page");
|
|
238
|
+
if (page.kind === "url") throw new Error("localhost pages are applied through their source files");
|
|
239
|
+
const clean = stripSdk(html);
|
|
240
|
+
atomicWrite(page.file, clean);
|
|
241
|
+
lastWritten.set(key, hash(clean));
|
|
242
|
+
return clean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ------------------------------------------------------------------ batch
|
|
246
|
+
|
|
247
|
+
function deliver(entryKey) {
|
|
248
|
+
const set = pollers.get(entryKey);
|
|
249
|
+
if (!set || set.size === 0) return false;
|
|
250
|
+
const pending = store.markBatchDelivered(entryKey);
|
|
251
|
+
if (!pending) return false;
|
|
252
|
+
for (const poller of [...set]) {
|
|
253
|
+
clearInterval(poller.timer);
|
|
254
|
+
set.delete(poller);
|
|
255
|
+
poller.res.end(JSON.stringify(pending.batch));
|
|
256
|
+
}
|
|
257
|
+
pollers.delete(entryKey);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Every page you left feedback on ships in one batch, grouped by target. */
|
|
262
|
+
function collectPages(session) {
|
|
263
|
+
const out = [];
|
|
264
|
+
for (const key of session.visited) {
|
|
265
|
+
const page = store.page(key);
|
|
266
|
+
if (!page) continue;
|
|
267
|
+
if (!page.comments.length && !page.edits.length) continue;
|
|
268
|
+
out.push({
|
|
269
|
+
key,
|
|
270
|
+
kind: page.kind === "url" ? "url" : "file",
|
|
271
|
+
file: page.kind === "url" ? page.url : page.file,
|
|
272
|
+
url: page.kind === "url" ? page.url : undefined,
|
|
273
|
+
comments: page.comments.map((c) => ({
|
|
274
|
+
id: c.id,
|
|
275
|
+
kind: c.kind,
|
|
276
|
+
quote: c.quote,
|
|
277
|
+
anchor: c.anchor,
|
|
278
|
+
feedback: c.feedback,
|
|
279
|
+
...(c.correction ? { correction: true, correction_of: c.correctionOf } : {}),
|
|
280
|
+
})),
|
|
281
|
+
edits: page.edits.map((e) => ({
|
|
282
|
+
label: e.label,
|
|
283
|
+
kind: e.kind,
|
|
284
|
+
before: e.before,
|
|
285
|
+
after: e.after,
|
|
286
|
+
...(e.before_html !== undefined && e.before_html !== e.before ? { before_html: e.before_html } : {}),
|
|
287
|
+
...(e.after_html !== undefined && e.after_html !== e.after ? { after_html: e.after_html } : {}),
|
|
288
|
+
...(Array.isArray(e.staged_assets) && e.staged_assets.length ? { staged_assets: e.staged_assets } : {}),
|
|
289
|
+
})),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Pages with feedback that are not the one on screen. */
|
|
296
|
+
function otherPages(session) {
|
|
297
|
+
return collectPages(session)
|
|
298
|
+
.filter((p) => p.key !== session.activeKey)
|
|
299
|
+
.map((p) => ({
|
|
300
|
+
key: p.key,
|
|
301
|
+
filename: p.kind === "url" ? new URL(p.url).pathname || p.url : path.basename(p.file),
|
|
302
|
+
count: p.comments.length + p.edits.length,
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sendBatch(sessionId, note) {
|
|
307
|
+
const session = sessions.get(sessionId);
|
|
308
|
+
if (!session) return { error: "unknown session" };
|
|
309
|
+
|
|
310
|
+
const pages = collectPages(session);
|
|
311
|
+
if (!pages.length && !note) return { error: "nothing to send" };
|
|
312
|
+
|
|
313
|
+
const hasMarkdown = pages.some((p) => p.kind === "file" && isMarkdown(p.file));
|
|
314
|
+
const hasUrl = pages.some((p) => p.kind === "url");
|
|
315
|
+
const hasCorrections = pages.some((p) => p.comments.some((c) => c.correction));
|
|
316
|
+
const id = `b_${crypto.randomBytes(12).toString("hex")}`;
|
|
317
|
+
const entry = store.page(session.entryKey);
|
|
318
|
+
const pollTarget = entry?.kind === "url" ? entry.url : entry?.file;
|
|
319
|
+
const ackCommand = `${cliInvocation} poll ${shellQuote(pollTarget)} --ack ${id} --timeout 600`;
|
|
320
|
+
const batch = {
|
|
321
|
+
batch_id: id,
|
|
322
|
+
status: "feedback",
|
|
323
|
+
pages: pages.map(({ kind, file, url, comments, edits }) => ({ kind, file, ...(url ? { url } : {}), comments, edits })),
|
|
324
|
+
overall_note: note || "",
|
|
325
|
+
sent_at: new Date().toISOString(),
|
|
326
|
+
next_step:
|
|
327
|
+
"Apply this feedback. Each entry in `pages` names the reviewed file or localhost URL. Items under `edits` are " +
|
|
328
|
+
"changes the human already made: `after` is their exact new wording, so carry it across verbatim, and " +
|
|
329
|
+
"never revert it. When an edit carries `after_html`, the human changed formatting (bold, italic, links) — " +
|
|
330
|
+
"use the HTML version, translated into the source's own syntax. " +
|
|
331
|
+
(hasMarkdown
|
|
332
|
+
? "Markdown pages were reviewed rendered, so quotes and `after` wording use the rendered text — apply " +
|
|
333
|
+
"the change to the Markdown source, keeping its formatting syntax. "
|
|
334
|
+
: "") +
|
|
335
|
+
(hasUrl
|
|
336
|
+
? "Localhost pages were edited directly in the review UI. Find the matching project source (such as MDX or TSX) " +
|
|
337
|
+
"and apply every exact edit or deletion there; never try to write the rendered HTML response back to the app. " +
|
|
338
|
+
"When an edit includes `staged_assets`, copy each local image into the app's appropriate asset folder, replace its " +
|
|
339
|
+
"temporary preview URL in `after_html`, and preserve the image at the user's insertion point. "
|
|
340
|
+
: "") +
|
|
341
|
+
(hasCorrections
|
|
342
|
+
? "Comments marked `correction` replace their `correction_of` instruction; follow the correction and do not apply the older wording. "
|
|
343
|
+
: "") +
|
|
344
|
+
`When every page is updated, acknowledge only this batch and wait for more by running: ${ackCommand}`,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const record = {
|
|
348
|
+
batch,
|
|
349
|
+
cleanup: pages.map((p) => ({
|
|
350
|
+
key: p.key,
|
|
351
|
+
ids: p.comments.map((c) => c.id),
|
|
352
|
+
staged: p.edits.flatMap((edit) => (edit.staged_assets || []).map((asset) => asset.path)),
|
|
353
|
+
sentAt: Date.now(),
|
|
354
|
+
})),
|
|
355
|
+
};
|
|
356
|
+
store.setBatch(session.entryKey, record);
|
|
357
|
+
deliver(session.entryKey);
|
|
358
|
+
broadcastAgent(session.entryKey);
|
|
359
|
+
return { ok: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function deleteStagedAsset(file) {
|
|
363
|
+
const stagedRoot = path.join(stateDir(), "pasted");
|
|
364
|
+
const resolved = path.resolve(file);
|
|
365
|
+
const relative = path.relative(stagedRoot, resolved);
|
|
366
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return;
|
|
367
|
+
try {
|
|
368
|
+
fs.unlinkSync(resolved);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (err.code !== "ENOENT") console.error(`Could not remove acknowledged staged asset ${resolved}: ${err.message}`);
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
fs.rmdirSync(path.dirname(resolved));
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (err.code !== "ENOENT" && err.code !== "ENOTEMPTY") {
|
|
376
|
+
console.error(`Could not remove acknowledged staged asset directory ${path.dirname(resolved)}: ${err.message}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function ack(entryKey, id) {
|
|
382
|
+
const result = store.acknowledgeBatch(entryKey, id);
|
|
383
|
+
if (!result.acknowledged) return false;
|
|
384
|
+
// The JSON transition is already durable. Files are cleanup only and must
|
|
385
|
+
// never disappear before the receipt and page cleanup commit succeeds.
|
|
386
|
+
for (const file of result.staged) deleteStagedAsset(file);
|
|
387
|
+
for (const session of sessionsForEntry(entryKey)) emit(session, "refresh", {});
|
|
388
|
+
// File targets reload through fs.watch. URL targets have no source file to
|
|
389
|
+
// watch, so acknowledgement is the signal to fetch the rebuilt route.
|
|
390
|
+
for (const key of result.keys) {
|
|
391
|
+
if (store.page(key)?.kind === "url") {
|
|
392
|
+
for (const session of sessionsForKey(key)) {
|
|
393
|
+
invalidateSessionRender(session);
|
|
394
|
+
emit(session, "reload", { key });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
broadcastAgent(entryKey);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A deliberate stop, not a tab close: the browser forgets the session and
|
|
404
|
+
* any waiting agent is released with a clear "stop polling" answer instead
|
|
405
|
+
* of being left to burn its timeout. Unsent feedback stays in the store.
|
|
406
|
+
*/
|
|
407
|
+
function endSession(session) {
|
|
408
|
+
invalidateSessionRender(session);
|
|
409
|
+
sessions.delete(session.id);
|
|
410
|
+
for (const res of session.clients) {
|
|
411
|
+
res.write(`event: ended\ndata: {}\n\n`);
|
|
412
|
+
res.end();
|
|
413
|
+
}
|
|
414
|
+
session.clients.clear();
|
|
415
|
+
// Another window on the same target keeps its agent connection alive.
|
|
416
|
+
if (sessionsForEntry(session.entryKey).length > 0) return;
|
|
417
|
+
const set = pollers.get(session.entryKey);
|
|
418
|
+
if (!set) return;
|
|
419
|
+
for (const poller of [...set]) {
|
|
420
|
+
clearInterval(poller.timer);
|
|
421
|
+
set.delete(poller);
|
|
422
|
+
poller.res.end(
|
|
423
|
+
JSON.stringify({
|
|
424
|
+
status: "closed",
|
|
425
|
+
next_step:
|
|
426
|
+
"The user ended this review session. Stop polling — do not run the poll command again. " +
|
|
427
|
+
"Any unsent feedback is kept and will ship the next time this target is reviewed.",
|
|
428
|
+
})
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ----------------------------------------------------------------- routes
|
|
434
|
+
|
|
435
|
+
function readBody(req) {
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
let size = 0;
|
|
438
|
+
const chunks = [];
|
|
439
|
+
req.on("data", (chunk) => {
|
|
440
|
+
size += chunk.length;
|
|
441
|
+
if (size > MAX_BODY) {
|
|
442
|
+
reject(new Error("body too large"));
|
|
443
|
+
req.destroy();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
chunks.push(chunk);
|
|
447
|
+
});
|
|
448
|
+
req.on("end", () => {
|
|
449
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
450
|
+
if (!raw) return resolve({});
|
|
451
|
+
try {
|
|
452
|
+
resolve(JSON.parse(raw));
|
|
453
|
+
} catch {
|
|
454
|
+
reject(new Error("invalid json"));
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
req.on("error", reject);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Binary request body (pasted images), capped like readBody. */
|
|
462
|
+
function readRawBody(req) {
|
|
463
|
+
return new Promise((resolve, reject) => {
|
|
464
|
+
let size = 0;
|
|
465
|
+
const chunks = [];
|
|
466
|
+
req.on("data", (chunk) => {
|
|
467
|
+
size += chunk.length;
|
|
468
|
+
if (size > MAX_BODY) {
|
|
469
|
+
reject(new Error("body too large"));
|
|
470
|
+
req.destroy();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
chunks.push(chunk);
|
|
474
|
+
});
|
|
475
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
476
|
+
req.on("error", reject);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const json = (res, code, payload) => {
|
|
481
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
|
|
482
|
+
res.end(JSON.stringify(payload));
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const opaqueModuleCors = (req) =>
|
|
486
|
+
req.headers.origin === "null"
|
|
487
|
+
? { "access-control-allow-origin": "null", vary: "Origin" }
|
|
488
|
+
: {};
|
|
489
|
+
|
|
490
|
+
function serveFile(res, file, extraHeaders) {
|
|
491
|
+
fs.readFile(file, (err, buf) => {
|
|
492
|
+
if (err) {
|
|
493
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
494
|
+
res.end("Not found");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
res.writeHead(200, {
|
|
499
|
+
"content-type": MIME[path.extname(file).toLowerCase()] || "application/octet-stream",
|
|
500
|
+
"cache-control": "no-store",
|
|
501
|
+
"x-content-type-options": "nosniff",
|
|
502
|
+
...(extraHeaders || {}),
|
|
503
|
+
});
|
|
504
|
+
res.end(buf);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function pageState(key, session) {
|
|
509
|
+
const page = store.page(key);
|
|
510
|
+
if (!page) return null;
|
|
511
|
+
// The entry target is what the agent polls, even after navigating elsewhere.
|
|
512
|
+
const entry = session ? store.page(session.entryKey) : null;
|
|
513
|
+
const currentTarget = page.kind === "url" ? page.url : page.file;
|
|
514
|
+
const pollTarget = entry ? (entry.kind === "url" ? entry.url : entry.file) : currentTarget;
|
|
515
|
+
return {
|
|
516
|
+
key: page.key,
|
|
517
|
+
kind: page.kind === "url" ? "url" : "file",
|
|
518
|
+
file: currentTarget,
|
|
519
|
+
...(page.kind === "url" ? { url: page.url } : {}),
|
|
520
|
+
filename: page.kind === "url" ? new URL(page.url).pathname || page.url : path.basename(page.file),
|
|
521
|
+
markdown: page.kind !== "url" && isMarkdown(page.file),
|
|
522
|
+
feedbackOnly: page.kind === "url",
|
|
523
|
+
comments: page.comments,
|
|
524
|
+
edits: page.edits,
|
|
525
|
+
canRevert: page.kind !== "url" && typeof page.pristine === "string" && page.pristine.length > 0,
|
|
526
|
+
pollCommand: `${cliInvocation} poll ${shellQuote(pollTarget)}`,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const server = http.createServer(async (req, res) => {
|
|
531
|
+
touch();
|
|
532
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
533
|
+
const route = url.pathname;
|
|
534
|
+
|
|
535
|
+
try {
|
|
536
|
+
// A request that arrived via a DNS-rebound hostname carries that hostname
|
|
537
|
+
// in Host. Refusing it means a malicious page can never speak to us as if
|
|
538
|
+
// it were same-origin.
|
|
539
|
+
const host = String(req.headers.host || "");
|
|
540
|
+
const port = req.socket.localPort;
|
|
541
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
542
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
543
|
+
return res.end("Forbidden");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (route === "/health") {
|
|
547
|
+
return json(res, 200, { ok: true, pid: process.pid, instance_id: instanceId, protocol: SERVER_PROTOCOL });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Every API route needs the per-run token; static assets and the
|
|
551
|
+
// unguessable /s/<id> chrome page do not.
|
|
552
|
+
// Header only — a token in a query string would leak into logs and
|
|
553
|
+
// history. Constant-time compare, so timing can't narrow the secret.
|
|
554
|
+
if (route.startsWith("/api/")) {
|
|
555
|
+
const provided = Buffer.from(String(req.headers["x-doc-review-token"] || ""));
|
|
556
|
+
const expected = Buffer.from(token);
|
|
557
|
+
const ok = provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
|
|
558
|
+
if (!ok) return json(res, 401, { error: "missing or invalid token" });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// --- static chrome assets
|
|
562
|
+
if (route === "/chrome.css") return serveFile(res, path.join(here, "chrome.css"));
|
|
563
|
+
if (route === "/chrome.js") return serveFile(res, path.join(here, "chrome-client.js"));
|
|
564
|
+
if (route === "/chrome-session.js") return serveFile(res, path.join(here, "chrome-session.js"));
|
|
565
|
+
if (route === "/sdk.js") return serveFile(res, path.join(here, "sdk.js"), opaqueModuleCors(req));
|
|
566
|
+
if (route === "/editing.js") return serveFile(res, path.join(here, "editing.js"), opaqueModuleCors(req));
|
|
567
|
+
if (route === "/anchor-text.js") return serveFile(res, path.join(here, "anchor-text.js"), opaqueModuleCors(req));
|
|
568
|
+
if (route === "/frame-policy.js") return serveFile(res, path.join(here, "frame-policy.js"));
|
|
569
|
+
if (route === "/click-target.js") return serveFile(res, path.join(here, "click-target.js"), opaqueModuleCors(req));
|
|
570
|
+
if (route === "/serialize.js") return serveFile(res, path.join(here, "serialize.js"), opaqueModuleCors(req));
|
|
571
|
+
if (route === "/frame-channel.js") return serveFile(res, path.join(here, "frame-channel.js"), opaqueModuleCors(req));
|
|
572
|
+
|
|
573
|
+
// --- open a browser session for a file or localhost URL
|
|
574
|
+
if (route === "/api/session" && req.method === "POST") {
|
|
575
|
+
const body = await readBody(req);
|
|
576
|
+
const target = canonicalTarget(body.target || body.file || "");
|
|
577
|
+
let page;
|
|
578
|
+
if (target.kind === "url") {
|
|
579
|
+
// Fail during open with a useful message rather than opening a blank review.
|
|
580
|
+
await fetchLocalPage(target.value);
|
|
581
|
+
page = store.openUrl(target.value);
|
|
582
|
+
} else {
|
|
583
|
+
if (!fs.existsSync(target.value)) return json(res, 404, { error: `File not found: ${target.value}` });
|
|
584
|
+
const html = fs.readFileSync(target.value, "utf8");
|
|
585
|
+
page = store.openPage(target.value, stripSdk(html));
|
|
586
|
+
lastWritten.set(page.key, hash(stripSdk(html)));
|
|
587
|
+
}
|
|
588
|
+
watchPage(page.key);
|
|
589
|
+
const id = uid("s");
|
|
590
|
+
sessions.set(id, {
|
|
591
|
+
id,
|
|
592
|
+
entryKey: page.key,
|
|
593
|
+
activeKey: page.key,
|
|
594
|
+
generation: 0,
|
|
595
|
+
renderId: null,
|
|
596
|
+
visited: new Set([page.key]),
|
|
597
|
+
clients: new Set(),
|
|
598
|
+
lastSeen: Date.now(),
|
|
599
|
+
});
|
|
600
|
+
return json(res, 200, { sessionId: id, key: page.key, path: `/s/${id}` });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// --- the chrome page
|
|
604
|
+
if (route.startsWith("/s/")) {
|
|
605
|
+
const id = route.slice(3);
|
|
606
|
+
if (!sessions.has(id)) {
|
|
607
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
608
|
+
return res.end("This review session has ended. Run doc-review <target> again.");
|
|
609
|
+
}
|
|
610
|
+
seen(sessions.get(id));
|
|
611
|
+
const shell = fs.readFileSync(path.join(here, "chrome.html"), "utf8");
|
|
612
|
+
res.writeHead(200, { "content-type": MIME[".html"], "cache-control": "no-store" });
|
|
613
|
+
return res.end(shell.replace("__SESSION_ID__", id).replace("__TOKEN__", token));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const renderMatch = route.match(/^\/api\/session\/(\w+)\/render$/);
|
|
617
|
+
if (renderMatch && req.method === "POST") {
|
|
618
|
+
const session = sessions.get(renderMatch[1]);
|
|
619
|
+
if (!session) return json(res, 404, { error: "unknown session" });
|
|
620
|
+
seen(session);
|
|
621
|
+
const body = await readBody(req);
|
|
622
|
+
const generation = Number(body.generation);
|
|
623
|
+
const pageKey = String(body.key || "");
|
|
624
|
+
if (!Number.isSafeInteger(generation) || generation <= session.generation) {
|
|
625
|
+
return json(res, 409, { error: "stale render generation", generation: session.generation });
|
|
626
|
+
}
|
|
627
|
+
if (pageKey !== session.activeKey || !store.page(pageKey)) {
|
|
628
|
+
return json(res, 409, { error: "render page is no longer current" });
|
|
629
|
+
}
|
|
630
|
+
invalidateSessionRender(session);
|
|
631
|
+
const renderId = `r_${crypto.randomBytes(24).toString("hex")}`;
|
|
632
|
+
const capability = crypto.randomBytes(32).toString("base64url");
|
|
633
|
+
const render = {
|
|
634
|
+
renderId,
|
|
635
|
+
capability,
|
|
636
|
+
sessionId: session.id,
|
|
637
|
+
pageKey,
|
|
638
|
+
generation,
|
|
639
|
+
createdAt: Date.now(),
|
|
640
|
+
documentState: "registered",
|
|
641
|
+
};
|
|
642
|
+
session.generation = generation;
|
|
643
|
+
session.renderId = renderId;
|
|
644
|
+
renders.set(renderId, render);
|
|
645
|
+
return json(res, 200, {
|
|
646
|
+
renderId,
|
|
647
|
+
capability,
|
|
648
|
+
generation,
|
|
649
|
+
pageKey,
|
|
650
|
+
path: `/artifact/${renderId}/index.html`,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const readyMatch = route.match(/^\/api\/session\/(\w+)\/render\/(r_[a-f0-9]+)\/ready$/);
|
|
655
|
+
if (readyMatch && req.method === "POST") {
|
|
656
|
+
const session = sessions.get(readyMatch[1]);
|
|
657
|
+
const render = currentRender(readyMatch[2]);
|
|
658
|
+
if (!session || !render || render.sessionId !== session.id) {
|
|
659
|
+
return json(res, 409, { error: "render is no longer current" });
|
|
660
|
+
}
|
|
661
|
+
const body = await readBody(req);
|
|
662
|
+
const provided = Buffer.from(String(body.capability || ""));
|
|
663
|
+
const expected = Buffer.from(String(render.capability || ""));
|
|
664
|
+
const capabilityMatches = provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
|
|
665
|
+
if (
|
|
666
|
+
!capabilityMatches ||
|
|
667
|
+
body.generation !== render.generation ||
|
|
668
|
+
body.pageKey !== render.pageKey
|
|
669
|
+
) {
|
|
670
|
+
return json(res, 403, { error: "invalid render capability" });
|
|
671
|
+
}
|
|
672
|
+
render.capability = null;
|
|
673
|
+
return json(res, 200, { ok: true });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// --- the reviewed page itself, plus sibling assets for its render
|
|
677
|
+
if (route.startsWith("/artifact/")) {
|
|
678
|
+
const rest = route.slice("/artifact/".length);
|
|
679
|
+
const slash = rest.indexOf("/");
|
|
680
|
+
const renderId = slash === -1 ? rest : rest.slice(0, slash);
|
|
681
|
+
const asset = slash === -1 ? "" : rest.slice(slash + 1);
|
|
682
|
+
const render = currentRender(renderId);
|
|
683
|
+
const page = render ? store.page(render.pageKey) : null;
|
|
684
|
+
if (!render || !page) {
|
|
685
|
+
res.writeHead(410, { "content-type": "text/plain", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
686
|
+
return res.end("Render expired");
|
|
687
|
+
}
|
|
688
|
+
if (!asset || asset === "index.html") {
|
|
689
|
+
if (render.documentState !== "registered") {
|
|
690
|
+
res.writeHead(410, { "content-type": "text/plain", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
691
|
+
return res.end("Render document already consumed");
|
|
692
|
+
}
|
|
693
|
+
render.documentState = "loading";
|
|
694
|
+
let html = "";
|
|
695
|
+
let sdkOptions = {};
|
|
696
|
+
let extraHeaders = {};
|
|
697
|
+
if (page.kind === "url") {
|
|
698
|
+
try {
|
|
699
|
+
const fetched = await fetchLocalPage(page.url);
|
|
700
|
+
html = fetched.html;
|
|
701
|
+
sdkOptions = {
|
|
702
|
+
baseHref: fetched.resolvedUrl,
|
|
703
|
+
nonce: render.capability,
|
|
704
|
+
generation: render.generation,
|
|
705
|
+
pageKey: render.pageKey,
|
|
706
|
+
src: `http://${host}/sdk.js`,
|
|
707
|
+
};
|
|
708
|
+
} catch (err) {
|
|
709
|
+
render.documentState = "registered";
|
|
710
|
+
res.writeHead(502, {
|
|
711
|
+
"content-type": "text/plain; charset=utf-8",
|
|
712
|
+
"cache-control": "no-store",
|
|
713
|
+
"referrer-policy": "no-referrer",
|
|
714
|
+
});
|
|
715
|
+
return res.end(`Could not load ${page.url}: ${err.message}`);
|
|
716
|
+
}
|
|
717
|
+
} else {
|
|
718
|
+
try {
|
|
719
|
+
html = fs.readFileSync(page.file, "utf8");
|
|
720
|
+
} catch {
|
|
721
|
+
render.documentState = "registered";
|
|
722
|
+
res.writeHead(404, {
|
|
723
|
+
"content-type": "text/plain",
|
|
724
|
+
"cache-control": "no-store",
|
|
725
|
+
"referrer-policy": "no-referrer",
|
|
726
|
+
});
|
|
727
|
+
return res.end("File is gone");
|
|
728
|
+
}
|
|
729
|
+
// Markdown reviews render on the fly; the source file stays untouched.
|
|
730
|
+
if (isMarkdown(page.file)) html = renderMarkdownPage(html, page.file);
|
|
731
|
+
sdkOptions = {
|
|
732
|
+
nonce: render.capability,
|
|
733
|
+
generation: render.generation,
|
|
734
|
+
pageKey: render.pageKey,
|
|
735
|
+
src: `http://${host}/sdk.js`,
|
|
736
|
+
};
|
|
737
|
+
extraHeaders = { "content-security-policy": fileReviewCsp(render.capability) };
|
|
738
|
+
}
|
|
739
|
+
res.writeHead(200, {
|
|
740
|
+
"content-type": MIME[".html"],
|
|
741
|
+
"cache-control": "no-store",
|
|
742
|
+
"referrer-policy": "no-referrer",
|
|
743
|
+
...extraHeaders,
|
|
744
|
+
});
|
|
745
|
+
render.documentState = "served";
|
|
746
|
+
return res.end(injectSdk(html, renderId, sdkOptions));
|
|
747
|
+
}
|
|
748
|
+
if (page.kind === "url") {
|
|
749
|
+
const stagedPrefix = "__doc_review_paste__/";
|
|
750
|
+
if (asset.startsWith(stagedPrefix)) {
|
|
751
|
+
const name = asset.slice(stagedPrefix.length);
|
|
752
|
+
if (!name || path.basename(name) !== name) {
|
|
753
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
754
|
+
return res.end("Forbidden");
|
|
755
|
+
}
|
|
756
|
+
return serveFile(res, path.join(stateDir(), "pasted", render.pageKey, name));
|
|
757
|
+
}
|
|
758
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
759
|
+
return res.end("Localhost assets load from the reviewed development server.");
|
|
760
|
+
}
|
|
761
|
+
const target = resolveAsset(page.file, asset.split("?")[0]);
|
|
762
|
+
if (!target) {
|
|
763
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
764
|
+
return res.end("Forbidden");
|
|
765
|
+
}
|
|
766
|
+
return serveFile(res, target);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// --- agent status probe: is feedback waiting? is anyone listening?
|
|
770
|
+
if (route === "/api/status" && req.method === "GET") {
|
|
771
|
+
const entryKey = targetKey(url.searchParams.get("target") || url.searchParams.get("file") || "");
|
|
772
|
+
const pending = store.batch(entryKey);
|
|
773
|
+
const listening = (pollers.get(entryKey) || new Set()).size > 0;
|
|
774
|
+
// Unsent feedback lives on every page reachable from this entry.
|
|
775
|
+
const keys = new Set([entryKey]);
|
|
776
|
+
for (const session of sessions.values()) {
|
|
777
|
+
if (session.entryKey !== entryKey) continue;
|
|
778
|
+
for (const k of session.visited) keys.add(k);
|
|
779
|
+
}
|
|
780
|
+
let comments = 0;
|
|
781
|
+
let edits = 0;
|
|
782
|
+
for (const k of keys) {
|
|
783
|
+
const page = store.page(k);
|
|
784
|
+
if (!page) continue;
|
|
785
|
+
comments += page.comments.length;
|
|
786
|
+
edits += page.edits.length;
|
|
787
|
+
}
|
|
788
|
+
return json(res, 200, {
|
|
789
|
+
status: pending ? "feedback-waiting" : "idle",
|
|
790
|
+
feedback_waiting: !!pending,
|
|
791
|
+
agent_listening: listening,
|
|
792
|
+
server_running: true,
|
|
793
|
+
unsent: { comments, edits },
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// --- page data
|
|
798
|
+
const pageMatch = route.match(/^\/api\/page\/([a-f0-9]+)(?:\/(\w+))?(?:\/(.+))?$/);
|
|
799
|
+
if (pageMatch) {
|
|
800
|
+
const [, key, action, tail] = pageMatch;
|
|
801
|
+
if (!store.page(key)) return json(res, 404, { error: "unknown page" });
|
|
802
|
+
|
|
803
|
+
if (!action && req.method === "GET") {
|
|
804
|
+
const sid = url.searchParams.get("session");
|
|
805
|
+
const session = sid ? sessions.get(sid) : null;
|
|
806
|
+
seen(session);
|
|
807
|
+
const body = pageState(key, session);
|
|
808
|
+
if (session) body.others = otherPages(session);
|
|
809
|
+
return json(res, 200, body);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// The file as it sits on disk, so the SDK can tell whether the page's
|
|
813
|
+
// own scripts have already rewritten the live DOM.
|
|
814
|
+
if (action === "raw" && req.method === "GET") {
|
|
815
|
+
if (store.page(key).kind === "url") {
|
|
816
|
+
return json(res, 400, { error: "localhost pages do not have a writable raw file" });
|
|
817
|
+
}
|
|
818
|
+
let html = "";
|
|
819
|
+
try {
|
|
820
|
+
html = fs.readFileSync(store.page(key).file, "utf8");
|
|
821
|
+
} catch {
|
|
822
|
+
return json(res, 404, { error: "file is gone" });
|
|
823
|
+
}
|
|
824
|
+
const clean = stripSdk(html);
|
|
825
|
+
// The hash is the save precondition: a later save must name the
|
|
826
|
+
// version it was based on, or it loses to a concurrent rewrite.
|
|
827
|
+
return json(res, 200, { html: clean, hash: hash(clean) });
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (action === "comment" && req.method === "POST") {
|
|
831
|
+
const body = await readBody(req);
|
|
832
|
+
const comment = {
|
|
833
|
+
id: uid("c"),
|
|
834
|
+
kind: body.kind === "element" ? "element" : "selection",
|
|
835
|
+
quote: String(body.quote || ""),
|
|
836
|
+
anchor: body.anchor || null,
|
|
837
|
+
feedback: String(body.feedback || ""),
|
|
838
|
+
createdAt: Date.now(),
|
|
839
|
+
};
|
|
840
|
+
if (!comment.feedback) return json(res, 400, { error: "empty feedback" });
|
|
841
|
+
store.addComment(key, comment);
|
|
842
|
+
return json(res, 200, { comment, page: pageState(key) });
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (action === "comment" && req.method === "DELETE") {
|
|
846
|
+
store.removeComment(key, tail);
|
|
847
|
+
return json(res, 200, { page: pageState(key) });
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (action === "comment" && req.method === "PATCH" && tail) {
|
|
851
|
+
const body = await readBody(req);
|
|
852
|
+
const feedback = String(body.feedback || "").trim();
|
|
853
|
+
if (!feedback) return json(res, 400, { error: "empty feedback" });
|
|
854
|
+
const existing = store.page(key).comments.find((comment) => comment.id === tail);
|
|
855
|
+
if (!existing) return json(res, 404, { error: "unknown comment" });
|
|
856
|
+
|
|
857
|
+
const revised = store.reviseComment(key, tail, feedback, { replacementId: uid("c") });
|
|
858
|
+
return json(res, 200, { delivery: revised.delivery, page: pageState(key) });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (action === "edit" && req.method === "POST") {
|
|
862
|
+
const body = await readBody(req);
|
|
863
|
+
const label = String(body.label || "Document");
|
|
864
|
+
const kind = body.kind === "deleted" ? "deleted" : body.kind === "moved" ? "moved" : "edited";
|
|
865
|
+
const cap = (s) => (typeof s === "string" ? s.slice(0, 4000) : undefined);
|
|
866
|
+
const stagedRoot = path.join(stateDir(), "pasted", key);
|
|
867
|
+
const stagedAssets = Array.isArray(body.staged_assets)
|
|
868
|
+
? body.staged_assets
|
|
869
|
+
.slice(0, 20)
|
|
870
|
+
.map((asset) => {
|
|
871
|
+
const id = String(asset?.id || "");
|
|
872
|
+
return {
|
|
873
|
+
id,
|
|
874
|
+
path: path.join(stagedRoot, id),
|
|
875
|
+
preview_src: String(asset?.preview_src || ""),
|
|
876
|
+
};
|
|
877
|
+
})
|
|
878
|
+
.filter((asset) => {
|
|
879
|
+
const relative = path.relative(stagedRoot, path.resolve(asset.path));
|
|
880
|
+
return asset.id && path.basename(asset.id) === asset.id && !relative.startsWith("..") && !path.isAbsolute(relative) && fs.existsSync(asset.path);
|
|
881
|
+
})
|
|
882
|
+
.map(({ path: assetPath, preview_src }) => ({ path: assetPath, preview_src }))
|
|
883
|
+
: [];
|
|
884
|
+
const extra = {
|
|
885
|
+
...(kind === "moved" ? { moved_after: cap(body.moved_after) || "", moved_before: cap(body.moved_before) || "" } : {}),
|
|
886
|
+
...(stagedAssets.length ? { staged_assets: stagedAssets } : {}),
|
|
887
|
+
};
|
|
888
|
+
store.addEdit(key, label, kind, cap(body.before), cap(body.after), cap(body.before_html), cap(body.after_html), extra);
|
|
889
|
+
return json(res, 200, { page: pageState(key) });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// File reviews keep pasted images beside the document. Localhost
|
|
893
|
+
// reviews stage them privately until the agent moves them into source.
|
|
894
|
+
if (action === "asset" && req.method === "POST") {
|
|
895
|
+
const page = store.page(key);
|
|
896
|
+
const type = String(url.searchParams.get("type") || "");
|
|
897
|
+
const ext = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }[type];
|
|
898
|
+
if (!ext) return json(res, 400, { error: `unsupported image type: ${type || "unknown"}` });
|
|
899
|
+
const bytes = await readRawBody(req);
|
|
900
|
+
if (!bytes.length) return json(res, 400, { error: "empty image" });
|
|
901
|
+
const staged = page.kind === "url";
|
|
902
|
+
const dir = staged ? path.join(stateDir(), "pasted", key) : path.join(path.dirname(page.file), "assets");
|
|
903
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
904
|
+
const base = staged
|
|
905
|
+
? "localhost"
|
|
906
|
+
: path
|
|
907
|
+
.basename(page.file)
|
|
908
|
+
.replace(/\.[^.]+$/, "")
|
|
909
|
+
.replace(/[^\w-]+/g, "-");
|
|
910
|
+
let name = "";
|
|
911
|
+
for (let n = 1; ; n += 1) {
|
|
912
|
+
name = `${base}-paste-${n}.${ext}`;
|
|
913
|
+
if (!fs.existsSync(path.join(dir, name))) break;
|
|
914
|
+
}
|
|
915
|
+
const saved = path.join(dir, name);
|
|
916
|
+
fs.writeFileSync(saved, bytes);
|
|
917
|
+
return json(res, 200, {
|
|
918
|
+
src: staged ? `__doc_review_paste__/${name}` : `assets/${name}`,
|
|
919
|
+
...(staged ? { stagedId: name } : {}),
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (action === "save" && req.method === "POST") {
|
|
924
|
+
const page = store.page(key);
|
|
925
|
+
// Rendered sources must never be overwritten with serialized browser HTML.
|
|
926
|
+
if (page.kind === "url" || isMarkdown(page.file)) {
|
|
927
|
+
return json(res, 400, { error: page.kind === "url" ? "localhost edits must be applied to app source" : "markdown pages are feedback-only" });
|
|
928
|
+
}
|
|
929
|
+
const body = await readBody(req);
|
|
930
|
+
if (typeof body.html !== "string" || !body.html.trim()) {
|
|
931
|
+
return json(res, 400, { error: "empty html" });
|
|
932
|
+
}
|
|
933
|
+
// A save based on an older version of the file must lose, not win:
|
|
934
|
+
// otherwise a debounced autosave that lands just after an agent
|
|
935
|
+
// rewrite silently overwrites the agent's work.
|
|
936
|
+
if (typeof body.baseHash === "string") {
|
|
937
|
+
let current = "";
|
|
938
|
+
try {
|
|
939
|
+
current = fs.readFileSync(page.file, "utf8");
|
|
940
|
+
} catch {
|
|
941
|
+
return json(res, 404, { error: "file is gone" });
|
|
942
|
+
}
|
|
943
|
+
if (hash(stripSdk(current)) !== body.baseHash) {
|
|
944
|
+
return json(res, 409, { error: "the file changed on disk since this edit began" });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
const clean = writePage(key, body.html);
|
|
949
|
+
return json(res, 200, { savedAt: Date.now(), hash: hash(clean) });
|
|
950
|
+
} catch (err) {
|
|
951
|
+
return json(res, 500, { error: String(err.message || err) });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (action === "revert" && req.method === "POST") {
|
|
956
|
+
const page = store.page(key);
|
|
957
|
+
if (page.kind === "url") return json(res, 400, { error: "localhost pages have no directly writable file to revert" });
|
|
958
|
+
if (!page.pristine) return json(res, 400, { error: "nothing to revert to" });
|
|
959
|
+
writePage(key, page.pristine);
|
|
960
|
+
store.clearEdits(key);
|
|
961
|
+
for (const session of sessionsForKey(key)) {
|
|
962
|
+
invalidateSessionRender(session);
|
|
963
|
+
emit(session, "reload", { key });
|
|
964
|
+
}
|
|
965
|
+
return json(res, 200, { page: pageState(key) });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (action === "send" && req.method === "POST") {
|
|
969
|
+
const body = await readBody(req);
|
|
970
|
+
const result = sendBatch(body.sessionId, body.note);
|
|
971
|
+
if (result.error) return json(res, 400, result);
|
|
972
|
+
return json(res, 200, { ok: true, page: pageState(key) });
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// --- the user is done: stop the review, release the agent
|
|
977
|
+
const endMatch = route.match(/^\/api\/session\/(\w+)\/end$/);
|
|
978
|
+
if (endMatch && req.method === "POST") {
|
|
979
|
+
const session = sessions.get(endMatch[1]);
|
|
980
|
+
if (!session) return json(res, 404, { error: "unknown session" });
|
|
981
|
+
endSession(session);
|
|
982
|
+
return json(res, 200, { ok: true });
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// --- which page a window is currently showing
|
|
986
|
+
const bootMatch = route.match(/^\/api\/session\/(\w+)\/page$/);
|
|
987
|
+
if (bootMatch && req.method === "GET") {
|
|
988
|
+
const session = sessions.get(bootMatch[1]);
|
|
989
|
+
if (!session) return json(res, 404, { error: "unknown session" });
|
|
990
|
+
seen(session);
|
|
991
|
+
return json(res, 200, {
|
|
992
|
+
key: session.activeKey,
|
|
993
|
+
generation: session.generation,
|
|
994
|
+
page: pageState(session.activeKey, session),
|
|
995
|
+
others: otherPages(session),
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// --- jump straight to a page already in this window
|
|
1000
|
+
const gotoMatch = route.match(/^\/api\/session\/(\w+)\/goto$/);
|
|
1001
|
+
if (gotoMatch && req.method === "POST") {
|
|
1002
|
+
const session = sessions.get(gotoMatch[1]);
|
|
1003
|
+
if (!session) return json(res, 404, { error: "unknown session" });
|
|
1004
|
+
seen(session);
|
|
1005
|
+
const body = await readBody(req);
|
|
1006
|
+
if (!store.page(body.key)) return json(res, 404, { error: "unknown page" });
|
|
1007
|
+
invalidateSessionRender(session);
|
|
1008
|
+
session.activeKey = body.key;
|
|
1009
|
+
session.visited.add(body.key);
|
|
1010
|
+
return json(res, 200, { key: body.key });
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// --- navigation between local files or localhost routes in one window
|
|
1014
|
+
const navMatch = route.match(/^\/api\/session\/(\w+)\/navigate$/);
|
|
1015
|
+
if (navMatch && req.method === "POST") {
|
|
1016
|
+
const session = sessions.get(navMatch[1]);
|
|
1017
|
+
if (!session) return json(res, 404, { error: "unknown session" });
|
|
1018
|
+
seen(session);
|
|
1019
|
+
const body = await readBody(req);
|
|
1020
|
+
const from = store.page(session.activeKey);
|
|
1021
|
+
if (!from) return json(res, 404, { error: "unknown page" });
|
|
1022
|
+
if (from.kind === "url") {
|
|
1023
|
+
const nextUrl = new URL(String(body.href || ""), from.url).href;
|
|
1024
|
+
const target = canonicalTarget(nextUrl);
|
|
1025
|
+
if (target.kind !== "url") return json(res, 400, { error: "not a localhost route" });
|
|
1026
|
+
await fetchLocalPage(target.value);
|
|
1027
|
+
const page = store.openUrl(target.value);
|
|
1028
|
+
invalidateSessionRender(session);
|
|
1029
|
+
session.activeKey = page.key;
|
|
1030
|
+
session.visited.add(page.key);
|
|
1031
|
+
return json(res, 200, { key: page.key, page: pageState(page.key) });
|
|
1032
|
+
}
|
|
1033
|
+
const targetFile = resolveAsset(from.file, String(body.href || "").split(/[?#]/)[0]);
|
|
1034
|
+
if (!targetFile || !fs.existsSync(targetFile) || !/\.(x?html?|md|markdown)$/i.test(targetFile)) {
|
|
1035
|
+
return json(res, 400, { error: "not a local html or markdown page" });
|
|
1036
|
+
}
|
|
1037
|
+
const html = fs.readFileSync(targetFile, "utf8");
|
|
1038
|
+
const page = store.openPage(targetFile, stripSdk(html));
|
|
1039
|
+
lastWritten.set(page.key, hash(stripSdk(html)));
|
|
1040
|
+
watchPage(page.key);
|
|
1041
|
+
invalidateSessionRender(session);
|
|
1042
|
+
session.activeKey = page.key;
|
|
1043
|
+
session.visited.add(page.key);
|
|
1044
|
+
return json(res, 200, { key: page.key, page: pageState(page.key) });
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// --- server-sent events for one window
|
|
1048
|
+
if (route.startsWith("/events/")) {
|
|
1049
|
+
const session = sessions.get(route.slice("/events/".length));
|
|
1050
|
+
if (!session) {
|
|
1051
|
+
res.writeHead(404);
|
|
1052
|
+
return res.end();
|
|
1053
|
+
}
|
|
1054
|
+
res.writeHead(200, {
|
|
1055
|
+
"content-type": "text/event-stream",
|
|
1056
|
+
"cache-control": "no-cache",
|
|
1057
|
+
connection: "keep-alive",
|
|
1058
|
+
});
|
|
1059
|
+
res.write(": open\n\n");
|
|
1060
|
+
session.clients.add(res);
|
|
1061
|
+
seen(session);
|
|
1062
|
+
emit(session, "agent", { state: agentState(session.entryKey) });
|
|
1063
|
+
const beat = setInterval(() => res.write(": beat\n\n"), POLL_HEARTBEAT_MS);
|
|
1064
|
+
sseResponses.set(res, beat);
|
|
1065
|
+
req.on("close", () => {
|
|
1066
|
+
clearInterval(beat);
|
|
1067
|
+
sseResponses.delete(res);
|
|
1068
|
+
session.clients.delete(res);
|
|
1069
|
+
seen(session);
|
|
1070
|
+
});
|
|
1071
|
+
return undefined;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// --- the agent long-poll
|
|
1075
|
+
if (route === "/api/poll") {
|
|
1076
|
+
const target = url.searchParams.get("target") || url.searchParams.get("file") || "";
|
|
1077
|
+
const entryKey = targetKey(target);
|
|
1078
|
+
const ackId = url.searchParams.get("ack");
|
|
1079
|
+
if (ackId !== null) ack(entryKey, ackId);
|
|
1080
|
+
|
|
1081
|
+
const pending = store.batch(entryKey);
|
|
1082
|
+
if (pending) {
|
|
1083
|
+
const delivered = store.markBatchDelivered(entryKey);
|
|
1084
|
+
broadcastAgent(entryKey);
|
|
1085
|
+
return json(res, 200, delivered.batch);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1089
|
+
res.write(" ");
|
|
1090
|
+
const set = pollers.get(entryKey) || new Set();
|
|
1091
|
+
pollers.set(entryKey, set);
|
|
1092
|
+
const poller = {
|
|
1093
|
+
res,
|
|
1094
|
+
timer: setInterval(() => res.write(" "), POLL_HEARTBEAT_MS),
|
|
1095
|
+
};
|
|
1096
|
+
set.add(poller);
|
|
1097
|
+
broadcastAgent(entryKey);
|
|
1098
|
+
req.on("close", () => {
|
|
1099
|
+
clearInterval(poller.timer);
|
|
1100
|
+
set.delete(poller);
|
|
1101
|
+
broadcastAgent(entryKey);
|
|
1102
|
+
});
|
|
1103
|
+
return undefined;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
1107
|
+
return res.end("Not found");
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return json(res, 500, { error: String(err.message || err) });
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
server.on("connection", (socket) => {
|
|
1113
|
+
sockets.add(socket);
|
|
1114
|
+
socket.on("close", () => sockets.delete(socket));
|
|
1115
|
+
});
|
|
1116
|
+
server.on("listening", () => {
|
|
1117
|
+
everListened = true;
|
|
1118
|
+
serverClosed = false;
|
|
1119
|
+
});
|
|
1120
|
+
server.on("close", () => {
|
|
1121
|
+
serverClosed = true;
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
const sweep = setInterval(() => {
|
|
1125
|
+
const now = Date.now();
|
|
1126
|
+
|
|
1127
|
+
// A window with no SSE client for a while is closed; forget its session.
|
|
1128
|
+
for (const [id, session] of sessions) {
|
|
1129
|
+
if (session.clients.size === 0 && now - session.lastSeen > SESSION_TTL_MS) {
|
|
1130
|
+
invalidateSessionRender(session);
|
|
1131
|
+
sessions.delete(id);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
for (const [renderId, render] of renders) {
|
|
1136
|
+
if (now - render.createdAt <= renderTtlMs) continue;
|
|
1137
|
+
if (render.documentState !== "served") expireRender(renderId);
|
|
1138
|
+
else render.capability = null;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Stop watching files no remaining session can see.
|
|
1142
|
+
for (const [key, entry] of watched) {
|
|
1143
|
+
const referenced = [...sessions.values()].some((s) => s.visited.has(key));
|
|
1144
|
+
if (!referenced) {
|
|
1145
|
+
fs.unwatchFile(entry.file);
|
|
1146
|
+
watched.delete(key);
|
|
1147
|
+
lastWritten.delete(key);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Busy means a connected browser or a listening agent — a session record
|
|
1152
|
+
// alone must not keep the process alive forever.
|
|
1153
|
+
const busy = [...sessions.values()].some((s) => s.clients.size > 0) || [...pollers.values()].some((s) => s.size > 0);
|
|
1154
|
+
if (!busy && now - lastActivity > IDLE_SHUTDOWN_MS) {
|
|
1155
|
+
void dispose().catch((err) => {
|
|
1156
|
+
console.error(`doc-review server shutdown failed: ${err.message}`);
|
|
1157
|
+
process.exitCode = 1;
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}, 60000);
|
|
1161
|
+
sweep.unref();
|
|
1162
|
+
|
|
1163
|
+
let disposePromise = null;
|
|
1164
|
+
const dispose = () => {
|
|
1165
|
+
if (disposePromise) return disposePromise;
|
|
1166
|
+
disposePromise = (async () => {
|
|
1167
|
+
clearInterval(sweep);
|
|
1168
|
+
for (const entry of watched.values()) fs.unwatchFile(entry.file);
|
|
1169
|
+
watched.clear();
|
|
1170
|
+
lastWritten.clear();
|
|
1171
|
+
|
|
1172
|
+
for (const set of pollers.values()) {
|
|
1173
|
+
for (const poller of set) {
|
|
1174
|
+
clearInterval(poller.timer);
|
|
1175
|
+
if (!poller.res.writableEnded) poller.res.end();
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
pollers.clear();
|
|
1179
|
+
|
|
1180
|
+
for (const [res, timer] of sseResponses) {
|
|
1181
|
+
clearInterval(timer);
|
|
1182
|
+
if (!res.writableEnded) res.end();
|
|
1183
|
+
}
|
|
1184
|
+
sseResponses.clear();
|
|
1185
|
+
for (const session of sessions.values()) session.clients.clear();
|
|
1186
|
+
sessions.clear();
|
|
1187
|
+
renders.clear();
|
|
1188
|
+
|
|
1189
|
+
try {
|
|
1190
|
+
// A caller may dispose immediately after server.listen(), before the
|
|
1191
|
+
// listening event flips server.listening. Give that pending transition
|
|
1192
|
+
// one turn so it can be closed instead of escaping cleanup.
|
|
1193
|
+
if (!server.listening && !everListened) {
|
|
1194
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1195
|
+
}
|
|
1196
|
+
if (server.listening) {
|
|
1197
|
+
const closed = new Promise((resolve, reject) => {
|
|
1198
|
+
server.close((err) => {
|
|
1199
|
+
if (err && err.code !== "ERR_SERVER_NOT_RUNNING") reject(err);
|
|
1200
|
+
else resolve();
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
server.closeIdleConnections?.();
|
|
1204
|
+
for (const socket of sockets) socket.end();
|
|
1205
|
+
server.closeAllConnections?.();
|
|
1206
|
+
await closed;
|
|
1207
|
+
} else {
|
|
1208
|
+
for (const socket of sockets) socket.destroy();
|
|
1209
|
+
if (everListened && !serverClosed) {
|
|
1210
|
+
await new Promise((resolve) => server.once("close", resolve));
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
} finally {
|
|
1214
|
+
sockets.clear();
|
|
1215
|
+
if (owner) {
|
|
1216
|
+
removeOwnedServerRecord(owner);
|
|
1217
|
+
releaseServerLock(owner);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
})();
|
|
1221
|
+
return disposePromise;
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
return { server, store, token, instanceId, dispose };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export async function start(port = 0, options = {}) {
|
|
1228
|
+
const owner = acquireServerLock(options.lock);
|
|
1229
|
+
let review;
|
|
1230
|
+
try {
|
|
1231
|
+
review = createServer({
|
|
1232
|
+
store: options.store,
|
|
1233
|
+
storeOptions: options.storeOptions,
|
|
1234
|
+
owner,
|
|
1235
|
+
renderTtlMs: options.renderTtlMs,
|
|
1236
|
+
});
|
|
1237
|
+
await new Promise((resolve, reject) => {
|
|
1238
|
+
const onError = (err) => reject(err);
|
|
1239
|
+
review.server.once("error", onError);
|
|
1240
|
+
review.server.listen(port, "127.0.0.1", () => {
|
|
1241
|
+
review.server.off("error", onError);
|
|
1242
|
+
resolve();
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
const actual = review.server.address().port;
|
|
1246
|
+
ensureStateDir();
|
|
1247
|
+
atomicWrite(
|
|
1248
|
+
serverPath(),
|
|
1249
|
+
JSON.stringify({
|
|
1250
|
+
port: actual,
|
|
1251
|
+
pid: process.pid,
|
|
1252
|
+
instance_id: owner.instance_id,
|
|
1253
|
+
token: review.token,
|
|
1254
|
+
protocol: SERVER_PROTOCOL,
|
|
1255
|
+
})
|
|
1256
|
+
);
|
|
1257
|
+
try {
|
|
1258
|
+
fs.chmodSync(serverPath(), 0o600);
|
|
1259
|
+
} catch (err) {
|
|
1260
|
+
if (process.platform !== "win32") throw err;
|
|
1261
|
+
}
|
|
1262
|
+
return { ...review, port: actual };
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
if (review) await review.dispose();
|
|
1265
|
+
else releaseServerLock(owner);
|
|
1266
|
+
throw err;
|
|
1267
|
+
}
|
|
1268
|
+
}
|