@erdemtuna/doc-review 0.9.0 → 0.10.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/README.md +52 -227
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +2255 -0
- package/lib/chrome-session.js +76 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comparison-view.js +380 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +69 -0
- package/lib/feedback-controller.js +106 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +210 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +249 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +300 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/view-identity.js +112 -0
- package/package.json +24 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/SKILL.md +0 -0
- /package/{src → lib}/chrome.css +0 -0
- /package/{src → lib}/chrome.html +0 -0
- /package/{src → lib}/document-trust.js +0 -0
package/lib/icons.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Generated by scripts/generate-icons.js from lucide-static. Do not edit.
|
|
2
|
+
export const ICON_NODES = Object.freeze({
|
|
3
|
+
"eye": [
|
|
4
|
+
[
|
|
5
|
+
"path",
|
|
6
|
+
{
|
|
7
|
+
"d": "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
[
|
|
11
|
+
"circle",
|
|
12
|
+
{
|
|
13
|
+
"cx": "12",
|
|
14
|
+
"cy": "12",
|
|
15
|
+
"r": "3"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
],
|
|
19
|
+
"pencil": [
|
|
20
|
+
[
|
|
21
|
+
"path",
|
|
22
|
+
{
|
|
23
|
+
"d": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
"path",
|
|
28
|
+
{
|
|
29
|
+
"d": "m15 5 4 4"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
],
|
|
33
|
+
"chevronDown": [
|
|
34
|
+
[
|
|
35
|
+
"path",
|
|
36
|
+
{
|
|
37
|
+
"d": "m6 9 6 6 6-6"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
],
|
|
41
|
+
"messageSquarePlus": [
|
|
42
|
+
[
|
|
43
|
+
"path",
|
|
44
|
+
{
|
|
45
|
+
"d": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
"path",
|
|
50
|
+
{
|
|
51
|
+
"d": "M12 8v6"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"path",
|
|
56
|
+
{
|
|
57
|
+
"d": "M9 11h6"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
],
|
|
61
|
+
"messages": [
|
|
62
|
+
[
|
|
63
|
+
"path",
|
|
64
|
+
{
|
|
65
|
+
"d": "M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
"path",
|
|
70
|
+
{
|
|
71
|
+
"d": "M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
],
|
|
75
|
+
"send": [
|
|
76
|
+
[
|
|
77
|
+
"path",
|
|
78
|
+
{
|
|
79
|
+
"d": "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
"path",
|
|
84
|
+
{
|
|
85
|
+
"d": "m21.854 2.147-10.94 10.939"
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
],
|
|
89
|
+
"trash": [
|
|
90
|
+
[
|
|
91
|
+
"path",
|
|
92
|
+
{
|
|
93
|
+
"d": "M10 11v6"
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
"path",
|
|
98
|
+
{
|
|
99
|
+
"d": "M14 11v6"
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
"path",
|
|
104
|
+
{
|
|
105
|
+
"d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
"path",
|
|
110
|
+
{
|
|
111
|
+
"d": "M3 6h18"
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
"path",
|
|
116
|
+
{
|
|
117
|
+
"d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
],
|
|
121
|
+
"moreHorizontal": [
|
|
122
|
+
[
|
|
123
|
+
"circle",
|
|
124
|
+
{
|
|
125
|
+
"cx": "12",
|
|
126
|
+
"cy": "12",
|
|
127
|
+
"r": "1"
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
"circle",
|
|
132
|
+
{
|
|
133
|
+
"cx": "19",
|
|
134
|
+
"cy": "12",
|
|
135
|
+
"r": "1"
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
"circle",
|
|
140
|
+
{
|
|
141
|
+
"cx": "5",
|
|
142
|
+
"cy": "12",
|
|
143
|
+
"r": "1"
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
],
|
|
147
|
+
"x": [
|
|
148
|
+
[
|
|
149
|
+
"path",
|
|
150
|
+
{
|
|
151
|
+
"d": "M18 6 6 18"
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
"path",
|
|
156
|
+
{
|
|
157
|
+
"d": "m6 6 12 12"
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
],
|
|
161
|
+
"check": [
|
|
162
|
+
[
|
|
163
|
+
"path",
|
|
164
|
+
{
|
|
165
|
+
"d": "M20 6 9 17l-5-5"
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
],
|
|
169
|
+
"sun": [
|
|
170
|
+
[
|
|
171
|
+
"circle",
|
|
172
|
+
{
|
|
173
|
+
"cx": "12",
|
|
174
|
+
"cy": "12",
|
|
175
|
+
"r": "4"
|
|
176
|
+
}
|
|
177
|
+
],
|
|
178
|
+
[
|
|
179
|
+
"path",
|
|
180
|
+
{
|
|
181
|
+
"d": "M12 2v2"
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
"path",
|
|
186
|
+
{
|
|
187
|
+
"d": "M12 20v2"
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
[
|
|
191
|
+
"path",
|
|
192
|
+
{
|
|
193
|
+
"d": "m4.93 4.93 1.41 1.41"
|
|
194
|
+
}
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"path",
|
|
198
|
+
{
|
|
199
|
+
"d": "m17.66 17.66 1.41 1.41"
|
|
200
|
+
}
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
"path",
|
|
204
|
+
{
|
|
205
|
+
"d": "M2 12h2"
|
|
206
|
+
}
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
"path",
|
|
210
|
+
{
|
|
211
|
+
"d": "M20 12h2"
|
|
212
|
+
}
|
|
213
|
+
],
|
|
214
|
+
[
|
|
215
|
+
"path",
|
|
216
|
+
{
|
|
217
|
+
"d": "m6.34 17.66-1.41 1.41"
|
|
218
|
+
}
|
|
219
|
+
],
|
|
220
|
+
[
|
|
221
|
+
"path",
|
|
222
|
+
{
|
|
223
|
+
"d": "m19.07 4.93-1.41 1.41"
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
],
|
|
227
|
+
"moon": [
|
|
228
|
+
[
|
|
229
|
+
"path",
|
|
230
|
+
{
|
|
231
|
+
"d": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
]
|
|
235
|
+
});
|
|
236
|
+
const escape = (value) => String(value).replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[char]);
|
|
237
|
+
export function iconMarkup(name, { title = "", className = "icon", size = 16 } = {}) {
|
|
238
|
+
const nodes = ICON_NODES[name];
|
|
239
|
+
if (!nodes)
|
|
240
|
+
throw new Error(`Unknown icon: ${name}`);
|
|
241
|
+
const body = nodes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${escape(value)}"`).join(" ")}></${tag}>`).join("");
|
|
242
|
+
const label = title ? ` role="img" aria-label="${escape(title)}"` : ' aria-hidden="true"';
|
|
243
|
+
return `<svg class="${escape(className)}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${label}>${body}</svg>`;
|
|
244
|
+
}
|
|
245
|
+
export function createIcon(name, options = {}) {
|
|
246
|
+
const template = document.createElement("template");
|
|
247
|
+
template.innerHTML = iconMarkup(name, options);
|
|
248
|
+
return template.content.firstElementChild;
|
|
249
|
+
}
|
package/{src → lib}/markdown.js
RENAMED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { marked, Renderer } from "marked";
|
|
3
|
-
|
|
4
3
|
export const isMarkdown = (file) => /\.(md|markdown)$/i.test(file);
|
|
5
|
-
|
|
6
4
|
/**
|
|
7
5
|
* Readable defaults for rendered Markdown. This HTML is a viewing surface
|
|
8
6
|
* only — it is never written back to disk, so the styling can be opinionated.
|
|
@@ -35,45 +33,46 @@ const STYLE = `
|
|
|
35
33
|
img { max-width: 100%; height: auto; }
|
|
36
34
|
hr { border: none; border-top: 1px solid #eceae3; margin: 2.2em 0; }
|
|
37
35
|
`;
|
|
38
|
-
|
|
39
|
-
const escapeHtml = (value) =>
|
|
40
|
-
String(value)
|
|
36
|
+
const escapeHtml = (value) => String(value)
|
|
41
37
|
.replace(/&/g, "&")
|
|
42
38
|
.replace(/</g, "<")
|
|
43
39
|
.replace(/>/g, ">");
|
|
44
|
-
|
|
45
40
|
function safeUrl(value, { image = false } = {}) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
const url = String(value || "").trim();
|
|
42
|
+
const probe = url.replace(/[\u0000-\u0020\u007f]+/g, "");
|
|
43
|
+
const match = /^([a-z][a-z0-9+.-]*):/i.exec(probe);
|
|
44
|
+
if (!match)
|
|
45
|
+
return url;
|
|
46
|
+
const scheme = match[1].toLowerCase();
|
|
47
|
+
if (scheme === "http" || scheme === "https")
|
|
48
|
+
return url;
|
|
49
|
+
if (!image && scheme === "mailto")
|
|
50
|
+
return url;
|
|
51
|
+
if (image && /^data:image\/(?:avif|gif|jpe?g|png|webp);base64,/i.test(probe))
|
|
52
|
+
return url;
|
|
53
|
+
return null;
|
|
55
54
|
}
|
|
56
|
-
|
|
57
55
|
// Markdown can contain arbitrary HTML. Show that source as text so event
|
|
58
56
|
// handlers, embeds, SVG, and future browser features can never become active.
|
|
59
57
|
const INERT_RENDERER = new Renderer();
|
|
60
58
|
INERT_RENDERER.html = ({ text }) => escapeHtml(text);
|
|
61
59
|
INERT_RENDERER.link = function (token) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
const href = safeUrl(token.href);
|
|
61
|
+
if (!href)
|
|
62
|
+
return this.parser.parseInline(token.tokens);
|
|
63
|
+
return Renderer.prototype.link.call(this, { ...token, href });
|
|
65
64
|
};
|
|
66
65
|
INERT_RENDERER.image = function (token) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
const href = safeUrl(token.href, { image: true });
|
|
67
|
+
if (!href)
|
|
68
|
+
return escapeHtml(token.text || "");
|
|
69
|
+
return Renderer.prototype.image.call(this, { ...token, href });
|
|
70
70
|
};
|
|
71
|
-
|
|
72
71
|
/** Render a Markdown file into a standalone review page. */
|
|
73
72
|
export function renderMarkdownPage(mdText, file) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
const body = marked.parse(mdText, { gfm: true, async: false, renderer: INERT_RENDERER });
|
|
74
|
+
const title = path.basename(file);
|
|
75
|
+
return `<!DOCTYPE html>
|
|
77
76
|
<html lang="en">
|
|
78
77
|
<head>
|
|
79
78
|
<meta charset="utf-8">
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
// Bump this when the CLI and detached server no longer share the same request
|
|
6
|
+
// contract. A new CLI must not silently reuse an older background server.
|
|
7
|
+
export const SERVER_PROTOCOL = 17;
|
|
8
|
+
export function serverProtocolMatches(protocol) {
|
|
9
|
+
return Number(protocol) === SERVER_PROTOCOL;
|
|
10
|
+
}
|
|
11
|
+
export function stateDir() {
|
|
12
|
+
const override = process.env.DOC_REVIEW_STATE_DIR;
|
|
13
|
+
return override ? path.resolve(override) : path.join(homedir(), ".doc-review");
|
|
14
|
+
}
|
|
15
|
+
export function statePath() {
|
|
16
|
+
return path.join(stateDir(), "state.json");
|
|
17
|
+
}
|
|
18
|
+
export function serverPath() {
|
|
19
|
+
return path.join(stateDir(), "server.json");
|
|
20
|
+
}
|
|
21
|
+
export function serverLockPath() {
|
|
22
|
+
return path.join(stateDir(), "server.lock");
|
|
23
|
+
}
|
|
24
|
+
export function ensureStateDir() {
|
|
25
|
+
// Comments and the server token live here; keep it private to the user.
|
|
26
|
+
fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
29
|
+
/**
|
|
30
|
+
* Return a canonical loopback URL, or null when the input is a filesystem path.
|
|
31
|
+
* HTTP-looking non-loopback targets fail loudly instead of becoming odd paths.
|
|
32
|
+
*/
|
|
33
|
+
export function localUrl(target) {
|
|
34
|
+
const value = String(target || "");
|
|
35
|
+
if (!/^https?:\/\//i.test(value))
|
|
36
|
+
return null;
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = new URL(value);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`Invalid localhost URL: ${value}`);
|
|
43
|
+
}
|
|
44
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname)) {
|
|
45
|
+
throw new Error("doc-review URL support is limited to localhost, 127.0.0.1, and [::1].");
|
|
46
|
+
}
|
|
47
|
+
if (parsed.username || parsed.password) {
|
|
48
|
+
throw new Error("Localhost review URLs cannot contain credentials.");
|
|
49
|
+
}
|
|
50
|
+
parsed.hash = "";
|
|
51
|
+
return parsed.href;
|
|
52
|
+
}
|
|
53
|
+
/** Canonical identity for a page: the real path of the file on disk. */
|
|
54
|
+
export function pageKey(file) {
|
|
55
|
+
let real = path.resolve(file);
|
|
56
|
+
try {
|
|
57
|
+
real = fs.realpathSync(real);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// File may not exist yet; the resolved path is still a stable identity.
|
|
61
|
+
}
|
|
62
|
+
return createHash("sha256").update(real).digest("hex").slice(0, 16);
|
|
63
|
+
}
|
|
64
|
+
/** Stable identity for either an existing file target or a localhost URL. */
|
|
65
|
+
export function targetKey(target) {
|
|
66
|
+
const url = localUrl(target);
|
|
67
|
+
if (!url)
|
|
68
|
+
return pageKey(target);
|
|
69
|
+
return createHash("sha256").update(`url:${url}`).digest("hex").slice(0, 16);
|
|
70
|
+
}
|
|
71
|
+
export function canonicalTarget(target) {
|
|
72
|
+
const url = localUrl(target);
|
|
73
|
+
return url ? { kind: "url", value: url } : { kind: "file", value: realFile(target) };
|
|
74
|
+
}
|
|
75
|
+
export function realFile(file) {
|
|
76
|
+
const resolved = path.resolve(file);
|
|
77
|
+
try {
|
|
78
|
+
return fs.realpathSync(resolved);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
export const DEFAULT_POLL_SECONDS = 12 * 60 * 60;
|
|
4
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
5
|
+
const clock = {
|
|
6
|
+
now: () => performance.now(),
|
|
7
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
8
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
9
|
+
};
|
|
10
|
+
function codedError(message, code) {
|
|
11
|
+
return Object.assign(new Error(message), { code });
|
|
12
|
+
}
|
|
13
|
+
export function createDeadline(seconds = DEFAULT_POLL_SECONDS, time = clock) {
|
|
14
|
+
const end = time.now() + seconds * 1000;
|
|
15
|
+
const remaining = () => Math.max(0, end - time.now());
|
|
16
|
+
const check = () => {
|
|
17
|
+
if (remaining() <= 0)
|
|
18
|
+
throw codedError("Polling deadline reached.", "POLL_DEADLINE");
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
seconds,
|
|
22
|
+
time,
|
|
23
|
+
remaining,
|
|
24
|
+
check,
|
|
25
|
+
async sleep(ms) {
|
|
26
|
+
check();
|
|
27
|
+
await new Promise((resolve) => time.setTimeout(resolve, Math.min(ms, remaining(), MAX_TIMER_MS)));
|
|
28
|
+
check();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function isRecoverableTransportError(err) {
|
|
33
|
+
return new Set([
|
|
34
|
+
"ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EHOSTUNREACH",
|
|
35
|
+
"ENETUNREACH", "ENETDOWN", "ECONNABORTED", "ERR_STREAM_PREMATURE_CLOSE",
|
|
36
|
+
"SERVER_START_PENDING",
|
|
37
|
+
]).has(err?.code);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* An absolute response deadline, not a socket-idle timeout: heartbeats and
|
|
41
|
+
* partially received JSON must not extend the caller's waiting budget.
|
|
42
|
+
*/
|
|
43
|
+
export function requestRaw(server, options, body, { timeoutMs = options.timeout, time = clock, signal, timeoutCode = "ETIMEDOUT", makeRequest = http.request, } = {}) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let req;
|
|
46
|
+
let res;
|
|
47
|
+
let timer;
|
|
48
|
+
let done = false;
|
|
49
|
+
let ended = false;
|
|
50
|
+
let raw = "";
|
|
51
|
+
const expires = timeoutMs == null ? null : time.now() + timeoutMs;
|
|
52
|
+
const premature = () => codedError("The server closed an incomplete response.", "ERR_STREAM_PREMATURE_CLOSE");
|
|
53
|
+
const cleanupResponse = () => {
|
|
54
|
+
res?.removeListener("data", onData);
|
|
55
|
+
res?.removeListener("end", onEnd);
|
|
56
|
+
res?.removeListener("aborted", onAborted);
|
|
57
|
+
};
|
|
58
|
+
const settle = (err, value) => {
|
|
59
|
+
if (done)
|
|
60
|
+
return;
|
|
61
|
+
done = true;
|
|
62
|
+
time.clearTimeout(timer);
|
|
63
|
+
signal?.removeEventListener("abort", onAbort);
|
|
64
|
+
cleanupResponse();
|
|
65
|
+
// Keep error handlers until close: destroying a socket may emit one last
|
|
66
|
+
// error asynchronously. Close removes the remaining owned listeners.
|
|
67
|
+
res?.destroy();
|
|
68
|
+
req?.destroy();
|
|
69
|
+
if (err)
|
|
70
|
+
reject(err);
|
|
71
|
+
else
|
|
72
|
+
resolve(value);
|
|
73
|
+
};
|
|
74
|
+
const onError = (err) => settle(err);
|
|
75
|
+
const onData = (chunk) => { raw += chunk; };
|
|
76
|
+
const onEnd = () => {
|
|
77
|
+
ended = true;
|
|
78
|
+
if (!res.complete)
|
|
79
|
+
return settle(premature());
|
|
80
|
+
settle(null, { status: res.statusCode, raw });
|
|
81
|
+
};
|
|
82
|
+
const onAborted = () => settle(premature());
|
|
83
|
+
const onResponseClose = () => {
|
|
84
|
+
if (!ended)
|
|
85
|
+
settle(premature());
|
|
86
|
+
cleanupResponse();
|
|
87
|
+
res.removeListener("error", onError);
|
|
88
|
+
res.removeListener("close", onResponseClose);
|
|
89
|
+
};
|
|
90
|
+
const onRequestClose = () => {
|
|
91
|
+
if (!done)
|
|
92
|
+
settle(premature());
|
|
93
|
+
req.removeListener("error", onError);
|
|
94
|
+
req.removeListener("close", onRequestClose);
|
|
95
|
+
req.removeListener("response", onResponse);
|
|
96
|
+
};
|
|
97
|
+
const onAbort = () => settle(codedError("Polling cancelled.", "ABORT_ERR"));
|
|
98
|
+
const armTimer = () => {
|
|
99
|
+
const left = expires - time.now();
|
|
100
|
+
if (left <= 0)
|
|
101
|
+
return settle(codedError("Polling request timed out.", timeoutCode));
|
|
102
|
+
timer = time.setTimeout(armTimer, Math.min(left, MAX_TIMER_MS));
|
|
103
|
+
};
|
|
104
|
+
const onResponse = (response) => {
|
|
105
|
+
res = response;
|
|
106
|
+
if (done) {
|
|
107
|
+
res.destroy();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
res.setEncoding("utf8");
|
|
111
|
+
res.on("data", onData);
|
|
112
|
+
res.on("end", onEnd);
|
|
113
|
+
res.on("error", onError);
|
|
114
|
+
res.on("aborted", onAborted);
|
|
115
|
+
res.on("close", onResponseClose);
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
if (signal?.aborted)
|
|
119
|
+
return onAbort();
|
|
120
|
+
const port = typeof server === "number" ? server : server.port;
|
|
121
|
+
const token = typeof server === "number" ? "" : server.token || "";
|
|
122
|
+
const { timeout: _timeout, ...requestOptions } = options;
|
|
123
|
+
req = makeRequest({
|
|
124
|
+
host: "127.0.0.1",
|
|
125
|
+
port,
|
|
126
|
+
...requestOptions,
|
|
127
|
+
headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
|
|
128
|
+
});
|
|
129
|
+
req.on("response", onResponse);
|
|
130
|
+
req.on("error", onError);
|
|
131
|
+
req.on("close", onRequestClose);
|
|
132
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
133
|
+
if (expires !== null)
|
|
134
|
+
armTimer();
|
|
135
|
+
if (done)
|
|
136
|
+
return;
|
|
137
|
+
if (body)
|
|
138
|
+
req.write(JSON.stringify(body));
|
|
139
|
+
req.end();
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
settle(err);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
export function parseServerResponse(response) {
|
|
147
|
+
if (response.status !== 200) {
|
|
148
|
+
let detail = "";
|
|
149
|
+
try {
|
|
150
|
+
detail = JSON.parse(response.raw).error || "";
|
|
151
|
+
}
|
|
152
|
+
catch { /* HTTP status is sufficient. */ }
|
|
153
|
+
throw codedError(`Doc-review server returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`, "SERVER_RESPONSE_ERROR");
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(response.raw);
|
|
157
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
158
|
+
throw new Error();
|
|
159
|
+
return parsed;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw codedError("Malformed response from the doc-review server. End the review and restart the server.", "SERVER_RESPONSE_INVALID");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export async function pollOnce(server, target, ackId, deadline) {
|
|
166
|
+
deadline.check();
|
|
167
|
+
const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
|
|
168
|
+
const response = await requestRaw(server, {
|
|
169
|
+
method: "GET",
|
|
170
|
+
path: `/api/poll?${query}`,
|
|
171
|
+
}, undefined, {
|
|
172
|
+
timeoutMs: deadline.remaining(),
|
|
173
|
+
time: deadline.time,
|
|
174
|
+
timeoutCode: "POLL_DEADLINE",
|
|
175
|
+
});
|
|
176
|
+
// Graceful server disposal ends the space heartbeats without a JSON payload.
|
|
177
|
+
// HTTP is complete, but this poll was interrupted just like a dropped socket.
|
|
178
|
+
if (response.status === 200 && /^ +$/.test(response.raw)) {
|
|
179
|
+
throw codedError("The server ended the poll before sending feedback.", "ERR_STREAM_PREMATURE_CLOSE");
|
|
180
|
+
}
|
|
181
|
+
const batch = parseServerResponse(response);
|
|
182
|
+
if (!["feedback", "closed", "timeout"].includes(batch.status) ||
|
|
183
|
+
(batch.status === "feedback" && (typeof batch.batch_id !== "string" || !batch.batch_id || !Array.isArray(batch.pages)))) {
|
|
184
|
+
throw codedError("Malformed polling response from the doc-review server.", "SERVER_RESPONSE_INVALID");
|
|
185
|
+
}
|
|
186
|
+
return batch;
|
|
187
|
+
}
|
|
188
|
+
export async function pollUntilDeadline({ target, ackId = "", deadline = createDeadline(), discover, poll = pollOnce, diagnostic = () => { }, }) {
|
|
189
|
+
let failures = 0;
|
|
190
|
+
for (;;) {
|
|
191
|
+
try {
|
|
192
|
+
deadline.check();
|
|
193
|
+
const server = await discover(deadline);
|
|
194
|
+
deadline.check();
|
|
195
|
+
// An uncertain send must repeat the supplied receipt, never a newer ID.
|
|
196
|
+
const batch = await poll(server, target, ackId, deadline);
|
|
197
|
+
deadline.check();
|
|
198
|
+
return batch;
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err.code === "POLL_DEADLINE" || deadline.remaining() <= 0) {
|
|
202
|
+
return {
|
|
203
|
+
status: "timeout",
|
|
204
|
+
waited_seconds: deadline.seconds,
|
|
205
|
+
next_step: "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!isRecoverableTransportError(err))
|
|
209
|
+
throw err;
|
|
210
|
+
diagnostic(`Lost the connection (${err.message}); retrying.\n`);
|
|
211
|
+
try {
|
|
212
|
+
await deadline.sleep(Math.min(250 * 2 ** Math.min(failures++, 5), 5000));
|
|
213
|
+
}
|
|
214
|
+
catch (sleepError) {
|
|
215
|
+
if (sleepError.code !== "POLL_DEADLINE")
|
|
216
|
+
throw sleepError;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|