dsh-figmark 1.2.0 → 1.2.2
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/CHANGELOG.md +33 -0
- package/PUBLISHING.md +2 -0
- package/README.md +62 -13
- package/lib/client.js +14572 -4498
- package/lib/client.pdf.js +48497 -48057
- package/lib/index.js +2005 -398
- package/market/index.json +12 -4
- package/package.json +7 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { createServer, request } from "node:http";
|
|
5
|
+
import { connect } from "node:net";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
3
7
|
import { createHash } from "node:crypto";
|
|
4
8
|
//#region src/host/guidance.ts
|
|
5
9
|
/**
|
|
@@ -22,31 +26,51 @@ const GUIDANCE = [
|
|
|
22
26
|
"用户可能在文档预览里直接标注(箭头/框选/椭圆/手绘/单点),发送给你的消息以【附图标注】开头,正文就是**逐条标注清单**,不需要再去读任何附加文件(标注文件是投递用的临时件,发出即删)。收到时按下列纪律处理:",
|
|
23
27
|
"",
|
|
24
28
|
"1. 逐条读消息正文的清单:每条形如 `① 箭头 (10,10) → (40,30),批注:标号指错了`。括号里是图面像素坐标,原点在左上角;框选与圈选给的是**左上角-右下角**,手绘线给的是它画在哪个框里。`批注:` 之后是用户写的**详细说明**——那是这条标注唯一的内容,按它判断意图。有的条目在形状后面紧跟一个圆括号,写着该处落在哪个元素上(SVG 给元素标题/`id`/元素文字,HTML 给 `selector`/元素文字),定位时优先用它。",
|
|
29
|
+
"1a. 若某条写的是**元素**而不是形状(`① 元素 …`),那条标注指的不是\"在这里画个框\",而是**那一个元素本身**:圆括号里给的是选中时抽到的名字(元素标题 / 元素文字 / 选择器 / `<g id=\"patch_2\">` 这样的开标签——最后一种里唯一能搜的就是那个 `id`),按它去源文件里找到那一个元素,改的就是它,不是它周围的一块区域。这类标注没有可挪动的形状,用户说的\"这里\"就是那个元素。",
|
|
25
30
|
"2. **PDF 是整份文档一份标注**:每条标注都带 `第 N 页`,坐标是那一页自己的(原点在该页左上角)。看每条标注前先确认它的页码。",
|
|
26
31
|
"3. 若标注超出原图边界,标注图会把越界的那圈纸也画出来,并用**灰色虚线**框住原图真正的边缘:虚线**里面**是原图,**外面**是你画的标注伸出去的部分——那块不属于图,不要把它当成新增的画布内容。有形状的标注(箭头/框选/椭圆/手绘)会附一张标注图(`<图名>.annotated.png`,PDF 则是一份**带批注的 PDF**,其批注是原生 PDF 注释对象:`/Ink` 手绘、`/Square` 框选、`/Circle` 圈选、`/Line` 箭头),可直接打开核对图面位置。**若消息说「本条标注只有位置点,没有标注图」,那就是全部内容:只有坐标加一句说明,直接按原图定位处理,不要去找不存在的附件。**",
|
|
27
32
|
"4. 逐条处理标注:每条标注都要落到具体的图面改动,并在答复里逐条回应(编号与标注一致)。无法实现的标注要说明原因,不要静默跳过。",
|
|
28
33
|
"5. 修改附图以**标注所指的生成源**为准(绘制脚本、SVG 源或 `generate_patent_figure` 的入参),不要直接涂改导出的位图;PDF 同理,改的是生成该 PDF 的源头,不是那份带批注的副本。未标注的部分保持不动。",
|
|
29
34
|
"6. 改完图后重新渲染并核对:图面尺寸/坐标若变化,旧标注的坐标会失准,此时以 `anchor` 与标注文字为准复核一遍,再回报结果。",
|
|
30
|
-
"7. 若消息以【HTML
|
|
31
|
-
"8. 处理 HTML
|
|
32
|
-
].join("\n");
|
|
33
|
-
/** Section order used for the Markdown edit-proposal guidance (after the annotation one). */
|
|
34
|
-
const EDIT_GUIDANCE_ORDER = 8991;
|
|
35
|
-
/** Stable section name, unique in the assembled prompt. */
|
|
36
|
-
const EDIT_GUIDANCE_NAME = "dsh-figmark:markdown-edits";
|
|
37
|
-
/** The guidance text appended for messages that carry a Markdown difference. */
|
|
38
|
-
const EDIT_GUIDANCE = [
|
|
39
|
-
"## Markdown 修改建议(dsh-figmark)",
|
|
40
|
-
"",
|
|
41
|
-
"用户可能在文档预览里直接编辑 Markdown,保存后你会收到一条【文档修改建议】开头、正文是一段 unified diff 的用户消息(`--- a/<文件名>`、`+++ b/<文件名>`,`@@` 头给出两侧的起始行与行数)。收到时按下列纪律处理:",
|
|
42
|
-
"",
|
|
43
|
-
"1. 先读文件(消息里给出路径)。**用户没有把改动写进磁盘**:磁盘上的文件仍是旧版,`a` 侧是用户打开时的那一版,`b` 侧是用户改完的结果。",
|
|
44
|
-
"2. 把 diff 应用到源文件:`-` 行删除、`+` 行写入、空格开头的是上下文(保持原样),逐处落地;不要重写整个文件,diff 之外的部分保持不动。",
|
|
45
|
-
"3. 消息里若说「另有 N 处改动未在本消息里展开」,完整结果就是随消息发送的 `<文件名>.edited.md` 附件;以它为准。",
|
|
46
|
-
"4. 消息里若带「我的说明:」清单,那是用户对某一行/某一处改动写的理由(形如 `1. 第 6 行(@@ -3,7 +3,7 @@):…`);落地时按它判断意图,说明里提到的行号是**基线(a 侧)**的行号。",
|
|
47
|
-
"5. 消息里若说文件在编辑期间被改动过、或 diff 以整块替换呈现,先核对再落地;无法安全应用时说明原因,不要猜着改。",
|
|
48
|
-
"6. 落地后逐处回应(顺序与 diff 一致),并说明哪些改动你没有采纳以及为什么。"
|
|
35
|
+
"7. 若消息以【HTML 标注】开头,那是**本机 loopback 服务页**:消息会给出页面地址。标注画在当时的真实页面视口里,`points` 只是当时的视口坐标,页面重排、滚动或更换视口后可能失准;回源定位以 `marks[].anchor.selector` 为主——每段是 `tag:nth-of-type(n)`,遇到 `#id` 就在那里截断,因此它要么以 `body > …` 开头,要么直接是 `#id > …`;`anchor.text` 是该元素当时的文字。SVG 的元素标注同理,只是那条路径以 `<svg>` 元素本身为根(`svg > g:nth-of-type(2) > …`),遇到 `#id` 同样在那里截断——所以它要么以 `svg > …` 开头,要么直接是 `#id > …`。",
|
|
36
|
+
"8. 处理 HTML 标注时,改的是**服务这个页面的源**(模板、组件、样式、生成脚本或静态资源),不是浏览器里的渲染结果。live preview 保留了真实脚本、路由和资源加载,所以页面里的状态与视觉是该服务实际给出的;若页面依赖登录态、外部 API 或一个未启动的服务,先按消息里的地址核对运行条件再改源。"
|
|
49
37
|
].join("\n");
|
|
38
|
+
/** Route prefix owned by this plugin. */
|
|
39
|
+
const ROUTE_PREFIX = "/dsh-figmark";
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/shared/live.ts
|
|
42
|
+
/**
|
|
43
|
+
* The wire between a rendered page and the panel.
|
|
44
|
+
*
|
|
45
|
+
* A page in the frame is not a document the panel holds: even when the file is on
|
|
46
|
+
* the reader's disk, the frame's own scripts have to run — a deck that animates, a
|
|
47
|
+
* video that plays, a link that means something — and a frame allowed to run scripts
|
|
48
|
+
* must not share the panel's origin. So the frame is opaque to the parent, and the
|
|
49
|
+
* only way across is a small script injected into it and `postMessage` back.
|
|
50
|
+
*
|
|
51
|
+
* This module is that vocabulary, and it is also the fence around it: everything it
|
|
52
|
+
* accepts is parsed field by field, because the other side is a page and a page can
|
|
53
|
+
* lie. A lie must stay a name — never a coordinate the reader never pointed at.
|
|
54
|
+
* @module dsh-figmark/shared/live
|
|
55
|
+
*/
|
|
56
|
+
/** Route the panel opens a live page through, under the plugin prefix. */
|
|
57
|
+
const LIVE_ROUTE = "/live";
|
|
58
|
+
/**
|
|
59
|
+
* Largest PNG data URL a live page may send back.
|
|
60
|
+
*
|
|
61
|
+
* The message is held by both browsing contexts while it crosses the frame
|
|
62
|
+
* boundary. Failing below the parser's hard ceiling leaves room for the rest of
|
|
63
|
+
* the object instead of sending an answer the receiving side would discard.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_LIVE_CAPTURE_DATA_URL = 155e5;
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/shared/services.ts
|
|
68
|
+
/**
|
|
69
|
+
* The small vocabulary shared by the service browser and the Host scan.
|
|
70
|
+
* @module dsh-figmark/shared/services
|
|
71
|
+
*/
|
|
72
|
+
/** Route that probes loopback services for the browser. */
|
|
73
|
+
const SERVICE_DISCOVER_ROUTE = "/services";
|
|
50
74
|
//#endregion
|
|
51
75
|
//#region src/shared/address.ts
|
|
52
76
|
/** Documented prefix of every file address. */
|
|
@@ -82,8 +106,36 @@ function parseFileAddress(address) {
|
|
|
82
106
|
}
|
|
83
107
|
if (rest.startsWith("absolute/")) return { path: decodeSegment(rest.slice(9)) };
|
|
84
108
|
}
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/shared/path-name.ts
|
|
111
|
+
/**
|
|
112
|
+
* The two path-name readings both halves need, in one place.
|
|
113
|
+
*
|
|
114
|
+
* A path arrives here from the Host (which uses platform separators) or from a
|
|
115
|
+
* browser document (which only ever writes `/`), so every splitter has to accept
|
|
116
|
+
* both. They used to be written out at each call site — once for the address
|
|
117
|
+
* grammar, once for the suffix table, once for a file name in a message — and the
|
|
118
|
+
* copies had already drifted apart on backslashes. One home makes them agree.
|
|
119
|
+
* @module dsh-figmark/shared/path-name
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* The last segment of a path, whatever separators it uses.
|
|
123
|
+
* @param path - a POSIX- or Windows-separated path.
|
|
124
|
+
* @returns the path's file name.
|
|
125
|
+
*/
|
|
126
|
+
function baseNameOf(path) {
|
|
127
|
+
return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read a path's lower-case suffix without the dot.
|
|
131
|
+
* @param path - file path or name.
|
|
132
|
+
* @returns the suffix, or an empty string when the name has none.
|
|
133
|
+
*/
|
|
134
|
+
function suffixOf(path) {
|
|
135
|
+
const name = baseNameOf(path);
|
|
136
|
+
const dot = name.lastIndexOf(".");
|
|
137
|
+
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
138
|
+
}
|
|
87
139
|
//#endregion
|
|
88
140
|
//#region src/shared/figure-kind.ts
|
|
89
141
|
/**
|
|
@@ -115,24 +167,8 @@ const FIGURE_EXTENSIONS = [
|
|
|
115
167
|
"html",
|
|
116
168
|
"htm"
|
|
117
169
|
];
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
* @param path - file path or name.
|
|
121
|
-
* @returns the suffix, or an empty string when there is none.
|
|
122
|
-
*/
|
|
123
|
-
function suffixOf(path) {
|
|
124
|
-
const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
|
|
125
|
-
const dot = name.lastIndexOf(".");
|
|
126
|
-
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Whether a path names a document this plugin renders.
|
|
130
|
-
* @param path - file path.
|
|
131
|
-
* @returns true when the suffix is one this plugin annotates.
|
|
132
|
-
*/
|
|
133
|
-
function isFigurePath(path) {
|
|
134
|
-
return FIGURE_EXTENSIONS.includes(suffixOf(path));
|
|
135
|
-
}
|
|
170
|
+
/** Media type of a document that has pages rather than one surface. */
|
|
171
|
+
const PAGED_MEDIA_TYPE = "application/pdf";
|
|
136
172
|
/**
|
|
137
173
|
* Media type of one document path.
|
|
138
174
|
* @param path - file path.
|
|
@@ -140,7 +176,7 @@ function isFigurePath(path) {
|
|
|
140
176
|
*/
|
|
141
177
|
function figureMediaType(path) {
|
|
142
178
|
switch (suffixOf(path)) {
|
|
143
|
-
case "pdf": return
|
|
179
|
+
case "pdf": return PAGED_MEDIA_TYPE;
|
|
144
180
|
case "svg": return "image/svg+xml";
|
|
145
181
|
case "png": return "image/png";
|
|
146
182
|
case "jpg":
|
|
@@ -154,113 +190,1150 @@ function figureMediaType(path) {
|
|
|
154
190
|
default: return;
|
|
155
191
|
}
|
|
156
192
|
}
|
|
157
|
-
//#endregion
|
|
158
|
-
//#region src/host/sidecar.ts
|
|
159
193
|
/**
|
|
160
|
-
*
|
|
194
|
+
* Whether a path names a document this plugin renders.
|
|
195
|
+
* @param path - file path.
|
|
196
|
+
* @returns true when the suffix is one this plugin annotates.
|
|
197
|
+
*/
|
|
198
|
+
function isFigurePath(path) {
|
|
199
|
+
return FIGURE_EXTENSIONS.includes(suffixOf(path));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether a figure's suffix calls for the paged-document treatment.
|
|
161
203
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
204
|
+
* One predicate for the whole plugin: the Host picks the review artifact's file
|
|
205
|
+
* extension from it, the store refuses a review whose kind contradicts it, and the
|
|
206
|
+
* browser half picks which JSON field carries the artifact from it. Reading the
|
|
207
|
+
* media type instead would work today and break the day a second suffix maps to
|
|
208
|
+
* `application/pdf`.
|
|
209
|
+
* @param path - file path.
|
|
210
|
+
* @returns true when the figure is a paged document.
|
|
211
|
+
*/
|
|
212
|
+
function isPagedFigurePath(path) {
|
|
213
|
+
return figureMediaType(path) === PAGED_MEDIA_TYPE;
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/host/figure-target.ts
|
|
217
|
+
/**
|
|
218
|
+
* Resolving one preview address to a figure that actually exists.
|
|
167
219
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* @module
|
|
220
|
+
* This is the only place a request-supplied string becomes a filesystem path, so
|
|
221
|
+
* it is where the plugin's write surface is defined: the address must parse as a
|
|
222
|
+
* file address, must resolve to a suffix this plugin renders, and must name an
|
|
223
|
+
* existing regular file. Anything else is refused with a message the browser can
|
|
224
|
+
* put in front of the reader — and nothing downstream ever accepts a path from the
|
|
225
|
+
* caller again, only the {@link FigureTarget} this module produced.
|
|
226
|
+
* @module dsh-figmark/host/figure-target
|
|
174
227
|
*/
|
|
175
|
-
/** Suffix appended to a figure's name for the marks file. */
|
|
176
|
-
const ANNOTATION_SUFFIX = ".annot.json";
|
|
177
|
-
/** Suffix appended to a figure's name for the flattened review artifact. */
|
|
178
|
-
const ANNOTATED_SUFFIX = ".annotated";
|
|
179
228
|
/**
|
|
180
|
-
* Resolve one figure
|
|
181
|
-
* @param
|
|
182
|
-
* @
|
|
229
|
+
* Resolve one request address to an existing figure file.
|
|
230
|
+
* @param address - `dsh-resource://file/…` address from the preview tab.
|
|
231
|
+
* @param sessionCwd - Session lookup used for a relative path.
|
|
232
|
+
* @returns the target, or an error message describing why it cannot be annotated.
|
|
183
233
|
*/
|
|
184
|
-
function
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
234
|
+
async function resolveTarget(address, sessionCwd) {
|
|
235
|
+
const parsed = parseFileAddress(address);
|
|
236
|
+
if (parsed === void 0) return {
|
|
237
|
+
ok: false,
|
|
238
|
+
message: `not a file address: ${address}`
|
|
239
|
+
};
|
|
240
|
+
const sessionId = parsed.sessionId;
|
|
241
|
+
const cwd = sessionId === void 0 ? void 0 : await sessionCwd(sessionId);
|
|
242
|
+
let absolute;
|
|
243
|
+
if (isAbsolute(parsed.path)) absolute = parsed.path;
|
|
244
|
+
else {
|
|
245
|
+
if (cwd === void 0) return {
|
|
246
|
+
ok: false,
|
|
247
|
+
message: `cannot resolve this Session's directory for a relative path: ${address}`
|
|
248
|
+
};
|
|
249
|
+
absolute = resolve(cwd, parsed.path);
|
|
250
|
+
}
|
|
251
|
+
if (!isFigurePath(absolute)) return {
|
|
252
|
+
ok: false,
|
|
253
|
+
message: `not an annotatable figure: ${absolute}`
|
|
254
|
+
};
|
|
255
|
+
const mediaType = figureMediaType(absolute);
|
|
256
|
+
/* v8 ignore next -- isFigurePath already restricted the suffixes figureMediaType covers. */
|
|
257
|
+
if (mediaType === void 0) return {
|
|
258
|
+
ok: false,
|
|
259
|
+
message: `no media type for ${absolute}`
|
|
260
|
+
};
|
|
261
|
+
try {
|
|
262
|
+
if ((await stat(absolute)).isFile()) return {
|
|
263
|
+
ok: true,
|
|
264
|
+
target: {
|
|
265
|
+
address,
|
|
266
|
+
path: absolute,
|
|
267
|
+
mediaType,
|
|
268
|
+
workspace: cwd ?? null
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
} catch {}
|
|
188
272
|
return {
|
|
189
|
-
|
|
190
|
-
|
|
273
|
+
ok: false,
|
|
274
|
+
message: `no such figure: ${absolute}`
|
|
191
275
|
};
|
|
192
276
|
}
|
|
277
|
+
/** The tag injected into every interactive HTML page. */
|
|
278
|
+
const PICKER_TAG = `<script src="/__dsh_figmark_picker.js"><\/script>`;
|
|
279
|
+
/** Largest HTML document either server will buffer in order to inject the picker. */
|
|
280
|
+
const MAX_HTML_BYTES = 8 * 1024 * 1024;
|
|
193
281
|
/**
|
|
194
|
-
*
|
|
282
|
+
* A page-level policy that would reject the picker script.
|
|
195
283
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* @param figurePath - absolute path of the annotated figure.
|
|
200
|
-
* @returns the file extension, dot included.
|
|
284
|
+
* The proxy already removes the same policy when it arrives as a response header.
|
|
285
|
+
* HTML can carry the policy in a meta tag as well, and a document-level
|
|
286
|
+
* `script-src 'none'` would otherwise leave a working page with a dead picker.
|
|
201
287
|
*/
|
|
202
|
-
|
|
203
|
-
return extname(figurePath).toLowerCase() === ".pdf" ? ".pdf" : ".png";
|
|
204
|
-
}
|
|
288
|
+
const CSP_META = /<meta\b(?=[^>]*\bhttp-equiv\s*=\s*(?:"\s*content-security-policy(?:-report-only)?\s*"|'\s*content-security-policy(?:-report-only)?\s*'|content-security-policy(?:-report-only)?))[^>]*>/giu;
|
|
205
289
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* @param figurePath - absolute path of the annotated figure.
|
|
210
|
-
* @returns the legacy marks file path beside the figure.
|
|
290
|
+
* Remove document-level CSP declarations before the picker is injected.
|
|
291
|
+
* @param html - the page as the source sent it.
|
|
292
|
+
* @returns the page without CSP meta tags.
|
|
211
293
|
*/
|
|
212
|
-
function
|
|
213
|
-
return
|
|
294
|
+
function stripCspMeta(html) {
|
|
295
|
+
return html.replace(CSP_META, "");
|
|
214
296
|
}
|
|
215
297
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* @
|
|
220
|
-
* @returns the candidate marks file paths.
|
|
298
|
+
* Put the picker tag in front of a page's last `</body>`.
|
|
299
|
+
* @param html - the page as the source sent it.
|
|
300
|
+
* @param tag - the tag to inject.
|
|
301
|
+
* @returns the page the frame receives.
|
|
221
302
|
*/
|
|
222
|
-
function
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
return current === legacy ? [current] : [current, legacy];
|
|
303
|
+
function injectPicker(html, tag = PICKER_TAG) {
|
|
304
|
+
const close = html.toLowerCase().lastIndexOf("</body>");
|
|
305
|
+
return close < 0 ? `${html}${tag}` : `${html.slice(0, close)}${tag}${html.slice(close)}`;
|
|
226
306
|
}
|
|
227
307
|
/**
|
|
228
|
-
*
|
|
229
|
-
* document became a single annotation.
|
|
308
|
+
* The picker, as the page receives it.
|
|
230
309
|
*
|
|
231
|
-
*
|
|
232
|
-
* (
|
|
233
|
-
*
|
|
310
|
+
* On load it marks the document element with `data-dsh-figmark="live"`, so the page
|
|
311
|
+
* (and anyone looking at it) can tell it is being previewed, and it reports that it
|
|
312
|
+
* is ready along with the current scroll — the panel needs both before it can place
|
|
313
|
+
* a single mark. While annotation is armed the pointer is reported once per animation
|
|
314
|
+
* frame and a click names the element instead of following it; while it is not, this
|
|
315
|
+
* script listens and does nothing else.
|
|
234
316
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
317
|
+
* Reports go to `window.parent` with target origin `'*'`: the panel is on another
|
|
318
|
+
* origin by construction, and the message carries nothing a hostile page could not
|
|
319
|
+
* already see. The panel validates the shape on arrival — this side only promises to
|
|
320
|
+
* send it.
|
|
321
|
+
*
|
|
322
|
+
* `selector` is positional and nothing else: `tag:nth-of-type(n)` steps joined by
|
|
323
|
+
* `' > '`, rooted at `body` but without naming it, so `body` itself is `'body'`.
|
|
324
|
+
* A positional path survives a page whose ids are regenerated on every render, which
|
|
325
|
+
* is exactly what a live page's ids do — and it is the same grammar the file
|
|
326
|
+
* preview's own path builder writes, so a saved mark resolves against either.
|
|
237
327
|
*/
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
328
|
+
const PICKER_SCRIPT = `/*! modern-screenshot v4.7.0 | MIT */
|
|
329
|
+
${readFileSync(createRequire(import.meta.url).resolve("modern-screenshot/dist/index.js"), "utf8")}
|
|
330
|
+
;(function () {
|
|
331
|
+
if (window.__dshFigmarkPicker) return
|
|
332
|
+
window.__dshFigmarkPicker = true
|
|
333
|
+
|
|
334
|
+
var MARKER = 'dsh-figmark:live'
|
|
335
|
+
var doc = document
|
|
336
|
+
var armed = false
|
|
337
|
+
var pending = 0
|
|
338
|
+
var latest = null
|
|
339
|
+
var latestKey = ''
|
|
340
|
+
var depth = 0
|
|
341
|
+
var lastScroll = ''
|
|
342
|
+
var watched = []
|
|
343
|
+
var lastBounds = ''
|
|
344
|
+
var boundsScheduled = 0
|
|
345
|
+
var lastHover = ''
|
|
346
|
+
var lastSurface
|
|
347
|
+
var surfaceScheduled = 0
|
|
348
|
+
var revealedStyles = []
|
|
349
|
+
var revealSeq = 0
|
|
350
|
+
|
|
351
|
+
// The common spellings presentation frameworks use for the one page in front.
|
|
352
|
+
// This is deliberately a small convention rather than "the largest element":
|
|
353
|
+
// marking an arbitrary large container as a page would hide valid annotations.
|
|
354
|
+
var SURFACE_QUERY = [
|
|
355
|
+
'[data-slide-current="true"]',
|
|
356
|
+
'[data-slide-active="true"]',
|
|
357
|
+
'.slide',
|
|
358
|
+
'.dk-slide',
|
|
359
|
+
'.page.current',
|
|
360
|
+
'.page.active',
|
|
361
|
+
'.page.is-live',
|
|
362
|
+
'.page.live',
|
|
363
|
+
'.swiper-slide',
|
|
364
|
+
'.carousel-item',
|
|
365
|
+
'.reveal .slides section',
|
|
366
|
+
'.remark-slide',
|
|
367
|
+
'.remark-visible',
|
|
368
|
+
'.step.active',
|
|
369
|
+
'[role="tabpanel"]',
|
|
370
|
+
'[data-slide]'
|
|
371
|
+
].join(',')
|
|
372
|
+
|
|
373
|
+
doc.documentElement.setAttribute('data-dsh-figmark', 'live')
|
|
374
|
+
var cursorStyle = doc.createElement('style')
|
|
375
|
+
cursorStyle.setAttribute('data-dsh-figmark-style', '')
|
|
376
|
+
cursorStyle.textContent = 'html[data-dsh-figmark-armed="true"] *{cursor:crosshair!important}'
|
|
377
|
+
;(doc.head || doc.documentElement).appendChild(cursorStyle)
|
|
378
|
+
|
|
379
|
+
function pathOf(el) {
|
|
380
|
+
if (el === doc.body) return 'body'
|
|
381
|
+
var steps = []
|
|
382
|
+
for (var node = el; node && node !== doc.body; node = node.parentElement) {
|
|
383
|
+
var index = 1
|
|
384
|
+
for (var sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
|
|
385
|
+
if (sib.tagName === node.tagName) index += 1
|
|
386
|
+
}
|
|
387
|
+
steps.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + index + ')')
|
|
388
|
+
}
|
|
389
|
+
return steps.join(' > ')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function ladderOf(el) {
|
|
393
|
+
var answer = []
|
|
394
|
+
for (var node = el; node && node !== doc.documentElement; node = node.parentElement) {
|
|
395
|
+
answer.push(node)
|
|
396
|
+
}
|
|
397
|
+
return answer
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function keyOf(ladder) {
|
|
401
|
+
var parts = []
|
|
402
|
+
for (var i = 0; i < ladder.length; i += 1) parts.push(pathOf(ladder[i]))
|
|
403
|
+
return parts.join('|')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function scrollOf() {
|
|
407
|
+
return [window.scrollX || 0, window.scrollY || 0]
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function send(message) {
|
|
411
|
+
var parent = window.parent
|
|
412
|
+
if (!parent) return
|
|
413
|
+
try { parent.postMessage(message, '*') } catch (error) { return }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function report(kind, el, ladder, index) {
|
|
417
|
+
var rect = el.getBoundingClientRect()
|
|
418
|
+
var scroll = scrollOf()
|
|
419
|
+
var selector = pathOf(el)
|
|
420
|
+
var hoverKey = selector + '|' + scroll[0] + ',' + scroll[1] + '|'
|
|
421
|
+
+ rect.left + ',' + rect.top + ',' + rect.width + ',' + rect.height
|
|
422
|
+
if (kind === 'hover') {
|
|
423
|
+
if (hoverKey === lastHover) return
|
|
424
|
+
lastHover = hoverKey
|
|
425
|
+
}
|
|
426
|
+
var message = {
|
|
427
|
+
figmark: MARKER,
|
|
428
|
+
kind: kind,
|
|
429
|
+
tag: el.tagName.toLowerCase(),
|
|
430
|
+
selector: selector,
|
|
431
|
+
bbox: [rect.left + scroll[0], rect.top + scroll[1], rect.width, rect.height],
|
|
432
|
+
scroll: scroll,
|
|
433
|
+
level: index + 1,
|
|
434
|
+
count: ladder.length
|
|
435
|
+
}
|
|
436
|
+
var text = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80)
|
|
437
|
+
if (text) message.text = text
|
|
438
|
+
if (el.id) message.id = el.id
|
|
439
|
+
var cls = el.getAttribute('class')
|
|
440
|
+
if (cls && cls.trim()) message['class'] = cls.trim()
|
|
441
|
+
if (kind === 'pick') {
|
|
442
|
+
var owner = surfaceOf(el)
|
|
443
|
+
message.surface = owner ? pathOf(owner) : null
|
|
444
|
+
}
|
|
445
|
+
send(message)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function choose(ladder) {
|
|
449
|
+
var key = keyOf(ladder)
|
|
450
|
+
if (key !== latestKey) {
|
|
451
|
+
latestKey = key
|
|
452
|
+
depth = 0
|
|
453
|
+
}
|
|
454
|
+
depth = Math.max(0, Math.min(depth, ladder.length - 1))
|
|
455
|
+
return depth
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function under(event) {
|
|
459
|
+
var el = event.target
|
|
460
|
+
if (!el || el.nodeType !== 1) return null
|
|
461
|
+
return el === doc.documentElement ? null : el
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function rendered(el) {
|
|
465
|
+
if (!el) return false
|
|
466
|
+
var style = window.getComputedStyle(el)
|
|
467
|
+
if (style.display === 'none'
|
|
468
|
+
|| style.visibility === 'hidden'
|
|
469
|
+
|| style.visibility === 'collapse'
|
|
470
|
+
|| style.contentVisibility === 'hidden'
|
|
471
|
+
|| parseFloat(style.opacity) <= 0) return false
|
|
472
|
+
// A slide is often put away by an ancestor's opacity. The element's own computed
|
|
473
|
+
// visibility stays "visible" there, so check the compositing chain as well.
|
|
474
|
+
for (var node = el.parentElement; node; node = node.parentElement) {
|
|
475
|
+
var parentStyle = window.getComputedStyle(node)
|
|
476
|
+
if (parentStyle.display === 'none'
|
|
477
|
+
|| parentStyle.contentVisibility === 'hidden'
|
|
478
|
+
|| parseFloat(parentStyle.opacity) <= 0) return false
|
|
479
|
+
}
|
|
480
|
+
if (typeof el.checkVisibility === 'function') {
|
|
481
|
+
try {
|
|
482
|
+
return el.checkVisibility({
|
|
483
|
+
checkOpacity: true,
|
|
484
|
+
checkVisibilityCSS: true,
|
|
485
|
+
opacityProperty: true,
|
|
486
|
+
visibilityProperty: true,
|
|
487
|
+
contentVisibilityAuto: true
|
|
488
|
+
})
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return true
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return true
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function shown(el) {
|
|
497
|
+
if (!rendered(el)) return false
|
|
498
|
+
var rect = el.getBoundingClientRect()
|
|
499
|
+
if (rect.width <= 0 || rect.height <= 0) return false
|
|
500
|
+
var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
|
|
501
|
+
var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
|
|
502
|
+
if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= viewportWidth || rect.top >= viewportHeight) {
|
|
503
|
+
return false
|
|
504
|
+
}
|
|
505
|
+
return true
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function inViewport(el) {
|
|
509
|
+
var rect = el.getBoundingClientRect()
|
|
510
|
+
var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
|
|
511
|
+
var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
|
|
512
|
+
return rect.left >= -1 && rect.top >= -1
|
|
513
|
+
&& rect.right <= viewportWidth + 1 && rect.bottom <= viewportHeight + 1
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function boxInViewport(box) {
|
|
517
|
+
var scroll = scrollOf()
|
|
518
|
+
var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
|
|
519
|
+
var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
|
|
520
|
+
var x = typeof box[0] === 'number' ? box[0] : 0
|
|
521
|
+
var y = typeof box[1] === 'number' ? box[1] : 0
|
|
522
|
+
var width = typeof box[2] === 'number' ? box[2] : 0
|
|
523
|
+
var height = typeof box[3] === 'number' ? box[3] : 0
|
|
524
|
+
return x >= scroll[0] - 1 && y >= scroll[1] - 1
|
|
525
|
+
&& x + width <= scroll[0] + viewportWidth + 1
|
|
526
|
+
&& y + height <= scroll[1] + viewportHeight + 1
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function measure(selector) {
|
|
530
|
+
var el = null
|
|
531
|
+
try { el = doc.querySelector(selector) } catch (error) { el = null }
|
|
532
|
+
if (!el) return { selector: selector, bbox: [0, 0, 0, 0], visible: false }
|
|
533
|
+
var rect = el.getBoundingClientRect()
|
|
534
|
+
var scroll = scrollOf()
|
|
535
|
+
return {
|
|
536
|
+
selector: selector,
|
|
537
|
+
bbox: [rect.left + scroll[0], rect.top + scroll[1], rect.width, rect.height],
|
|
538
|
+
visible: shown(el)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function delay(ms) {
|
|
543
|
+
return new Promise(function (resolve) { window.setTimeout(resolve, ms) })
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function waitForRendered(el, remaining) {
|
|
547
|
+
if (rendered(el)) return Promise.resolve(true)
|
|
548
|
+
if (remaining <= 0) return Promise.resolve(false)
|
|
549
|
+
return delay(45).then(function () { return waitForRendered(el, remaining - 1) })
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function restoreHidden() {
|
|
553
|
+
for (var i = revealedStyles.length - 1; i >= 0; i -= 1) {
|
|
554
|
+
var item = revealedStyles[i]
|
|
555
|
+
if (item.value) item.el.style.setProperty(item.name, item.value, item.priority)
|
|
556
|
+
else item.el.style.removeProperty(item.name)
|
|
557
|
+
}
|
|
558
|
+
revealedStyles = []
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function overrideHidden(node, name, value) {
|
|
562
|
+
revealedStyles.push({
|
|
563
|
+
el: node,
|
|
564
|
+
name: name,
|
|
565
|
+
value: node.style.getPropertyValue(name),
|
|
566
|
+
priority: node.style.getPropertyPriority(name)
|
|
567
|
+
})
|
|
568
|
+
node.style.setProperty(name, value, 'important')
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function revealHidden(el, stop) {
|
|
572
|
+
var outermost = null
|
|
573
|
+
for (var node = el; node && node !== doc.body && node !== doc.documentElement && node !== stop; node = node.parentElement) {
|
|
574
|
+
var style = window.getComputedStyle(node)
|
|
575
|
+
if (style.display === 'none') overrideHidden(node, 'display', 'revert')
|
|
576
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
|
|
577
|
+
overrideHidden(node, 'visibility', 'visible')
|
|
578
|
+
}
|
|
579
|
+
if (style.contentVisibility === 'hidden') overrideHidden(node, 'content-visibility', 'visible')
|
|
580
|
+
if (parseFloat(style.opacity) <= 0) overrideHidden(node, 'opacity', '1')
|
|
581
|
+
outermost = node
|
|
582
|
+
}
|
|
583
|
+
if (outermost && window.getComputedStyle(outermost).pointerEvents !== 'none') {
|
|
584
|
+
overrideHidden(outermost, 'pointer-events', 'none')
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function surfaceOf(el) {
|
|
589
|
+
for (var node = el; node && node !== doc.documentElement; node = node.parentElement) {
|
|
590
|
+
try {
|
|
591
|
+
if (node.matches(SURFACE_QUERY)) return node
|
|
592
|
+
} catch (error) {
|
|
593
|
+
return null
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return null
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function nearestSurface() {
|
|
600
|
+
var surfaces = []
|
|
601
|
+
try { surfaces = Array.prototype.slice.call(doc.querySelectorAll(SURFACE_QUERY)) } catch (error) { return null }
|
|
602
|
+
for (var i = 0; i < surfaces.length; i += 1) {
|
|
603
|
+
var rect = surfaces[i].getBoundingClientRect()
|
|
604
|
+
// A deck often clones every slide into its overview. Those copies keep the
|
|
605
|
+
// same classes but have no layout, so they cannot be navigation targets.
|
|
606
|
+
if (rect.width > 0 && rect.height > 0) return surfaces[i]
|
|
607
|
+
}
|
|
608
|
+
return null
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function turnToward(target) {
|
|
612
|
+
var surfaces = []
|
|
613
|
+
try { surfaces = Array.prototype.slice.call(doc.querySelectorAll(SURFACE_QUERY)) } catch (error) { return Promise.resolve(false) }
|
|
614
|
+
var wanted = surfaces.indexOf(target)
|
|
615
|
+
var current = visibleSurface()
|
|
616
|
+
var from = current ? surfaces.indexOf(current) : -1
|
|
617
|
+
if (wanted < 0 || from < 0 || wanted === from) return Promise.resolve(shown(target))
|
|
618
|
+
var key = wanted > from ? 'ArrowRight' : 'ArrowLeft'
|
|
619
|
+
var steps = Math.min(32, Math.abs(wanted - from) + 2)
|
|
620
|
+
function next(remaining) {
|
|
621
|
+
if (remaining <= 0 || shown(target)) return Promise.resolve(shown(target))
|
|
622
|
+
var node = doc.activeElement || doc.body || doc.documentElement
|
|
623
|
+
var event = new KeyboardEvent('keydown', {
|
|
624
|
+
key: key,
|
|
625
|
+
code: key,
|
|
626
|
+
keyCode: key === 'ArrowRight' ? 39 : 37,
|
|
627
|
+
which: key === 'ArrowRight' ? 39 : 37,
|
|
628
|
+
bubbles: true,
|
|
629
|
+
cancelable: true
|
|
630
|
+
})
|
|
631
|
+
node.dispatchEvent(event)
|
|
632
|
+
return delay(45).then(function () { return next(remaining - 1) })
|
|
633
|
+
}
|
|
634
|
+
return next(steps)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function reveal(requestId, selector, surface, bbox) {
|
|
638
|
+
revealSeq += 1
|
|
639
|
+
var seq = revealSeq
|
|
640
|
+
restoreHidden()
|
|
641
|
+
function current() {
|
|
642
|
+
return seq === revealSeq
|
|
643
|
+
}
|
|
644
|
+
var requestedEl = null
|
|
645
|
+
if (selector) {
|
|
646
|
+
try { requestedEl = doc.querySelector(selector) } catch (error) { requestedEl = null }
|
|
647
|
+
}
|
|
648
|
+
var ownerSurface = null
|
|
649
|
+
var surfaceEl = null
|
|
650
|
+
if (requestedEl) {
|
|
651
|
+
// The element is the strongest evidence of where it lives. A mark made
|
|
652
|
+
// before page ownership was recorded may carry the slide that was open at
|
|
653
|
+
// creation time instead, which would leave us waiting on the wrong page.
|
|
654
|
+
ownerSurface = surfaceOf(requestedEl)
|
|
655
|
+
surfaceEl = ownerSurface
|
|
656
|
+
}
|
|
657
|
+
if (!surfaceEl && requestedEl && !shown(requestedEl)) {
|
|
658
|
+
surfaceEl = nearestSurface()
|
|
659
|
+
}
|
|
660
|
+
if (!surfaceEl && surface) {
|
|
661
|
+
try { surfaceEl = doc.querySelector(surface) } catch (error) { surfaceEl = null }
|
|
662
|
+
if (!surfaceEl) {
|
|
663
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'wrong-surface' })
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
var ready = shown(surfaceEl) ? Promise.resolve(true) : surfaceEl ? turnToward(surfaceEl) : Promise.resolve(true)
|
|
668
|
+
ready.then(function (surfaceReady) {
|
|
669
|
+
if (!current()) return
|
|
670
|
+
if (!surfaceReady) {
|
|
671
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'wrong-surface' })
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
if (selector) {
|
|
675
|
+
var el = requestedEl
|
|
676
|
+
if (!el) {
|
|
677
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'missing' })
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
waitForRendered(el, 4).then(function (elementReady) {
|
|
681
|
+
if (!current()) return false
|
|
682
|
+
if (!elementReady) {
|
|
683
|
+
// A cover, modal, or intro layer can sit outside every slide and stay
|
|
684
|
+
// hidden after the deck has advanced. Restore only the hidden ancestors
|
|
685
|
+
// below the owning surface; the source and the page's state are untouched.
|
|
686
|
+
revealHidden(el, ownerSurface)
|
|
687
|
+
return waitForRendered(el, 10)
|
|
688
|
+
}
|
|
689
|
+
return elementReady
|
|
690
|
+
}).then(function (elementReady) {
|
|
691
|
+
if (!current()) return
|
|
692
|
+
if (!elementReady) {
|
|
693
|
+
restoreHidden()
|
|
694
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'hidden' })
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
// A fully visible element must not move. Centring used to shift decks and
|
|
698
|
+
// fixed-stage pages under the reader even when the mark was already on
|
|
699
|
+
// screen; nearest changes only what is actually clipped.
|
|
700
|
+
if (window.getComputedStyle(el).position !== 'fixed' && !inViewport(el)) {
|
|
701
|
+
try { el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' }) } catch (error) { el.scrollIntoView() }
|
|
702
|
+
}
|
|
703
|
+
delay(0).then(function () {
|
|
704
|
+
if (!current()) return
|
|
705
|
+
sendScroll()
|
|
706
|
+
var visible = shown(el)
|
|
707
|
+
var answer = {
|
|
708
|
+
figmark: MARKER,
|
|
709
|
+
kind: 'reveal-result',
|
|
710
|
+
requestId: requestId,
|
|
711
|
+
status: visible ? 'revealed' : 'hidden',
|
|
712
|
+
surface: surfaceEl ? pathOf(surfaceEl) : null
|
|
713
|
+
}
|
|
714
|
+
if (visible && revealedStyles.length > 0) answer.temporary = true
|
|
715
|
+
send(answer)
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
var x = bbox && typeof bbox[0] === 'number' ? bbox[0] : 0
|
|
721
|
+
var y = bbox && typeof bbox[1] === 'number' ? bbox[1] : 0
|
|
722
|
+
if (!boxInViewport(bbox || [0, 0, 0, 0])) {
|
|
723
|
+
window.scrollTo({
|
|
724
|
+
left: Math.max(0, x - (window.innerWidth || 0) / 2),
|
|
725
|
+
top: Math.max(0, y - (window.innerHeight || 0) / 2),
|
|
726
|
+
behavior: 'auto'
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
delay(0).then(function () {
|
|
730
|
+
if (!current()) return
|
|
731
|
+
sendScroll()
|
|
732
|
+
var width = bbox && typeof bbox[2] === 'number' ? bbox[2] : 0
|
|
733
|
+
var height = bbox && typeof bbox[3] === 'number' ? bbox[3] : 0
|
|
734
|
+
var visible = width <= 0 || height <= 0
|
|
735
|
+
|| (x + width > window.scrollX && x < window.scrollX + (window.innerWidth || 0)
|
|
736
|
+
&& y + height > window.scrollY && y < window.scrollY + (window.innerHeight || 0))
|
|
737
|
+
send({
|
|
738
|
+
figmark: MARKER,
|
|
739
|
+
kind: 'reveal-result',
|
|
740
|
+
requestId: requestId,
|
|
741
|
+
status: visible ? 'revealed' : 'hidden'
|
|
742
|
+
})
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function sendBounds(force) {
|
|
748
|
+
boundsScheduled = 0
|
|
749
|
+
if (watched.length === 0) return
|
|
750
|
+
var bounds = []
|
|
751
|
+
for (var i = 0; i < watched.length; i += 1) bounds.push(measure(watched[i]))
|
|
752
|
+
var key = JSON.stringify(bounds)
|
|
753
|
+
if (!force && key === lastBounds) return
|
|
754
|
+
lastBounds = key
|
|
755
|
+
send({ figmark: MARKER, kind: 'bounds', bounds: bounds })
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function scheduleBounds() {
|
|
759
|
+
if (watched.length === 0 || boundsScheduled) return
|
|
760
|
+
// Do not store the request handle here: a test clock may run the callback before
|
|
761
|
+
// requestAnimationFrame returns, which would assign a truthy handle after the
|
|
762
|
+
// callback had already cleared the flag.
|
|
763
|
+
boundsScheduled = 1
|
|
764
|
+
window.requestAnimationFrame(function () { sendBounds(false) })
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function sendScroll() {
|
|
768
|
+
var scroll = scrollOf()
|
|
769
|
+
var key = scroll[0] + ',' + scroll[1]
|
|
770
|
+
if (key !== lastScroll) {
|
|
771
|
+
lastScroll = key
|
|
772
|
+
send({ figmark: MARKER, kind: 'scroll', scroll: scroll })
|
|
773
|
+
}
|
|
774
|
+
scheduleBounds()
|
|
775
|
+
scheduleSurface()
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function visibleSurface() {
|
|
779
|
+
var candidates
|
|
780
|
+
try { candidates = doc.querySelectorAll(SURFACE_QUERY) } catch (error) { return null }
|
|
781
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
782
|
+
var el = candidates[i]
|
|
783
|
+
if (shown(el)) return el
|
|
784
|
+
}
|
|
785
|
+
return null
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function sendSurface(force) {
|
|
789
|
+
surfaceScheduled = 0
|
|
790
|
+
var el = visibleSurface()
|
|
791
|
+
var selector = el ? pathOf(el) : null
|
|
792
|
+
var key = selector === null ? '\\u0000none' : selector
|
|
793
|
+
if (selector === null && lastSurface === undefined) return
|
|
794
|
+
if (!force && key === lastSurface) return
|
|
795
|
+
lastSurface = key
|
|
796
|
+
send({ figmark: MARKER, kind: 'surface', selector: selector })
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function scheduleSurface() {
|
|
800
|
+
if (surfaceScheduled) return
|
|
801
|
+
surfaceScheduled = 1
|
|
802
|
+
window.requestAnimationFrame(function () { sendSurface(false) })
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
doc.addEventListener('pointermove', function (event) {
|
|
806
|
+
if (!armed) return
|
|
807
|
+
var el = under(event)
|
|
808
|
+
if (!el) return
|
|
809
|
+
var ladder = ladderOf(el)
|
|
810
|
+
if (ladder.length === 0) return
|
|
811
|
+
var index = choose(ladder)
|
|
812
|
+
latest = { ladder: ladder, index: index }
|
|
813
|
+
if (pending) return
|
|
814
|
+
pending = 1
|
|
815
|
+
window.requestAnimationFrame(function () {
|
|
816
|
+
pending = 0
|
|
817
|
+
if (!latest) return
|
|
818
|
+
report('hover', latest.ladder[latest.index], latest.ladder, latest.index)
|
|
819
|
+
})
|
|
820
|
+
}, true)
|
|
821
|
+
|
|
822
|
+
// The pointer going off the page takes the name with it: a label left behind over
|
|
823
|
+
// an element the reader's pointer has already left is a label about nothing.
|
|
824
|
+
doc.documentElement.addEventListener('pointerleave', function () {
|
|
825
|
+
if (!armed) return
|
|
826
|
+
latest = null
|
|
827
|
+
lastHover = ''
|
|
828
|
+
send({ figmark: MARKER, kind: 'leave' })
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
doc.addEventListener('click', function (event) {
|
|
832
|
+
if (!armed) return
|
|
833
|
+
var el = under(event)
|
|
834
|
+
if (!el) return
|
|
835
|
+
var ladder = ladderOf(el)
|
|
836
|
+
if (ladder.length === 0) return
|
|
837
|
+
var index = choose(ladder)
|
|
838
|
+
event.preventDefault()
|
|
839
|
+
event.stopImmediatePropagation()
|
|
840
|
+
report('pick', ladder[index], ladder, index)
|
|
841
|
+
}, true)
|
|
842
|
+
|
|
843
|
+
// Arrow keys climb the same ladder the hover box is promising. Escape dismisses
|
|
844
|
+
// the panel's current comment layer; both belong to the frame because that is
|
|
845
|
+
// where the keyboard focus is once the reader points into the page.
|
|
846
|
+
doc.addEventListener('keydown', function (event) {
|
|
847
|
+
if (!armed) return
|
|
848
|
+
if (event.key === 'Escape') {
|
|
849
|
+
event.preventDefault()
|
|
850
|
+
event.stopImmediatePropagation()
|
|
851
|
+
latest = null
|
|
852
|
+
lastHover = ''
|
|
853
|
+
send({ figmark: MARKER, kind: 'cancel' })
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
if (!latest) return
|
|
857
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
858
|
+
event.preventDefault()
|
|
859
|
+
event.stopImmediatePropagation()
|
|
860
|
+
depth = Math.max(0, Math.min(latest.ladder.length - 1, depth + (event.key === 'ArrowUp' ? 1 : -1)))
|
|
861
|
+
latest.index = depth
|
|
862
|
+
report('hover', latest.ladder[depth], latest.ladder, depth)
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
}, true)
|
|
866
|
+
|
|
867
|
+
window.addEventListener('scroll', sendScroll, true)
|
|
868
|
+
window.addEventListener('resize', sendScroll)
|
|
869
|
+
|
|
870
|
+
function capture(requestId) {
|
|
871
|
+
var root = doc.documentElement
|
|
872
|
+
var documentWidth = Math.max(1, Math.ceil(root.scrollWidth || root.clientWidth || window.innerWidth || 1))
|
|
873
|
+
var documentHeight = Math.max(1, Math.ceil(root.scrollHeight || root.clientHeight || window.innerHeight || 1))
|
|
874
|
+
var bounds = []
|
|
875
|
+
for (var i = 0; i < watched.length; i += 1) bounds.push(measure(watched[i]))
|
|
876
|
+
var surface = visibleSurface()
|
|
877
|
+
// A deck keeps every slide in the same document. Cloning all of them to PNG is
|
|
878
|
+
// both slow and wrong for a screenshot of the page in front: capture the surface
|
|
879
|
+
// subtree when one exists and let the panel place it at its document offset.
|
|
880
|
+
var rect = surface ? surface.getBoundingClientRect() : null
|
|
881
|
+
var scroll = scrollOf()
|
|
882
|
+
var width = rect ? Math.max(1, Math.ceil(rect.width)) : documentWidth
|
|
883
|
+
var height = rect ? Math.max(1, Math.ceil(rect.height)) : documentHeight
|
|
884
|
+
var imageRect = rect
|
|
885
|
+
? [rect.left + scroll[0], rect.top + scroll[1], width, height]
|
|
886
|
+
: [0, 0, width, height]
|
|
887
|
+
var target = surface || root
|
|
888
|
+
// The parent composes at no more than 1.5x, so a larger source only costs a DOM
|
|
889
|
+
// clone and PNG encode without adding pixels the delivered picture keeps.
|
|
890
|
+
var scale = Math.min(1.5, 1800 / Math.max(width, height))
|
|
891
|
+
var screenshot = window.modernScreenshot
|
|
892
|
+
if (!screenshot || typeof screenshot.domToDataUrl !== 'function') {
|
|
893
|
+
send({
|
|
894
|
+
figmark: MARKER,
|
|
895
|
+
kind: 'capture-result',
|
|
896
|
+
requestId: requestId,
|
|
897
|
+
status: 'failed',
|
|
898
|
+
reason: 'the screenshot renderer is unavailable'
|
|
899
|
+
})
|
|
900
|
+
return
|
|
901
|
+
}
|
|
902
|
+
screenshot.domToDataUrl(target, {
|
|
903
|
+
width: width,
|
|
904
|
+
height: height,
|
|
905
|
+
scale: scale,
|
|
906
|
+
backgroundColor: '#ffffff',
|
|
907
|
+
maximumCanvasSize: 4096,
|
|
908
|
+
timeout: 12000,
|
|
909
|
+
filter: function (node) {
|
|
910
|
+
if (!node || node.nodeType !== 1) return true
|
|
911
|
+
return !node.hasAttribute('data-dsh-figmark-style')
|
|
912
|
+
&& !node.hasAttribute('data-da-spot')
|
|
913
|
+
}
|
|
914
|
+
}).then(function (dataUrl) {
|
|
915
|
+
if (typeof dataUrl !== 'string' || dataUrl.length > ${String(MAX_LIVE_CAPTURE_DATA_URL)}) {
|
|
916
|
+
send({
|
|
917
|
+
figmark: MARKER,
|
|
918
|
+
kind: 'capture-result',
|
|
919
|
+
requestId: requestId,
|
|
920
|
+
status: 'failed',
|
|
921
|
+
reason: 'the screenshot is too large'
|
|
922
|
+
})
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
send({
|
|
926
|
+
figmark: MARKER,
|
|
927
|
+
kind: 'capture-result',
|
|
928
|
+
requestId: requestId,
|
|
929
|
+
status: 'ok',
|
|
930
|
+
dataUrl: dataUrl,
|
|
931
|
+
document: [documentWidth, documentHeight],
|
|
932
|
+
imageRect: imageRect,
|
|
933
|
+
viewport: [window.innerWidth || width, window.innerHeight || height],
|
|
934
|
+
scroll: scroll,
|
|
935
|
+
scale: scale,
|
|
936
|
+
surface: surface ? pathOf(surface) : null,
|
|
937
|
+
bounds: bounds
|
|
938
|
+
})
|
|
939
|
+
}, function (error) {
|
|
940
|
+
var reason = error && error.message ? String(error.message) : 'the page could not be captured'
|
|
941
|
+
send({
|
|
942
|
+
figmark: MARKER,
|
|
943
|
+
kind: 'capture-result',
|
|
944
|
+
requestId: requestId,
|
|
945
|
+
status: 'failed',
|
|
946
|
+
reason: reason.slice(0, 280)
|
|
947
|
+
})
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
window.addEventListener('message', function (event) {
|
|
952
|
+
var data = event.data
|
|
953
|
+
if (!data || typeof data !== 'object' || data.figmark !== MARKER) return
|
|
954
|
+
if (data.kind === 'mode') {
|
|
955
|
+
armed = !!data.armed
|
|
956
|
+
doc.documentElement.setAttribute('data-dsh-figmark-armed', armed ? 'true' : 'false')
|
|
957
|
+
if (!armed) {
|
|
958
|
+
latest = null
|
|
959
|
+
latestKey = ''
|
|
960
|
+
depth = 0
|
|
961
|
+
lastHover = ''
|
|
962
|
+
}
|
|
963
|
+
scheduleSurface()
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
if (data.kind === 'depth' && armed && latest && (data.delta === 1 || data.delta === -1)) {
|
|
967
|
+
event.preventDefault()
|
|
968
|
+
event.stopImmediatePropagation()
|
|
969
|
+
depth = Math.max(0, Math.min(latest.ladder.length - 1, latest.index + data.delta))
|
|
970
|
+
latest.index = depth
|
|
971
|
+
report('hover', latest.ladder[depth], latest.ladder, depth)
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
if (data.kind === 'watch' && Array.isArray(data.selectors)) {
|
|
975
|
+
watched = []
|
|
976
|
+
for (var i = 0; i < data.selectors.length && watched.length < 256; i += 1) {
|
|
977
|
+
var selector = data.selectors[i]
|
|
978
|
+
if (typeof selector === 'string' && selector.length > 0 && selector.length <= 2048
|
|
979
|
+
&& watched.indexOf(selector) < 0) watched.push(selector)
|
|
980
|
+
}
|
|
981
|
+
lastBounds = ''
|
|
982
|
+
sendBounds(true)
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
if (data.kind === 'reveal' && typeof data.requestId === 'string' && data.requestId.length <= 128) {
|
|
986
|
+
var requestedSelector = typeof data.selector === 'string'
|
|
987
|
+
&& data.selector.length > 0 && data.selector.length <= 2048 ? data.selector : null
|
|
988
|
+
var requestedSurface = typeof data.surface === 'string'
|
|
989
|
+
&& data.surface.length > 0 && data.surface.length <= 2048 ? data.surface : null
|
|
990
|
+
var requestedBox = Array.isArray(data.bbox) && data.bbox.length === 4 ? data.bbox : [0, 0, 0, 0]
|
|
991
|
+
reveal(data.requestId, requestedSelector, requestedSurface, requestedBox)
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
if (data.kind === 'capture' && typeof data.requestId === 'string'
|
|
995
|
+
&& data.requestId.length > 0 && data.requestId.length <= 128) {
|
|
996
|
+
capture(data.requestId)
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
if (data.kind === 'release-reveal') {
|
|
1000
|
+
revealSeq += 1
|
|
1001
|
+
restoreHidden()
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
if (window.MutationObserver) {
|
|
1006
|
+
new window.MutationObserver(function () {
|
|
1007
|
+
scheduleBounds()
|
|
1008
|
+
scheduleSurface()
|
|
1009
|
+
}).observe(doc.documentElement, {
|
|
1010
|
+
subtree: true,
|
|
1011
|
+
childList: true,
|
|
1012
|
+
attributes: true,
|
|
1013
|
+
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'aria-current']
|
|
1014
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
window.addEventListener('hashchange', scheduleSurface)
|
|
1017
|
+
window.addEventListener('popstate', scheduleSurface)
|
|
1018
|
+
window.setInterval(function () {
|
|
1019
|
+
sendBounds(false)
|
|
1020
|
+
sendSurface(false)
|
|
1021
|
+
}, 250)
|
|
1022
|
+
|
|
1023
|
+
send({ figmark: MARKER, kind: 'ready', scroll: scrollOf() })
|
|
1024
|
+
scheduleSurface()
|
|
1025
|
+
})()
|
|
1026
|
+
`;
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/host/live-proxy.ts
|
|
1029
|
+
/**
|
|
1030
|
+
* A live page, served from this machine so the panel can frame it.
|
|
1031
|
+
*
|
|
1032
|
+
* The panel cannot simply point a frame at somebody's dev server. A frame is
|
|
1033
|
+
* usually refused outright by the page's own `X-Frame-Options` or CSP, and even
|
|
1034
|
+
* when it loads, a cross-origin frame is opaque: the panel can look at it and
|
|
1035
|
+
* nothing more. Neither is acceptable — the reader has to point at elements in
|
|
1036
|
+
* the page, which takes a script running inside it.
|
|
1037
|
+
*
|
|
1038
|
+
* So the page is fetched by the Host instead. The frame points at
|
|
1039
|
+
* `http://127.0.0.1:<port>`, that origin is the proxy's, and the proxy is a
|
|
1040
|
+
* pass-through with three edits: policies that would forbid framing or the
|
|
1041
|
+
* injected script are dropped (headers and HTML meta tags alike), same-origin
|
|
1042
|
+
* redirects are kept on the proxy origin, and the script tag is put in front of
|
|
1043
|
+
* `</body>` in HTML. Everything else — methods, bodies, assets, the dev server's
|
|
1044
|
+
* own websocket-free HMR client — crosses untouched, which is what keeps a
|
|
1045
|
+
* script-driven page behaving like the page it is.
|
|
1046
|
+
*
|
|
1047
|
+
* Only this machine's own servers are reachable through it. A proxy that
|
|
1048
|
+
* accepted any origin would be an open relay running inside the app, so a
|
|
1049
|
+
* target has to be loopback by name *and* be plain `http:`: the plugin's route
|
|
1050
|
+
* is not the place to invent TLS to a local server.
|
|
1051
|
+
* @module dsh-figmark/host/live-proxy
|
|
1052
|
+
*/
|
|
1053
|
+
/** What the frame shows when the page stopped answering. */
|
|
1054
|
+
const DOWN = "the page is not answering";
|
|
1055
|
+
/** What the frame shows when a page is too big to rewrite. */
|
|
1056
|
+
const TOO_BIG = `the page is larger than ${MAX_HTML_BYTES} bytes, so the picker was not injected`;
|
|
1057
|
+
/**
|
|
1058
|
+
* Headers the proxy always drops from an answer.
|
|
1059
|
+
*
|
|
1060
|
+
* The first two would forbid the injected script or the frame itself; the third
|
|
1061
|
+
* is the pre-CSP way of saying the same thing. Nothing else is touched: the
|
|
1062
|
+
* page's own policy on its assets is its business.
|
|
1063
|
+
*/
|
|
1064
|
+
const STRIPPED = [
|
|
1065
|
+
"content-security-policy",
|
|
1066
|
+
"content-security-policy-report-only",
|
|
1067
|
+
"x-frame-options"
|
|
1068
|
+
];
|
|
1069
|
+
/** The host names that mean this machine. */
|
|
1070
|
+
const LOOPBACK = /* @__PURE__ */ new Set([
|
|
1071
|
+
"localhost",
|
|
1072
|
+
"127.0.0.1",
|
|
1073
|
+
"[::1]"
|
|
1074
|
+
]);
|
|
1075
|
+
/**
|
|
1076
|
+
* Serve one page from this machine.
|
|
1077
|
+
* @param url - the address the reader typed, e.g. `http://localhost:5173`.
|
|
1078
|
+
* @returns the proxy, already listening.
|
|
1079
|
+
* @throws {Error} when the address is not a loopback `http:` page.
|
|
1080
|
+
*/
|
|
1081
|
+
async function openLive(url) {
|
|
1082
|
+
if (url.protocol !== "http:") throw new Error(`only a server on this machine can be opened, and ${url.protocol} is not http`);
|
|
1083
|
+
if (!LOOPBACK.has(url.hostname)) throw new Error(`only a server on this machine can be opened, and ${url.hostname} is not one of its loopback names`);
|
|
1084
|
+
const target = new URL(url.href);
|
|
1085
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1086
|
+
let proxyOrigin = "";
|
|
1087
|
+
const server = createServer((req, res) => {
|
|
1088
|
+
serve(target, proxyOrigin, req, res);
|
|
1089
|
+
});
|
|
1090
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1091
|
+
upgrade(target, req, socket, head, sockets);
|
|
1092
|
+
});
|
|
1093
|
+
server.on("connection", (socket) => {
|
|
1094
|
+
sockets.add(socket);
|
|
1095
|
+
socket.on("close", () => {
|
|
1096
|
+
sockets.delete(socket);
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
await listen(server);
|
|
1100
|
+
const { port } = server.address();
|
|
1101
|
+
proxyOrigin = `http://127.0.0.1:${port}`;
|
|
1102
|
+
let closed = false;
|
|
1103
|
+
return {
|
|
1104
|
+
url: `${proxyOrigin}${target.pathname}${target.search}`,
|
|
1105
|
+
close() {
|
|
1106
|
+
if (closed) return;
|
|
1107
|
+
closed = true;
|
|
1108
|
+
for (const socket of sockets) socket.destroy();
|
|
1109
|
+
sockets.clear();
|
|
1110
|
+
server.close();
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
242
1113
|
}
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
1114
|
+
/**
|
|
1115
|
+
* Hand one upgrade request to the target, socket to socket.
|
|
1116
|
+
* @param target - the page being served.
|
|
1117
|
+
* @param req - the upgrade request from the frame.
|
|
1118
|
+
* @param socket - the frame's own socket.
|
|
1119
|
+
* @param head - bytes the client already sent past the headers.
|
|
1120
|
+
* @param sockets - every socket this proxy holds, so closing it closes these too.
|
|
1121
|
+
*/
|
|
1122
|
+
function upgrade(target, req, socket, head, sockets) {
|
|
1123
|
+
const upstream = connect(target.port === "" ? 80 : Number(target.port), target.hostname, () => {
|
|
1124
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
1125
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
1126
|
+
const name = req.rawHeaders[i];
|
|
1127
|
+
if (name.toLowerCase() === "host") continue;
|
|
1128
|
+
lines.push(`${name}: ${req.rawHeaders[i + 1]}`);
|
|
1129
|
+
}
|
|
1130
|
+
lines.push(`host: ${target.host}`);
|
|
1131
|
+
upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
1132
|
+
if (head.length > 0) upstream.write(head);
|
|
1133
|
+
upstream.pipe(socket);
|
|
1134
|
+
socket.pipe(upstream);
|
|
1135
|
+
});
|
|
1136
|
+
sockets.add(upstream);
|
|
1137
|
+
upstream.on("close", () => {
|
|
1138
|
+
sockets.delete(upstream);
|
|
1139
|
+
});
|
|
1140
|
+
upstream.on("error", () => {
|
|
1141
|
+
socket.destroy();
|
|
1142
|
+
});
|
|
1143
|
+
socket.on("error", () => {
|
|
1144
|
+
upstream.destroy();
|
|
1145
|
+
});
|
|
246
1146
|
}
|
|
247
1147
|
/**
|
|
248
|
-
*
|
|
1148
|
+
* Bind a server to a free port on the loopback interface.
|
|
1149
|
+
* @param server - the server to bind.
|
|
1150
|
+
*/
|
|
1151
|
+
async function listen(server) {
|
|
1152
|
+
await new Promise((resolve, reject) => {
|
|
1153
|
+
server.once("error", reject);
|
|
1154
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1155
|
+
resolve();
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Answer one request on the proxy.
|
|
1161
|
+
* @param target - the page being served.
|
|
1162
|
+
* @param proxyOrigin - this proxy's own origin, used for same-origin redirects.
|
|
1163
|
+
* @param req - the request from the frame.
|
|
1164
|
+
* @param res - the answer to it.
|
|
1165
|
+
*/
|
|
1166
|
+
function serve(target, proxyOrigin, req, res) {
|
|
1167
|
+
if (req.method === "GET" && req.url === "/__dsh_figmark_picker.js") {
|
|
1168
|
+
sendPicker(res);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
forward(target, proxyOrigin, req, res);
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Answer the request for the picker itself.
|
|
1175
|
+
* @param res - the answer to the frame.
|
|
1176
|
+
*/
|
|
1177
|
+
function sendPicker(res) {
|
|
1178
|
+
res.writeHead(200, {
|
|
1179
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
1180
|
+
"cache-control": "no-store",
|
|
1181
|
+
"content-length": Buffer.byteLength(PICKER_SCRIPT)
|
|
1182
|
+
});
|
|
1183
|
+
res.end(PICKER_SCRIPT);
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Ask the target for what the frame asked for, and hand the answer back.
|
|
249
1187
|
*
|
|
250
|
-
* A
|
|
251
|
-
* the
|
|
252
|
-
*
|
|
253
|
-
*
|
|
1188
|
+
* A refused connection, a reset and a name that does not resolve all arrive on
|
|
1189
|
+
* the request's `error` event, which is the only place they can be turned into
|
|
1190
|
+
* an answer: without it, the frame would sit on a connection that never starts.
|
|
1191
|
+
* @param target - the page being served.
|
|
1192
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1193
|
+
* @param req - the request from the frame.
|
|
1194
|
+
* @param res - the answer to it.
|
|
1195
|
+
*/
|
|
1196
|
+
function forward(target, proxyOrigin, req, res) {
|
|
1197
|
+
const headers = { ...req.headers };
|
|
1198
|
+
headers["host"] = target.host;
|
|
1199
|
+
delete headers["accept-encoding"];
|
|
1200
|
+
const upstream = request(target, {
|
|
1201
|
+
method: req.method,
|
|
1202
|
+
path: req.url,
|
|
1203
|
+
headers
|
|
1204
|
+
});
|
|
1205
|
+
upstream.on("response", (answer) => {
|
|
1206
|
+
returnAnswer(target, proxyOrigin, res, answer);
|
|
1207
|
+
});
|
|
1208
|
+
upstream.on("error", () => {
|
|
1209
|
+
fail(res, DOWN);
|
|
1210
|
+
});
|
|
1211
|
+
req.on("error", () => {
|
|
1212
|
+
upstream.destroy();
|
|
1213
|
+
});
|
|
1214
|
+
req.pipe(upstream);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Hand one answer from the target back to the frame.
|
|
1218
|
+
* @param target - the page being served.
|
|
1219
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1220
|
+
* @param res - the answer to the frame.
|
|
1221
|
+
* @param answer - the target's answer.
|
|
1222
|
+
*/
|
|
1223
|
+
function returnAnswer(target, proxyOrigin, res, answer) {
|
|
1224
|
+
if (isHtml(answer.headers)) {
|
|
1225
|
+
holdHtml(target, proxyOrigin, res, answer);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
res.writeHead(statusOf(answer), withoutStripped(answer.headers, target, proxyOrigin));
|
|
1229
|
+
answer.on("error", () => {
|
|
1230
|
+
res.destroy();
|
|
1231
|
+
});
|
|
1232
|
+
answer.pipe(res);
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Buffer an HTML page, put the picker tag in it, and send it on.
|
|
1236
|
+
* @param target - the page being served.
|
|
1237
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1238
|
+
* @param res - the answer to the frame.
|
|
1239
|
+
* @param answer - the target's answer.
|
|
1240
|
+
*/
|
|
1241
|
+
function holdHtml(target, proxyOrigin, res, answer) {
|
|
1242
|
+
const chunks = [];
|
|
1243
|
+
let size = 0;
|
|
1244
|
+
answer.on("data", (chunk) => {
|
|
1245
|
+
size += chunk.length;
|
|
1246
|
+
if (size > 8388608) {
|
|
1247
|
+
answer.destroy();
|
|
1248
|
+
fail(res, TOO_BIG);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
chunks.push(chunk);
|
|
1252
|
+
});
|
|
1253
|
+
answer.on("error", () => {
|
|
1254
|
+
res.destroy();
|
|
1255
|
+
});
|
|
1256
|
+
answer.on("end", () => {
|
|
1257
|
+
const body = injectPicker(stripCspMeta(Buffer.concat(chunks).toString("utf8")), PICKER_TAG);
|
|
1258
|
+
const headers = withoutStripped(answer.headers, target, proxyOrigin);
|
|
1259
|
+
delete headers["content-length"];
|
|
1260
|
+
delete headers["transfer-encoding"];
|
|
1261
|
+
headers["content-length"] = Buffer.byteLength(body);
|
|
1262
|
+
res.writeHead(statusOf(answer), headers);
|
|
1263
|
+
res.end(body);
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Whether an answer is a page the picker has to be injected into.
|
|
1268
|
+
* @param headers - the target's headers.
|
|
1269
|
+
* @returns true for an HTML body, whatever else the type says.
|
|
1270
|
+
*/
|
|
1271
|
+
function isHtml(headers) {
|
|
1272
|
+
const type = headers["content-type"];
|
|
1273
|
+
return typeof type === "string" && type.toLowerCase().startsWith("text/html");
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Copy headers, dropping the ones that would keep the frame from loading the page.
|
|
1277
|
+
* @param source - the target's headers.
|
|
1278
|
+
* @param target - the page being served.
|
|
1279
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1280
|
+
* @returns the headers to send to the frame.
|
|
1281
|
+
*/
|
|
1282
|
+
function withoutStripped(source, target, proxyOrigin) {
|
|
1283
|
+
const headers = { ...source };
|
|
1284
|
+
for (const name of STRIPPED) delete headers[name];
|
|
1285
|
+
const location = source.location;
|
|
1286
|
+
if (location !== void 0) try {
|
|
1287
|
+
const redirect = new URL(location, target);
|
|
1288
|
+
if (redirect.origin === target.origin) headers["location"] = `${proxyOrigin}${redirect.pathname}${redirect.search}${redirect.hash}`;
|
|
1289
|
+
} catch {}
|
|
1290
|
+
return headers;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* The status code of an answer.
|
|
254
1294
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* @
|
|
1295
|
+
* The `response` event only fires once a status line has been parsed, so the code
|
|
1296
|
+
* is always there; the type is optional because a message can be built by hand.
|
|
1297
|
+
* @param answer - the target's answer.
|
|
1298
|
+
* @returns its status code.
|
|
258
1299
|
*/
|
|
259
|
-
function
|
|
260
|
-
return
|
|
1300
|
+
function statusOf(answer) {
|
|
1301
|
+
return answer.statusCode;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Answer the frame with a sentence instead of a page.
|
|
1305
|
+
* @param res - the answer to the frame.
|
|
1306
|
+
* @param message - the sentence to show.
|
|
1307
|
+
*/
|
|
1308
|
+
function fail(res, message) {
|
|
1309
|
+
/* v8 ignore next 8 -- both callers answer before they have written a status line;
|
|
1310
|
+
the branch stays because a truncated answer is the only honest thing to send if
|
|
1311
|
+
that ever stops being true. */
|
|
1312
|
+
if (res.headersSent) {
|
|
1313
|
+
res.destroy();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
res.writeHead(502, {
|
|
1317
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1318
|
+
"cache-control": "no-store",
|
|
1319
|
+
"content-length": Buffer.byteLength(message)
|
|
1320
|
+
});
|
|
1321
|
+
res.end(message);
|
|
261
1322
|
}
|
|
262
1323
|
//#endregion
|
|
263
1324
|
//#region src/shared/annotation.ts
|
|
1325
|
+
/**
|
|
1326
|
+
* Longest note one mark may carry, in characters.
|
|
1327
|
+
*
|
|
1328
|
+
* Lives in `shared` because both halves need the same number for different
|
|
1329
|
+
* reasons: the browser stops the reader from typing past it, so the field cannot
|
|
1330
|
+
* grow into a sidecar the Host will refuse; the Host refuses one anyway, so a
|
|
1331
|
+
* client that forgot cannot write a file the next open cannot read. A single
|
|
1332
|
+
* number in one place is what keeps those two from drifting apart.
|
|
1333
|
+
*/
|
|
1334
|
+
const MAX_DETAIL_CHARS = 4e3;
|
|
1335
|
+
/** Longest overall note a document may carry, in characters. */
|
|
1336
|
+
const MAX_SUMMARY_CHARS = 4e3;
|
|
264
1337
|
/** Thinnest stroke a mark can be drawn at, as a multiple of the default width. */
|
|
265
1338
|
const WEIGHT_MIN = .5;
|
|
266
1339
|
/**
|
|
@@ -272,36 +1345,332 @@ const WEIGHT_MIN = .5;
|
|
|
272
1345
|
function clampWeight(value) {
|
|
273
1346
|
return Math.min(8, Math.max(WEIGHT_MIN, value));
|
|
274
1347
|
}
|
|
275
|
-
//#endregion
|
|
276
|
-
//#region src/host/store.ts
|
|
277
1348
|
/**
|
|
278
|
-
*
|
|
1349
|
+
* The kinds in the order the rail hands them out, which is the order the list reads
|
|
1350
|
+
* in.
|
|
279
1351
|
*
|
|
280
|
-
* The
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* @module dsh-figmark/host/store
|
|
1352
|
+
* The list is gathered by kind rather than left in the order the marks were drawn,
|
|
1353
|
+
* because its numbers are per kind: drawn in an evening of circling and dropping
|
|
1354
|
+
* pins, a list of six read 1, 2, 1, 1, 2, 3 — the count kept starting over wherever
|
|
1355
|
+
* the hand happened to change tool. Gathered, the same six read 1, 2, 3, 1, 1, 2,
|
|
1356
|
+
* and each run of numbers belongs to one shape.
|
|
286
1357
|
*/
|
|
287
|
-
|
|
288
|
-
const MAX_ANNOTATION_BYTES = 4 * 1024 * 1024;
|
|
289
|
-
/** Maximum accepted flattened review artifact. */
|
|
290
|
-
const MAX_REVIEW_BYTES = 24 * 1024 * 1024;
|
|
291
|
-
/** Longest element selector one anchor may carry. */
|
|
292
|
-
const MAX_SELECTOR_CHARS = 400;
|
|
293
|
-
/** Kinds a mark may declare. */
|
|
294
|
-
const MARK_KINDS = [
|
|
1358
|
+
const MARK_KIND_ORDER = [
|
|
295
1359
|
"arrow",
|
|
296
1360
|
"rect",
|
|
297
1361
|
"ellipse",
|
|
298
1362
|
"pen",
|
|
299
|
-
"point"
|
|
1363
|
+
"point",
|
|
1364
|
+
"element"
|
|
300
1365
|
];
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
1366
|
+
//#endregion
|
|
1367
|
+
//#region src/host/route-http.ts
|
|
1368
|
+
/**
|
|
1369
|
+
* Largest accepted request body.
|
|
1370
|
+
*
|
|
1371
|
+
* Twice the review cap: the artifact travels base64-encoded inside a JSON
|
|
1372
|
+
* envelope, and base64 costs four characters for every three bytes. Anything past
|
|
1373
|
+
* this is refused before it is buffered, which is what keeps a hostile body from
|
|
1374
|
+
* being an allocation.
|
|
1375
|
+
*/
|
|
1376
|
+
const MAX_BODY_BYTES$1 = 24 * 1024 * 1024 * 2;
|
|
1377
|
+
/**
|
|
1378
|
+
* Whether a request carries the plugin guard and a same-origin posture.
|
|
1379
|
+
*
|
|
1380
|
+
* A browser that does not send `Sec-Fetch-Site` at all cannot be origin-checked,
|
|
1381
|
+
* and the header alone is still a preflight requirement for any cross-origin
|
|
1382
|
+
* attempt — so an absent value passes and a present one must be same-origin.
|
|
1383
|
+
* @param req - incoming request.
|
|
1384
|
+
* @returns true when the request may proceed.
|
|
1385
|
+
*/
|
|
1386
|
+
function guarded(req) {
|
|
1387
|
+
if (req.headers["x-dsh-figmark"] === void 0) return false;
|
|
1388
|
+
const site = req.headers["sec-fetch-site"];
|
|
1389
|
+
if (typeof site !== "string") return true;
|
|
1390
|
+
return site === "same-origin" || site === "none";
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Write one JSON response.
|
|
1394
|
+
* @param res - response to own.
|
|
1395
|
+
* @param status - HTTP status code.
|
|
1396
|
+
* @param body - JSON-serializable payload.
|
|
1397
|
+
*/
|
|
1398
|
+
function sendJson(res, status, body) {
|
|
1399
|
+
const text = JSON.stringify(body);
|
|
1400
|
+
res.writeHead(status, {
|
|
1401
|
+
"content-type": "application/json; charset=utf-8",
|
|
1402
|
+
"cache-control": "no-store"
|
|
1403
|
+
});
|
|
1404
|
+
res.end(text);
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Write one refusal.
|
|
1408
|
+
* @param res - response to own.
|
|
1409
|
+
* @param status - HTTP status code.
|
|
1410
|
+
* @param error - what went wrong, in the Host's own words.
|
|
1411
|
+
*/
|
|
1412
|
+
function sendFailure(res, status, error) {
|
|
1413
|
+
sendJson(res, status, {
|
|
1414
|
+
ok: false,
|
|
1415
|
+
error
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Read a request body with a byte cap.
|
|
1420
|
+
* @param req - incoming request.
|
|
1421
|
+
* @returns the body text.
|
|
1422
|
+
* @throws {Error} when the body exceeds the cap.
|
|
1423
|
+
*/
|
|
1424
|
+
async function readBody(req) {
|
|
1425
|
+
const chunks = [];
|
|
1426
|
+
let total = 0;
|
|
1427
|
+
for await (const chunk of req) {
|
|
1428
|
+
const buffer = chunk;
|
|
1429
|
+
total += buffer.length;
|
|
1430
|
+
if (total > MAX_BODY_BYTES$1) throw new Error(`request body exceeds ${MAX_BODY_BYTES$1} bytes`);
|
|
1431
|
+
chunks.push(buffer);
|
|
1432
|
+
}
|
|
1433
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1434
|
+
}
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/host/review-payload.ts
|
|
1437
|
+
/** First bytes of every PDF file, used to tell one payload from another. */
|
|
1438
|
+
const PDF_HEADER = "%PDF-";
|
|
1439
|
+
/**
|
|
1440
|
+
* Read the one flattened review artifact a save may carry.
|
|
1441
|
+
*
|
|
1442
|
+
* The kind is taken from the payload, and the store refuses a pair that
|
|
1443
|
+
* contradicts the figure's own suffix — so a caller cannot name PNG bytes as the
|
|
1444
|
+
* review of a PDF.
|
|
1445
|
+
*
|
|
1446
|
+
* @param body - parsed request body.
|
|
1447
|
+
* @returns the artifact, or undefined when the save carried none.
|
|
1448
|
+
* @throws {Error} when both payloads are present, or the PDF bytes are not a PDF.
|
|
1449
|
+
*/
|
|
1450
|
+
function readReview(body) {
|
|
1451
|
+
const image = typeof body["reviewImage"] === "string" ? body["reviewImage"] : "";
|
|
1452
|
+
const pdf = typeof body["annotatedPdf"] === "string" ? body["annotatedPdf"] : "";
|
|
1453
|
+
if (image !== "" && pdf !== "") throw new Error("a save carries one review artifact, not both");
|
|
1454
|
+
if (pdf !== "") {
|
|
1455
|
+
const bytes = new Uint8Array(Buffer.from(pdf, "base64"));
|
|
1456
|
+
if (!Buffer.from(bytes.subarray(0, 5)).toString("latin1").startsWith(PDF_HEADER)) throw new Error("the annotated document is not a PDF");
|
|
1457
|
+
return {
|
|
1458
|
+
kind: "pdf",
|
|
1459
|
+
bytes
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
if (image !== "") return {
|
|
1463
|
+
kind: "image",
|
|
1464
|
+
bytes: new Uint8Array(Buffer.from(image, "base64"))
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
//#endregion
|
|
1468
|
+
//#region src/host/service-discovery.ts
|
|
1469
|
+
/**
|
|
1470
|
+
* Find local HTTP services without exposing an open network relay.
|
|
1471
|
+
*
|
|
1472
|
+
* The scan is deliberately bounded: loopback only, a short list of common ports,
|
|
1473
|
+
* ports named by the current workspace's package scripts, and recently used URLs.
|
|
1474
|
+
* It never scans 1-65535 and never follows a candidate to another host.
|
|
1475
|
+
* @module dsh-figmark/host/service-discovery
|
|
1476
|
+
*/
|
|
1477
|
+
/** Ports common development servers use when the package does not say. */
|
|
1478
|
+
const COMMON_PORTS = [
|
|
1479
|
+
3e3,
|
|
1480
|
+
3001,
|
|
1481
|
+
4173,
|
|
1482
|
+
4321,
|
|
1483
|
+
5173,
|
|
1484
|
+
5174,
|
|
1485
|
+
8e3,
|
|
1486
|
+
8080,
|
|
1487
|
+
8888,
|
|
1488
|
+
8898
|
|
1489
|
+
];
|
|
1490
|
+
/** Most pages one scan reads at once. */
|
|
1491
|
+
const CONCURRENCY = 8;
|
|
1492
|
+
/** Most candidates one scan will consider. */
|
|
1493
|
+
const MAX_CANDIDATES = 32;
|
|
1494
|
+
/** Longest title retained from a page. */
|
|
1495
|
+
const MAX_TITLE = 80;
|
|
1496
|
+
/** Most body bytes one probe will read before closing the response. */
|
|
1497
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
1498
|
+
/** Absolute lifetime of one probe, including connect, response, and body. */
|
|
1499
|
+
const PROBE_TIMEOUT_MS = 450;
|
|
1500
|
+
/** Whether a URL is plain HTTP on this machine. */
|
|
1501
|
+
function loopback(url) {
|
|
1502
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
|
|
1503
|
+
}
|
|
1504
|
+
/** A normalized candidate URL, or undefined when it is not a loopback HTTP page. */
|
|
1505
|
+
function candidateOf(value, source) {
|
|
1506
|
+
try {
|
|
1507
|
+
const url = new URL(value);
|
|
1508
|
+
return loopback(url) ? {
|
|
1509
|
+
url: url.href,
|
|
1510
|
+
source
|
|
1511
|
+
} : void 0;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/** Ports explicitly named by the workspace's package scripts. */
|
|
1517
|
+
async function workspacePorts(cwd) {
|
|
1518
|
+
if (cwd === void 0) return [];
|
|
1519
|
+
try {
|
|
1520
|
+
const raw = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
1521
|
+
return [...Object.values(raw.scripts ?? {}).filter((value) => typeof value === "string").join(" ").matchAll(/(?:--port(?:=|\s+)|PORT=)([1-9][0-9]{1,4})/gu)].map((match) => Number(match[1])).filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
|
|
1522
|
+
} catch {
|
|
1523
|
+
return [];
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/** Extract a short title from the beginning of an HTML answer. */
|
|
1527
|
+
function titleOf(html) {
|
|
1528
|
+
const match = /<title\b[^>]*>([\s\S]*?)<\/title>/iu.exec(html);
|
|
1529
|
+
if (match === null) return void 0;
|
|
1530
|
+
const title = match[0].replace(/^<title\b[^>]*>/iu, "").replace(/<\/title>$/iu, "").replace(/<[^>]+>/gu, " ").replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, "\"").replace(/'/gu, "'").replace(/\s+/gu, " ").trim();
|
|
1531
|
+
return title === "" ? void 0 : title.slice(0, MAX_TITLE);
|
|
1532
|
+
}
|
|
1533
|
+
/** Ask one address for its title; undefined means nothing answered there. */
|
|
1534
|
+
function probe(candidate) {
|
|
1535
|
+
return new Promise((resolve) => {
|
|
1536
|
+
const url = new URL(candidate.url);
|
|
1537
|
+
let settled = false;
|
|
1538
|
+
let deadline;
|
|
1539
|
+
const finish = (answer) => {
|
|
1540
|
+
if (settled) return;
|
|
1541
|
+
settled = true;
|
|
1542
|
+
clearTimeout(deadline);
|
|
1543
|
+
resolve(answer);
|
|
1544
|
+
};
|
|
1545
|
+
const req = request({
|
|
1546
|
+
protocol: "http:",
|
|
1547
|
+
hostname: url.hostname,
|
|
1548
|
+
port: url.port === "" ? 80 : Number(url.port),
|
|
1549
|
+
path: `${url.pathname}${url.search}`,
|
|
1550
|
+
method: "GET",
|
|
1551
|
+
headers: { accept: "text/html,application/xhtml+xml" }
|
|
1552
|
+
}, (res) => {
|
|
1553
|
+
const chunks = [];
|
|
1554
|
+
let bytes = 0;
|
|
1555
|
+
const finishResponse = () => {
|
|
1556
|
+
/* v8 ignore next -- Node populates statusCode before emitting `response`. */
|
|
1557
|
+
const status = res.statusCode ?? 0;
|
|
1558
|
+
if (status < 200 || status >= 500) {
|
|
1559
|
+
finish(void 0);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const title = titleOf(Buffer.concat(chunks).toString("utf8"));
|
|
1563
|
+
finish({
|
|
1564
|
+
url: candidate.url,
|
|
1565
|
+
source: candidate.source,
|
|
1566
|
+
...title === void 0 ? {} : { title }
|
|
1567
|
+
});
|
|
1568
|
+
};
|
|
1569
|
+
res.on("data", (chunk) => {
|
|
1570
|
+
const kept = chunk.subarray(0, MAX_BODY_BYTES - bytes);
|
|
1571
|
+
chunks.push(kept);
|
|
1572
|
+
bytes += kept.byteLength;
|
|
1573
|
+
if (bytes < MAX_BODY_BYTES) return;
|
|
1574
|
+
finishResponse();
|
|
1575
|
+
res.destroy();
|
|
1576
|
+
});
|
|
1577
|
+
res.on("end", finishResponse);
|
|
1578
|
+
res.on("error", () => {
|
|
1579
|
+
finish(void 0);
|
|
1580
|
+
});
|
|
1581
|
+
});
|
|
1582
|
+
deadline = setTimeout(() => {
|
|
1583
|
+
finish(void 0);
|
|
1584
|
+
req.destroy();
|
|
1585
|
+
}, PROBE_TIMEOUT_MS);
|
|
1586
|
+
req.on("error", () => {
|
|
1587
|
+
finish(void 0);
|
|
1588
|
+
});
|
|
1589
|
+
req.end();
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
/** Probe candidates with a fixed number of workers. */
|
|
1593
|
+
async function probeAll(candidates) {
|
|
1594
|
+
const answers = [];
|
|
1595
|
+
let next = 0;
|
|
1596
|
+
const work = async () => {
|
|
1597
|
+
for (;;) {
|
|
1598
|
+
const index = next;
|
|
1599
|
+
next += 1;
|
|
1600
|
+
const candidate = candidates[index];
|
|
1601
|
+
if (candidate === void 0) return;
|
|
1602
|
+
const answer = await probe(candidate);
|
|
1603
|
+
if (answer !== void 0) answers.push(answer);
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, work));
|
|
1607
|
+
return answers;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Discover reachable local HTTP services.
|
|
1611
|
+
* @param request - the browser's session and recent addresses.
|
|
1612
|
+
* @param sessionCwd - resolves that session's workspace.
|
|
1613
|
+
* @returns the reachable services, de-duplicated by URL.
|
|
1614
|
+
*/
|
|
1615
|
+
async function discoverServices(request, sessionCwd) {
|
|
1616
|
+
const ports = await workspacePorts(await sessionCwd(request.sessionId));
|
|
1617
|
+
const candidates = [];
|
|
1618
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1619
|
+
const add = (candidate) => {
|
|
1620
|
+
if (candidate === void 0 || seen.has(candidate.url) || candidates.length >= MAX_CANDIDATES) return;
|
|
1621
|
+
seen.add(candidate.url);
|
|
1622
|
+
candidates.push(candidate);
|
|
1623
|
+
};
|
|
1624
|
+
for (const recent of request.recent ?? []) add(candidateOf(recent, "recent"));
|
|
1625
|
+
for (const port of ports) add(candidateOf(`http://127.0.0.1:${String(port)}/`, "workspace"));
|
|
1626
|
+
for (const port of COMMON_PORTS) add(candidateOf(`http://127.0.0.1:${String(port)}/`, "common"));
|
|
1627
|
+
const services = await probeAll(candidates);
|
|
1628
|
+
const rank = {
|
|
1629
|
+
recent: 0,
|
|
1630
|
+
workspace: 1,
|
|
1631
|
+
common: 2
|
|
1632
|
+
};
|
|
1633
|
+
return services.sort((left, right) => rank[left.source] - rank[right.source] || new URL(left.url).port.localeCompare(new URL(right.url).port, void 0, { numeric: true }));
|
|
1634
|
+
}
|
|
1635
|
+
//#endregion
|
|
1636
|
+
//#region src/host/store-schema.ts
|
|
1637
|
+
/**
|
|
1638
|
+
* The wire schema of one annotation document.
|
|
1639
|
+
*
|
|
1640
|
+
* Everything that crosses the HTTP boundary is JSON somebody else wrote, so it is
|
|
1641
|
+
* validated field by field before any of it reaches disk. This module owns that
|
|
1642
|
+
* one job: turning unknown JSON into {@link AnnotationDocument} or refusing it
|
|
1643
|
+
* with a message that names the field. It deliberately knows nothing about files
|
|
1644
|
+
* — the read and write modules call it, never the other way round.
|
|
1645
|
+
*
|
|
1646
|
+
* The two figure shapes are held apart here rather than tolerated downstream: a
|
|
1647
|
+
* document is either one surface (width and height) or a paged one (pageCount),
|
|
1648
|
+
* and a mark may name a page only when its figure has pages. A reader that got
|
|
1649
|
+
* both would have to guess which coordinate space the marks live in.
|
|
1650
|
+
* @module dsh-figmark/host/store-schema
|
|
1651
|
+
*/
|
|
1652
|
+
/** Longest element selector one anchor may carry. */
|
|
1653
|
+
const MAX_SELECTOR_CHARS = 400;
|
|
1654
|
+
/** Longest class list one anchor may carry. */
|
|
1655
|
+
const MAX_ANCHOR_CLASS_CHARS = 160;
|
|
1656
|
+
/**
|
|
1657
|
+
* A note as it is stored: trimmed, and refused when it is longer than a paragraph.
|
|
1658
|
+
*
|
|
1659
|
+
* The cap is enforced rather than truncated. A writer that silently cut a pasted
|
|
1660
|
+
* paragraph would hand the reader back a different sentence than the one they
|
|
1661
|
+
* wrote, with no way to tell which half survived; refusing says so and keeps the
|
|
1662
|
+
* screen's copy intact. Absent and empty are the same thing — an empty note is no
|
|
1663
|
+
* note, and writing one would make the file say something the reader did not.
|
|
1664
|
+
* @param value - the field as it arrived.
|
|
1665
|
+
* @param what - which field, for the refusal message.
|
|
1666
|
+
* @returns the note, or undefined when there is none.
|
|
1667
|
+
* @throws {Error} when the note is longer than a paragraph.
|
|
1668
|
+
*/
|
|
1669
|
+
function readNote(value, what, max) {
|
|
1670
|
+
if (typeof value !== "string") return void 0;
|
|
1671
|
+
if (value.length > max) throw new Error(`the annotation ${what} is longer than ${String(max)} characters`);
|
|
1672
|
+
const trimmed = value.trim();
|
|
1673
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
305
1674
|
}
|
|
306
1675
|
/**
|
|
307
1676
|
* Whether a value is a finite figure coordinate pair.
|
|
@@ -334,22 +1703,29 @@ function readAnchor(value) {
|
|
|
334
1703
|
tag: raw["tag"],
|
|
335
1704
|
bbox: raw["bbox"],
|
|
336
1705
|
...typeof raw["id"] === "string" ? { id: raw["id"] } : {},
|
|
1706
|
+
...typeof raw["class"] === "string" ? { class: raw["class"].slice(0, MAX_ANCHOR_CLASS_CHARS) } : {},
|
|
337
1707
|
...typeof raw["title"] === "string" ? { title: raw["title"] } : {},
|
|
338
1708
|
...typeof raw["text"] === "string" ? { text: raw["text"] } : {},
|
|
339
1709
|
...typeof raw["selector"] === "string" ? { selector: raw["selector"].slice(0, MAX_SELECTOR_CHARS) } : {}
|
|
340
1710
|
};
|
|
341
1711
|
}
|
|
342
1712
|
/**
|
|
343
|
-
* Read one optional
|
|
1713
|
+
* Read one optional stroke weight from the wire.
|
|
344
1714
|
* @param value - the field as it arrived.
|
|
345
|
-
* @returns the
|
|
346
|
-
* @throws {Error} when the field is present but is not a usable
|
|
1715
|
+
* @returns the weight, or undefined when absent.
|
|
1716
|
+
* @throws {Error} when the field is present but is not a usable multiple.
|
|
347
1717
|
*/
|
|
348
1718
|
function readWeight(value) {
|
|
349
1719
|
if (value === void 0) return void 0;
|
|
350
1720
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("annotation mark weight must be a finite multiple of the default width");
|
|
351
1721
|
return clampWeight(value);
|
|
352
1722
|
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Read one optional rotation from the wire.
|
|
1725
|
+
* @param value - the field as it arrived.
|
|
1726
|
+
* @returns the angle in degrees, or undefined when absent.
|
|
1727
|
+
* @throws {Error} when the field is present but is not a finite number.
|
|
1728
|
+
*/
|
|
353
1729
|
function readRotation(value) {
|
|
354
1730
|
if (value === void 0) return void 0;
|
|
355
1731
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("annotation mark rotation must be a finite number of degrees");
|
|
@@ -369,6 +1745,19 @@ function readPositiveInteger(raw, field) {
|
|
|
369
1745
|
return value;
|
|
370
1746
|
}
|
|
371
1747
|
/**
|
|
1748
|
+
* Read one optional positive size from the wire.
|
|
1749
|
+
* @param raw - the figure object as it arrived.
|
|
1750
|
+
* @param field - field name, used in the failure message.
|
|
1751
|
+
* @returns the size, or undefined when the field is absent.
|
|
1752
|
+
* @throws {Error} when the field is present but is not a positive number.
|
|
1753
|
+
*/
|
|
1754
|
+
function readSize(raw, field) {
|
|
1755
|
+
const value = raw[field];
|
|
1756
|
+
if (value === void 0) return void 0;
|
|
1757
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`annotation figure ${field} must be a positive number`);
|
|
1758
|
+
return value;
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
372
1761
|
* Validate one mark from the wire.
|
|
373
1762
|
*
|
|
374
1763
|
* A mark names the page it was drawn on exactly when its figure is a paged
|
|
@@ -385,7 +1774,7 @@ function readMark(value, index, figure) {
|
|
|
385
1774
|
if (typeof value !== "object" || value === null) throw new Error(`annotation mark ${index} must be an object`);
|
|
386
1775
|
const raw = value;
|
|
387
1776
|
const kind = raw["kind"];
|
|
388
|
-
if (typeof kind !== "string" || !
|
|
1777
|
+
if (typeof kind !== "string" || !MARK_KIND_ORDER.includes(kind)) throw new Error(`annotation mark ${index} has unknown kind ${JSON.stringify(kind)}`);
|
|
389
1778
|
if (typeof raw["id"] !== "string" || raw["id"] === "") throw new Error(`annotation mark ${index} needs an id`);
|
|
390
1779
|
if (typeof raw["color"] !== "string") throw new Error(`annotation mark ${index} needs a color`);
|
|
391
1780
|
const points = raw["points"];
|
|
@@ -394,15 +1783,18 @@ function readMark(value, index, figure) {
|
|
|
394
1783
|
if (page !== void 0 && figure.width !== void 0) throw new Error(`annotation mark ${index} names a page, but its figure is one surface`);
|
|
395
1784
|
if (page !== void 0 && figure.pageCount !== void 0 && page > figure.pageCount) throw new Error(`annotation mark ${index} is on page ${page} of a ${figure.pageCount}-page document`);
|
|
396
1785
|
const anchor = readAnchor(raw["anchor"]);
|
|
1786
|
+
const surface = typeof raw["surface"] === "string" ? raw["surface"].slice(0, MAX_SELECTOR_CHARS) : void 0;
|
|
397
1787
|
const rotation = readRotation(raw["rotation"]);
|
|
398
1788
|
const weight = readWeight(raw["weight"]);
|
|
1789
|
+
const detail = readNote(raw["detail"], "mark note", MAX_DETAIL_CHARS);
|
|
399
1790
|
return {
|
|
400
1791
|
id: raw["id"],
|
|
401
1792
|
kind,
|
|
402
1793
|
color: raw["color"],
|
|
403
1794
|
points,
|
|
404
|
-
...
|
|
1795
|
+
...detail === void 0 ? {} : { detail },
|
|
405
1796
|
...page === void 0 ? {} : { page },
|
|
1797
|
+
...surface === void 0 ? {} : { surface },
|
|
406
1798
|
...rotation === void 0 ? {} : { rotation },
|
|
407
1799
|
...weight === void 0 ? {} : { weight },
|
|
408
1800
|
...anchor === void 0 ? {} : { anchor }
|
|
@@ -435,6 +1827,7 @@ function readAnnotationDocument(value) {
|
|
|
435
1827
|
const marks = raw["marks"];
|
|
436
1828
|
if (!Array.isArray(marks)) throw new Error("annotation document needs a marks array");
|
|
437
1829
|
const updatedAt = typeof raw["updatedAt"] === "string" ? raw["updatedAt"] : (/* @__PURE__ */ new Date()).toISOString();
|
|
1830
|
+
const summary = readNote(raw["summary"], "document note", MAX_SUMMARY_CHARS);
|
|
438
1831
|
return {
|
|
439
1832
|
version: 1,
|
|
440
1833
|
figure: {
|
|
@@ -454,21 +1847,141 @@ function readAnnotationDocument(value) {
|
|
|
454
1847
|
...width === void 0 ? {} : { width },
|
|
455
1848
|
...pageCount === void 0 ? {} : { pageCount }
|
|
456
1849
|
})),
|
|
457
|
-
...
|
|
1850
|
+
...summary === void 0 ? {} : { summary }
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
//#endregion
|
|
1854
|
+
//#region src/host/sidecar.ts
|
|
1855
|
+
/**
|
|
1856
|
+
* Annotation files beside one figure.
|
|
1857
|
+
*
|
|
1858
|
+
* An annotation never modifies the figure: it is written to sibling files
|
|
1859
|
+
* (`<name>.annot.json` for the marks and `<name>.annotated.png` — or
|
|
1860
|
+
* `<name>.annotated.pdf` for a document that has pages — for the flattened
|
|
1861
|
+
* review artifact). One figure is one document, however many pages it has, so a
|
|
1862
|
+
* paged document's marks share a single sidecar and name their page inside it.
|
|
1863
|
+
*
|
|
1864
|
+
* The name keeps the figure's own extension (`fig1.svg` → `fig1.svg.annot.json`):
|
|
1865
|
+
* `fig1.svg` and `fig1.png` in one directory are two different documents, and marks
|
|
1866
|
+
* drawn on one of them mean nothing on the other, so they must never share a
|
|
1867
|
+
* sidecar. Every name is derived from the figure path, so the plugin can never be
|
|
1868
|
+
* asked to write an arbitrary location.
|
|
1869
|
+
* @module dsh-figmark/host/sidecar
|
|
1870
|
+
*/
|
|
1871
|
+
/** Suffix appended to a figure's name for the marks file. */
|
|
1872
|
+
const ANNOTATION_SUFFIX = ".annot.json";
|
|
1873
|
+
/** Suffix appended to a figure's name for the flattened review artifact. */
|
|
1874
|
+
const ANNOTATED_SUFFIX = ".annotated";
|
|
1875
|
+
/**
|
|
1876
|
+
* Resolve one figure's sidecar paths.
|
|
1877
|
+
* @param figurePath - absolute path of the annotated figure.
|
|
1878
|
+
* @returns the sidecar paths; both live in the figure's own directory.
|
|
1879
|
+
*/
|
|
1880
|
+
function sidecarPaths(figurePath) {
|
|
1881
|
+
const name = basename(figurePath);
|
|
1882
|
+
const directory = dirname(figurePath);
|
|
1883
|
+
const reviewExtension = reviewFileExtension(figurePath);
|
|
1884
|
+
return {
|
|
1885
|
+
annotation: join(directory, `${name}${ANNOTATION_SUFFIX}`),
|
|
1886
|
+
review: join(directory, `${name}${ANNOTATED_SUFFIX}${reviewExtension}`)
|
|
458
1887
|
};
|
|
459
1888
|
}
|
|
460
1889
|
/**
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
1890
|
+
* Suffix of the flattened review artifact for one figure.
|
|
1891
|
+
*
|
|
1892
|
+
* A paged document is reviewed as a document — the page marks travel as native
|
|
1893
|
+
* PDF annotations in a copy of the original — while a single-surface figure is
|
|
1894
|
+
* reviewed as the picture it is.
|
|
1895
|
+
* @param figurePath - absolute path of the annotated figure.
|
|
1896
|
+
* @returns the file extension, dot included.
|
|
1897
|
+
*/
|
|
1898
|
+
function reviewFileExtension(figurePath) {
|
|
1899
|
+
return isPagedFigurePath(figurePath) ? ".pdf" : ".png";
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* The marks file this plugin derived before the figure's extension became part of
|
|
1903
|
+
* the name (`fig1.svg` → `fig1.annot.json`). Read-only: a file an earlier version
|
|
1904
|
+
* wrote stays readable, so an existing annotation never disappears on upgrade.
|
|
1905
|
+
* @param figurePath - absolute path of the annotated figure.
|
|
1906
|
+
* @returns the legacy marks file path beside the figure.
|
|
1907
|
+
*/
|
|
1908
|
+
function legacyAnnotationPath(figurePath) {
|
|
1909
|
+
return join(dirname(figurePath), `${basename(figurePath, extname(figurePath))}${ANNOTATION_SUFFIX}`);
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Marks files to read for one figure, most authoritative first: the current name,
|
|
1913
|
+
* then the legacy one. A figure whose name carries no extension derives both from
|
|
1914
|
+
* the same name, so the list is deduplicated.
|
|
1915
|
+
* @param figurePath - absolute path of the annotated figure.
|
|
1916
|
+
* @returns the candidate marks file paths.
|
|
1917
|
+
*/
|
|
1918
|
+
function annotationCandidates(figurePath) {
|
|
1919
|
+
const current = sidecarPaths(figurePath).annotation;
|
|
1920
|
+
const legacy = legacyAnnotationPath(figurePath);
|
|
1921
|
+
return current === legacy ? [current] : [current, legacy];
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Name pattern of the one-page-per-file sidecars this plugin wrote before a paged
|
|
1925
|
+
* document became a single annotation.
|
|
1926
|
+
*
|
|
1927
|
+
* Both spellings are matched — the extension-bearing name
|
|
1928
|
+
* (`fig1.pdf.p2.annot.json`) and the base-name one an even earlier version wrote
|
|
1929
|
+
* (`fig1.p2.annot.json`) — and the page number is the pattern's first group.
|
|
1930
|
+
*
|
|
1931
|
+
* @param figurePath - absolute path of the annotated figure.
|
|
1932
|
+
* @returns an anchored, case-insensitive pattern over one directory entry's name.
|
|
1933
|
+
*/
|
|
1934
|
+
function legacyPagePattern(figurePath) {
|
|
1935
|
+
const extension = extname(figurePath);
|
|
1936
|
+
const alternatives = [.../* @__PURE__ */ new Set([basename(figurePath), basename(figurePath, extension)])].map(escapeForRegExp).join("|");
|
|
1937
|
+
return new RegExp(`^(?:${alternatives})\\.p(\\d+)${escapeForRegExp(ANNOTATION_SUFFIX)}$`, "iu");
|
|
1938
|
+
}
|
|
1939
|
+
/** Escape one literal so it matches itself inside a regular expression. */
|
|
1940
|
+
function escapeForRegExp(value) {
|
|
1941
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* Whether a stored document is the annotation of this very figure.
|
|
1945
|
+
*
|
|
1946
|
+
* A sidecar sits beside its figure and both names derive from the figure path, so
|
|
1947
|
+
* the file name — extension included, and case-insensitive because one file may be
|
|
1948
|
+
* spelled `FIG1.SVG` — is the whole identity. The check matters most for the legacy
|
|
1949
|
+
* name, which two figures sharing a base name also share.
|
|
1950
|
+
*
|
|
1951
|
+
* @param document - stored document to test.
|
|
1952
|
+
* @param figurePath - absolute path of the figure being opened.
|
|
1953
|
+
* @returns true when the document names this figure.
|
|
466
1954
|
*/
|
|
467
|
-
function
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1955
|
+
function annotationTargetsFigure(document, figurePath) {
|
|
1956
|
+
return basename(document.figure.path).toLowerCase() === basename(figurePath).toLowerCase();
|
|
1957
|
+
}
|
|
1958
|
+
//#endregion
|
|
1959
|
+
//#region src/host/store-read.ts
|
|
1960
|
+
/**
|
|
1961
|
+
* Reading one figure's annotation sidecar, including the shapes older versions wrote.
|
|
1962
|
+
*
|
|
1963
|
+
* Three stored shapes have to read back as one document, because a figure
|
|
1964
|
+
* annotated by an earlier version must not lose its marks on upgrade: the current
|
|
1965
|
+
* sidecar name, the legacy base-name sidecar, and the per-page files a paged
|
|
1966
|
+
* document was once split into. All of them are read-only here — a save always
|
|
1967
|
+
* writes the current name — so the upgrade is invisible to the reader.
|
|
1968
|
+
*
|
|
1969
|
+
* Nothing in this module decides where a sidecar lives: the candidate names come
|
|
1970
|
+
* from the sidecar module, which derives every one of them from the figure path.
|
|
1971
|
+
* @module dsh-figmark/host/store-read
|
|
1972
|
+
*/
|
|
1973
|
+
/**
|
|
1974
|
+
* Content hash of one file.
|
|
1975
|
+
*
|
|
1976
|
+
* Read whole rather than streamed: the caller compares it against the hash the
|
|
1977
|
+
* browser recorded when the marks were drawn, and a figure large enough for the
|
|
1978
|
+
* difference to matter is not one this plugin renders in a sidebar.
|
|
1979
|
+
* @param path - absolute path of the file.
|
|
1980
|
+
* @returns the SHA-256 digest as lower-case hex.
|
|
1981
|
+
*/
|
|
1982
|
+
async function fileDigest(path) {
|
|
1983
|
+
const bytes = await readFile(path);
|
|
1984
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
472
1985
|
}
|
|
473
1986
|
/**
|
|
474
1987
|
* Read one figure's saved annotation.
|
|
@@ -478,15 +1991,27 @@ function readSize(raw, field) {
|
|
|
478
1991
|
* that share a base name would otherwise read each other's marks.
|
|
479
1992
|
*
|
|
480
1993
|
* @param figurePath - absolute path of the figure.
|
|
481
|
-
* @returns the document
|
|
1994
|
+
* @returns the document and any sidecar that was present but unusable.
|
|
482
1995
|
*/
|
|
483
1996
|
async function readAnnotation(figurePath) {
|
|
1997
|
+
const unreadable = [];
|
|
484
1998
|
for (const candidate of annotationCandidates(figurePath)) {
|
|
485
|
-
const
|
|
486
|
-
if (
|
|
1999
|
+
const read = await readSidecar(candidate);
|
|
2000
|
+
if (read === "unreadable") unreadable.push(candidate);
|
|
2001
|
+
else if (read !== null && annotationTargetsFigure(read, figurePath)) return {
|
|
2002
|
+
annotation: read,
|
|
2003
|
+
unreadable
|
|
2004
|
+
};
|
|
487
2005
|
}
|
|
488
|
-
if (
|
|
489
|
-
|
|
2006
|
+
if (!isPagedFigurePath(figurePath)) return {
|
|
2007
|
+
annotation: null,
|
|
2008
|
+
unreadable
|
|
2009
|
+
};
|
|
2010
|
+
const merged = await readLegacyPages(figurePath);
|
|
2011
|
+
return {
|
|
2012
|
+
annotation: merged.annotation,
|
|
2013
|
+
unreadable: [...unreadable, ...merged.unreadable]
|
|
2014
|
+
};
|
|
490
2015
|
}
|
|
491
2016
|
/**
|
|
492
2017
|
* Restate the per-page sidecars an earlier version wrote as one document.
|
|
@@ -501,7 +2026,7 @@ async function readAnnotation(figurePath) {
|
|
|
501
2026
|
* time it saves.
|
|
502
2027
|
*
|
|
503
2028
|
* @param figurePath - absolute path of the figure.
|
|
504
|
-
* @returns the merged document,
|
|
2029
|
+
* @returns the merged document, when any page file was readable.
|
|
505
2030
|
*/
|
|
506
2031
|
async function readLegacyPages(figurePath) {
|
|
507
2032
|
const directory = dirname(figurePath);
|
|
@@ -510,51 +2035,64 @@ async function readLegacyPages(figurePath) {
|
|
|
510
2035
|
try {
|
|
511
2036
|
entries = await readdir(directory);
|
|
512
2037
|
} catch {
|
|
513
|
-
return
|
|
2038
|
+
return {
|
|
2039
|
+
annotation: null,
|
|
2040
|
+
unreadable: []
|
|
2041
|
+
};
|
|
514
2042
|
}
|
|
515
2043
|
const pages = entries.map((name) => ({
|
|
516
2044
|
name,
|
|
517
2045
|
page: Number(pattern.exec(name)?.[1])
|
|
518
2046
|
})).filter((entry) => Number.isInteger(entry.page) && entry.page > 0).sort((left, right) => left.page - right.page);
|
|
519
2047
|
const read = [];
|
|
2048
|
+
const unreadable = [];
|
|
520
2049
|
for (const entry of pages) {
|
|
521
|
-
const
|
|
522
|
-
|
|
2050
|
+
const path = join(directory, entry.name);
|
|
2051
|
+
const document = await readSidecar(path);
|
|
2052
|
+
if (document === "unreadable") unreadable.push(path);
|
|
2053
|
+
else if (document !== null && annotationTargetsFigure(document, figurePath)) read.push({
|
|
523
2054
|
page: entry.page,
|
|
524
2055
|
document
|
|
525
2056
|
});
|
|
526
2057
|
}
|
|
527
2058
|
const first = read[0];
|
|
528
|
-
if (first === void 0) return
|
|
2059
|
+
if (first === void 0) return {
|
|
2060
|
+
annotation: null,
|
|
2061
|
+
unreadable
|
|
2062
|
+
};
|
|
529
2063
|
let summary;
|
|
530
2064
|
for (const entry of read) if (entry.document.summary !== void 0) summary = entry.document.summary;
|
|
531
2065
|
return {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
2066
|
+
annotation: {
|
|
2067
|
+
version: 1,
|
|
2068
|
+
figure: {
|
|
2069
|
+
address: first.document.figure.address,
|
|
2070
|
+
path: first.document.figure.path,
|
|
2071
|
+
mediaType: first.document.figure.mediaType,
|
|
2072
|
+
sha256: first.document.figure.sha256,
|
|
2073
|
+
pageCount: read.reduce((highest, entry) => Math.max(highest, entry.page), first.page)
|
|
2074
|
+
},
|
|
2075
|
+
createdAt: read.reduce((earliest, entry) => entry.document.createdAt < earliest ? entry.document.createdAt : earliest, first.document.createdAt),
|
|
2076
|
+
updatedAt: read.reduce((latest, entry) => entry.document.updatedAt > latest ? entry.document.updatedAt : latest, first.document.updatedAt),
|
|
2077
|
+
marks: read.flatMap((entry) => entry.document.marks.map((mark) => ({
|
|
2078
|
+
...mark,
|
|
2079
|
+
page: entry.page
|
|
2080
|
+
}))),
|
|
2081
|
+
...summary === void 0 ? {} : { summary }
|
|
539
2082
|
},
|
|
540
|
-
|
|
541
|
-
updatedAt: read.reduce((latest, entry) => entry.document.updatedAt > latest ? entry.document.updatedAt : latest, first.document.updatedAt),
|
|
542
|
-
marks: read.flatMap((entry) => entry.document.marks.map((mark) => ({
|
|
543
|
-
...mark,
|
|
544
|
-
page: entry.page
|
|
545
|
-
}))),
|
|
546
|
-
...summary === void 0 ? {} : { summary }
|
|
2083
|
+
unreadable
|
|
547
2084
|
};
|
|
548
2085
|
}
|
|
549
2086
|
/**
|
|
550
2087
|
* Parse one marks file into this plugin's document shape.
|
|
551
2088
|
* @param sidecarPath - absolute path of the marks file.
|
|
552
|
-
* @returns the document,
|
|
2089
|
+
* @returns the document, null when there is no readable file, or `'unreadable'`
|
|
2090
|
+
* when the file is there and is not something this plugin will read.
|
|
553
2091
|
*/
|
|
554
2092
|
async function readSidecar(sidecarPath) {
|
|
555
2093
|
let text;
|
|
556
2094
|
try {
|
|
557
|
-
if ((await stat(sidecarPath)).size >
|
|
2095
|
+
if ((await stat(sidecarPath)).size > 4194304) return "unreadable";
|
|
558
2096
|
text = await readFile(sidecarPath, "utf8");
|
|
559
2097
|
} catch {
|
|
560
2098
|
return null;
|
|
@@ -611,6 +2149,45 @@ function bridgeV2Document(raw) {
|
|
|
611
2149
|
summary: raw["summary"]
|
|
612
2150
|
};
|
|
613
2151
|
}
|
|
2152
|
+
//#endregion
|
|
2153
|
+
//#region src/host/store-write.ts
|
|
2154
|
+
/**
|
|
2155
|
+
* Writing one figure's annotation sidecar, and removing it again.
|
|
2156
|
+
*
|
|
2157
|
+
* The two files are written through a temporary name and a rename so a reader
|
|
2158
|
+
* never observes a half-written document: a preview opening while an autosave
|
|
2159
|
+
* runs reads either the previous document or the new one, never a truncated one.
|
|
2160
|
+
*
|
|
2161
|
+
* Both file names are derived here from the figure path, never passed in, and the
|
|
2162
|
+
* review artifact's kind is checked against the figure's own kind before anything
|
|
2163
|
+
* is touched — a PNG review of a PDF would be a file no reader could open.
|
|
2164
|
+
* @module dsh-figmark/host/store-write
|
|
2165
|
+
*/
|
|
2166
|
+
/**
|
|
2167
|
+
* How many scratch files this process has named.
|
|
2168
|
+
*
|
|
2169
|
+
* The clock is not enough on its own: two saves started in the same millisecond
|
|
2170
|
+
* share a pid and a millisecond, took the same scratch name, and one of them renamed
|
|
2171
|
+
* the other's half-written bytes into place — or found nothing to rename and failed a
|
|
2172
|
+
* save that had every right to succeed. A counter inside the process cannot repeat,
|
|
2173
|
+
* and the pid and clock still keep two *processes* apart.
|
|
2174
|
+
*/
|
|
2175
|
+
let scratchCount = 0;
|
|
2176
|
+
/**
|
|
2177
|
+
* Replace one file atomically.
|
|
2178
|
+
*
|
|
2179
|
+
* The temporary name carries the pid, a counter and the clock so two saves that
|
|
2180
|
+
* overlap cannot pick the same scratch file; the rename is the moment the new
|
|
2181
|
+
* content becomes observable.
|
|
2182
|
+
* @param path - absolute path of the file to replace.
|
|
2183
|
+
* @param content - text or bytes to write.
|
|
2184
|
+
*/
|
|
2185
|
+
async function writeAtomically(path, content) {
|
|
2186
|
+
scratchCount += 1;
|
|
2187
|
+
const temporary = `${path}.tmp-${process.pid}-${scratchCount}-${Date.now()}`;
|
|
2188
|
+
await writeFile(temporary, content, typeof content === "string" ? "utf8" : void 0);
|
|
2189
|
+
await rename(temporary, path);
|
|
2190
|
+
}
|
|
614
2191
|
/**
|
|
615
2192
|
* Write one figure's annotation, and optionally its flattened review artifact.
|
|
616
2193
|
* @param figurePath - absolute path of the annotated figure.
|
|
@@ -621,18 +2198,14 @@ function bridgeV2Document(raw) {
|
|
|
621
2198
|
*/
|
|
622
2199
|
async function writeAnnotation(figurePath, document, review) {
|
|
623
2200
|
const paths = sidecarPaths(figurePath);
|
|
624
|
-
if (review !== void 0 && review.kind !== (
|
|
2201
|
+
if (review !== void 0 && review.kind !== (isPagedFigurePath(figurePath) ? "pdf" : "image")) throw new Error(`a ${reviewFileExtension(figurePath)} review artifact cannot be written from ${review.kind} bytes`);
|
|
625
2202
|
await mkdir(dirname(paths.annotation), { recursive: true });
|
|
626
|
-
|
|
627
|
-
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
|
628
|
-
await rename(temporary, paths.annotation);
|
|
2203
|
+
await writeAtomically(paths.annotation, `${JSON.stringify(document, null, 2)}\n`);
|
|
629
2204
|
if (review === void 0) return {
|
|
630
2205
|
paths,
|
|
631
2206
|
wroteReview: false
|
|
632
2207
|
};
|
|
633
|
-
|
|
634
|
-
await writeFile(reviewTemporary, review.bytes);
|
|
635
|
-
await rename(reviewTemporary, paths.review);
|
|
2208
|
+
await writeAtomically(paths.review, review.bytes);
|
|
636
2209
|
return {
|
|
637
2210
|
paths,
|
|
638
2211
|
wroteReview: true
|
|
@@ -654,7 +2227,7 @@ async function writeAnnotation(figurePath, document, review) {
|
|
|
654
2227
|
async function removeAnnotation(figurePath) {
|
|
655
2228
|
const paths = sidecarPaths(figurePath);
|
|
656
2229
|
let removed = false;
|
|
657
|
-
for (const path of
|
|
2230
|
+
for (const path of await allSidecarNames(figurePath)) try {
|
|
658
2231
|
await rm(path, { force: false });
|
|
659
2232
|
removed = true;
|
|
660
2233
|
} catch (error) {
|
|
@@ -665,239 +2238,273 @@ async function removeAnnotation(figurePath) {
|
|
|
665
2238
|
removed
|
|
666
2239
|
};
|
|
667
2240
|
}
|
|
668
|
-
//#endregion
|
|
669
|
-
//#region src/host/routes.ts
|
|
670
2241
|
/**
|
|
671
|
-
*
|
|
2242
|
+
* Every file this plugin may have written beside one figure, oldest spelling first.
|
|
672
2243
|
*
|
|
673
|
-
* The
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
2244
|
+
* The read path accepts more names than the write path produces: the legacy
|
|
2245
|
+
* base-name sidecar (`fig1.annot.json` beside `fig1.svg`), and for a paged document
|
|
2246
|
+
* the one-file-per-page sidecars an earlier version wrote. Removing only the two
|
|
2247
|
+
* current names left those behind — and because the reader merges them back in, a
|
|
2248
|
+
* cleared figure reopened with its marks on it, which is the bug in finding 1.
|
|
2249
|
+
* Whatever the reader will read has to be what a clear removes.
|
|
2250
|
+
* @param figurePath - absolute path of the figure whose sidecars are removed.
|
|
2251
|
+
* @returns the paths to unlink.
|
|
679
2252
|
*/
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
2253
|
+
async function allSidecarNames(figurePath) {
|
|
2254
|
+
const paths = sidecarPaths(figurePath);
|
|
2255
|
+
const names = [
|
|
2256
|
+
paths.annotation,
|
|
2257
|
+
paths.review,
|
|
2258
|
+
legacyAnnotationPath(figurePath)
|
|
2259
|
+
];
|
|
2260
|
+
if (isPagedFigurePath(figurePath)) {
|
|
2261
|
+
const pattern = legacyPagePattern(figurePath);
|
|
2262
|
+
let entries;
|
|
2263
|
+
try {
|
|
2264
|
+
entries = await readdir(dirname(figurePath));
|
|
2265
|
+
} catch {
|
|
2266
|
+
entries = [];
|
|
2267
|
+
}
|
|
2268
|
+
for (const entry of entries) if (pattern.test(entry)) names.push(join(dirname(figurePath), entry));
|
|
2269
|
+
}
|
|
2270
|
+
return [...new Set(names)];
|
|
2271
|
+
}
|
|
2272
|
+
//#endregion
|
|
2273
|
+
//#region src/host/routes.ts
|
|
2274
|
+
/** The route a live page is opened and closed on. */
|
|
2275
|
+
const LIVE_PATH = `${ROUTE_PREFIX}${LIVE_ROUTE}`;
|
|
2276
|
+
/** The route that probes bounded loopback candidates. */
|
|
2277
|
+
const SERVICES_PATH = `${ROUTE_PREFIX}${SERVICE_DISCOVER_ROUTE}`;
|
|
684
2278
|
/**
|
|
685
|
-
* Resolve one request
|
|
686
|
-
* @param address -
|
|
687
|
-
* @param deps - Session lookup
|
|
688
|
-
* @returns the target, or
|
|
2279
|
+
* Resolve the figure one request names, as an answer rather than an exception.
|
|
2280
|
+
* @param address - the address the request carried, empty when it carried none.
|
|
2281
|
+
* @param deps - Session lookup.
|
|
2282
|
+
* @returns the target, or the 404 refusal to send.
|
|
689
2283
|
*/
|
|
690
|
-
async function
|
|
691
|
-
const
|
|
692
|
-
|
|
2284
|
+
async function targetOf(address, deps) {
|
|
2285
|
+
const resolved = await resolveTarget(address, deps.sessionCwd);
|
|
2286
|
+
return resolved.ok ? {
|
|
2287
|
+
ok: true,
|
|
2288
|
+
body: resolved.target
|
|
2289
|
+
} : {
|
|
693
2290
|
ok: false,
|
|
694
|
-
|
|
2291
|
+
status: 404,
|
|
2292
|
+
error: resolved.message
|
|
695
2293
|
};
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Read the figure's current hash and its saved annotation.
|
|
2297
|
+
* @param target - the resolved figure.
|
|
2298
|
+
* @returns the read body.
|
|
2299
|
+
*/
|
|
2300
|
+
async function readAnswer(target) {
|
|
2301
|
+
const saved = await readAnnotation(target.path);
|
|
2302
|
+
const sha256 = await fileDigest(target.path);
|
|
2303
|
+
return {
|
|
2304
|
+
ok: true,
|
|
2305
|
+
figure: {
|
|
2306
|
+
...target,
|
|
2307
|
+
sha256
|
|
2308
|
+
},
|
|
2309
|
+
annotation: saved.annotation,
|
|
2310
|
+
...saved.unreadable.length === 0 ? {} : { unreadable: saved.unreadable }
|
|
710
2311
|
};
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Write the annotation one request carried.
|
|
2315
|
+
* @param target - the resolved figure.
|
|
2316
|
+
* @param raw - the parsed request body.
|
|
2317
|
+
* @returns the save body, or the refusal to send.
|
|
2318
|
+
*/
|
|
2319
|
+
async function saveAnswer(target, raw) {
|
|
2320
|
+
const document = readAnnotationDocument(raw["annotation"]);
|
|
2321
|
+
const review = readReview(raw);
|
|
2322
|
+
if (review !== void 0 && review.bytes.byteLength > 25165824) return {
|
|
714
2323
|
ok: false,
|
|
715
|
-
|
|
2324
|
+
status: 413,
|
|
2325
|
+
error: "the review artifact is too large"
|
|
716
2326
|
};
|
|
717
|
-
|
|
718
|
-
if (!(await stat(absolute)).isFile()) return {
|
|
719
|
-
ok: false,
|
|
720
|
-
message: `not a regular file: ${absolute}`
|
|
721
|
-
};
|
|
722
|
-
} catch {
|
|
723
|
-
return {
|
|
724
|
-
ok: false,
|
|
725
|
-
message: `no such figure: ${absolute}`
|
|
726
|
-
};
|
|
727
|
-
}
|
|
2327
|
+
const saved = await writeAnnotation(target.path, document, review);
|
|
728
2328
|
return {
|
|
729
2329
|
ok: true,
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
workspace: cwd ?? null
|
|
2330
|
+
body: {
|
|
2331
|
+
ok: true,
|
|
2332
|
+
annotationPath: saved.paths.annotation,
|
|
2333
|
+
reviewPath: saved.wroteReview ? saved.paths.review : null
|
|
735
2334
|
}
|
|
736
2335
|
};
|
|
737
2336
|
}
|
|
738
2337
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
* contradicts the figure's own suffix — so a caller cannot name PNG bytes as the
|
|
743
|
-
* review of a PDF.
|
|
744
|
-
*
|
|
745
|
-
* @param body - parsed request body.
|
|
746
|
-
* @returns the artifact, or undefined when the save carried none.
|
|
747
|
-
* @throws {Error} when both payloads are present, or the PDF bytes are not a PDF.
|
|
2338
|
+
* Remove the sidecars of one figure.
|
|
2339
|
+
* @param target - the resolved figure.
|
|
2340
|
+
* @returns the removal body.
|
|
748
2341
|
*/
|
|
749
|
-
function
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
return {
|
|
757
|
-
kind: "pdf",
|
|
758
|
-
bytes
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
if (image !== "") return {
|
|
762
|
-
kind: "image",
|
|
763
|
-
bytes: new Uint8Array(Buffer.from(image, "base64"))
|
|
2342
|
+
async function removeAnswer(target) {
|
|
2343
|
+
const removed = await removeAnnotation(target.path);
|
|
2344
|
+
return {
|
|
2345
|
+
ok: true,
|
|
2346
|
+
annotationPath: removed.paths.annotation,
|
|
2347
|
+
reviewPath: removed.paths.review,
|
|
2348
|
+
removed: removed.removed
|
|
764
2349
|
};
|
|
765
2350
|
}
|
|
766
2351
|
/**
|
|
767
|
-
* Whether a request carries the plugin guard and a same-origin posture.
|
|
768
|
-
* @param req - incoming request.
|
|
769
|
-
* @returns true when the request may proceed.
|
|
770
|
-
*/
|
|
771
|
-
function guarded(req) {
|
|
772
|
-
if (req.headers["x-dsh-figmark"] === void 0) return false;
|
|
773
|
-
const site = req.headers["sec-fetch-site"];
|
|
774
|
-
if (typeof site !== "string") return true;
|
|
775
|
-
return site === "same-origin" || site === "none";
|
|
776
|
-
}
|
|
777
|
-
/**
|
|
778
|
-
* Write one JSON response.
|
|
779
|
-
* @param res - response to own.
|
|
780
|
-
* @param status - HTTP status code.
|
|
781
|
-
* @param body - JSON-serializable payload.
|
|
782
|
-
*/
|
|
783
|
-
function sendJson(res, status, body) {
|
|
784
|
-
const text = JSON.stringify(body);
|
|
785
|
-
res.writeHead(status, {
|
|
786
|
-
"content-type": "application/json; charset=utf-8",
|
|
787
|
-
"cache-control": "no-store"
|
|
788
|
-
});
|
|
789
|
-
res.end(text);
|
|
790
|
-
}
|
|
791
|
-
/**
|
|
792
|
-
* Read a request body with a byte cap.
|
|
793
|
-
* @param req - incoming request.
|
|
794
|
-
* @returns the body text.
|
|
795
|
-
* @throws {Error} when the body exceeds the cap.
|
|
796
|
-
*/
|
|
797
|
-
async function readBody(req) {
|
|
798
|
-
const chunks = [];
|
|
799
|
-
let total = 0;
|
|
800
|
-
for await (const chunk of req) {
|
|
801
|
-
const buffer = chunk;
|
|
802
|
-
total += buffer.length;
|
|
803
|
-
if (total > MAX_BODY_BYTES) throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
804
|
-
chunks.push(buffer);
|
|
805
|
-
}
|
|
806
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
807
|
-
}
|
|
808
|
-
/**
|
|
809
2352
|
* Build the route handlers this plugin registers.
|
|
810
2353
|
* @param deps - Session lookup.
|
|
2354
|
+
* @param liveRuntime - live-page operation, replaceable at the listener boundary in tests.
|
|
811
2355
|
* @returns one handler per route path.
|
|
812
2356
|
*/
|
|
813
|
-
function createHandlers(deps) {
|
|
2357
|
+
function createHandlers(deps, liveRuntime = { open: openLive }) {
|
|
2358
|
+
/**
|
|
2359
|
+
* The page being served to the panel, when one is.
|
|
2360
|
+
*
|
|
2361
|
+
* One at a time, because the panel shows one frame: a second page would leave
|
|
2362
|
+
* the first proxy listening with nothing pointing at it. The state lives here,
|
|
2363
|
+
* per composition, so two hosts in one process never share a frame.
|
|
2364
|
+
*/
|
|
2365
|
+
let live;
|
|
2366
|
+
/** The latest open request admitted, even while its listener is still starting. */
|
|
2367
|
+
let liveOpenSequence = 0;
|
|
2368
|
+
/**
|
|
2369
|
+
* Open the page one request names, or close whatever page is open.
|
|
2370
|
+
* @param req - the request naming the operation.
|
|
2371
|
+
* @param requestUrl - the already parsed request target.
|
|
2372
|
+
* @returns the answer to send.
|
|
2373
|
+
*/
|
|
2374
|
+
const liveAnswer = async (req, requestUrl) => {
|
|
2375
|
+
if (req.method === "POST") {
|
|
2376
|
+
const sequence = liveOpenSequence + 1;
|
|
2377
|
+
liveOpenSequence = sequence;
|
|
2378
|
+
const body = JSON.parse(await readBody(req));
|
|
2379
|
+
const proxy = await liveRuntime.open(new URL(typeof body["url"] === "string" ? body["url"] : ""));
|
|
2380
|
+
if (sequence !== liveOpenSequence) {
|
|
2381
|
+
proxy.close();
|
|
2382
|
+
return {
|
|
2383
|
+
ok: false,
|
|
2384
|
+
status: 409,
|
|
2385
|
+
error: "a newer local page superseded this one"
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
live?.close();
|
|
2389
|
+
live = proxy;
|
|
2390
|
+
return {
|
|
2391
|
+
ok: true,
|
|
2392
|
+
body: {
|
|
2393
|
+
ok: true,
|
|
2394
|
+
url: proxy.url
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
if (req.method === "DELETE") {
|
|
2399
|
+
const expected = requestUrl.searchParams.get("url");
|
|
2400
|
+
if (expected === null) liveOpenSequence += 1;
|
|
2401
|
+
if (live !== void 0 && (expected === null || expected === live.url)) {
|
|
2402
|
+
live.close();
|
|
2403
|
+
live = void 0;
|
|
2404
|
+
}
|
|
2405
|
+
return {
|
|
2406
|
+
ok: true,
|
|
2407
|
+
body: { ok: true }
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
return {
|
|
2411
|
+
ok: false,
|
|
2412
|
+
status: 405,
|
|
2413
|
+
error: `method ${String(req.method)} is not allowed`
|
|
2414
|
+
};
|
|
2415
|
+
};
|
|
2416
|
+
/**
|
|
2417
|
+
* Scan the bounded local candidates the browser asked about.
|
|
2418
|
+
* @param req - a POST carrying a session and optional recent URLs.
|
|
2419
|
+
* @returns reachable services, or a method refusal.
|
|
2420
|
+
*/
|
|
2421
|
+
const serviceAnswer = async (req) => {
|
|
2422
|
+
if (req.method !== "POST") return {
|
|
2423
|
+
ok: false,
|
|
2424
|
+
status: 405,
|
|
2425
|
+
error: `method ${String(req.method)} is not allowed`
|
|
2426
|
+
};
|
|
2427
|
+
const raw = JSON.parse(await readBody(req));
|
|
2428
|
+
const sessionId = typeof raw["sessionId"] === "string" ? raw["sessionId"] : "";
|
|
2429
|
+
if (sessionId === "") return {
|
|
2430
|
+
ok: false,
|
|
2431
|
+
status: 400,
|
|
2432
|
+
error: "service discovery needs a session"
|
|
2433
|
+
};
|
|
2434
|
+
const recent = Array.isArray(raw["recent"]) ? raw["recent"].filter((value) => typeof value === "string").slice(0, 16) : void 0;
|
|
2435
|
+
return {
|
|
2436
|
+
ok: true,
|
|
2437
|
+
body: {
|
|
2438
|
+
ok: true,
|
|
2439
|
+
services: await discoverServices({
|
|
2440
|
+
sessionId,
|
|
2441
|
+
...recent === void 0 ? {} : { recent }
|
|
2442
|
+
}, deps.sessionCwd)
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
};
|
|
814
2446
|
const annotation = async (req, res) => {
|
|
815
2447
|
if (!guarded(req)) {
|
|
816
|
-
|
|
817
|
-
ok: false,
|
|
818
|
-
error: "missing plugin guard header"
|
|
819
|
-
});
|
|
2448
|
+
sendFailure(res, 403, "missing plugin guard header");
|
|
820
2449
|
return;
|
|
821
2450
|
}
|
|
822
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
823
2451
|
try {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
ok: false,
|
|
829
|
-
error: resolved.message
|
|
830
|
-
});
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
const [sha256, saved] = await Promise.all([fileDigest(resolved.target.path), readAnnotation(resolved.target.path)]);
|
|
834
|
-
sendJson(res, 200, {
|
|
835
|
-
ok: true,
|
|
836
|
-
figure: {
|
|
837
|
-
...resolved.target,
|
|
838
|
-
sha256
|
|
839
|
-
},
|
|
840
|
-
annotation: saved
|
|
841
|
-
});
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
if (req.method === "POST") {
|
|
845
|
-
const body = JSON.parse(await readBody(req));
|
|
846
|
-
const resolved = await resolveTarget(typeof body["address"] === "string" ? body["address"] : "", deps);
|
|
847
|
-
if (!resolved.ok) {
|
|
848
|
-
sendJson(res, 404, {
|
|
849
|
-
ok: false,
|
|
850
|
-
error: resolved.message
|
|
851
|
-
});
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
const document = readAnnotationDocument(body["annotation"]);
|
|
855
|
-
const review = readReview(body);
|
|
856
|
-
if (review !== void 0 && review.bytes.byteLength > 25165824) {
|
|
857
|
-
sendJson(res, 413, {
|
|
858
|
-
ok: false,
|
|
859
|
-
error: "the review artifact is too large"
|
|
860
|
-
});
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
const saved = await writeAnnotation(resolved.target.path, document, review);
|
|
864
|
-
sendJson(res, 200, {
|
|
865
|
-
ok: true,
|
|
866
|
-
annotationPath: saved.paths.annotation,
|
|
867
|
-
reviewPath: saved.wroteReview ? saved.paths.review : null
|
|
868
|
-
});
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
if (req.method === "DELETE") {
|
|
872
|
-
const resolved = await resolveTarget(url.searchParams.get("address") ?? "", deps);
|
|
873
|
-
if (!resolved.ok) {
|
|
874
|
-
sendJson(res, 404, {
|
|
875
|
-
ok: false,
|
|
876
|
-
error: resolved.message
|
|
877
|
-
});
|
|
878
|
-
return;
|
|
879
|
-
}
|
|
880
|
-
const removed = await removeAnnotation(resolved.target.path);
|
|
881
|
-
sendJson(res, 200, {
|
|
882
|
-
ok: true,
|
|
883
|
-
annotationPath: removed.paths.annotation,
|
|
884
|
-
reviewPath: removed.paths.review,
|
|
885
|
-
removed: removed.removed
|
|
886
|
-
});
|
|
2452
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2453
|
+
const answer = url.pathname === LIVE_PATH ? await liveAnswer(req, url) : url.pathname === SERVICES_PATH ? await serviceAnswer(req) : await route(req, url, deps);
|
|
2454
|
+
if (answer.ok) {
|
|
2455
|
+
sendJson(res, 200, answer.body);
|
|
887
2456
|
return;
|
|
888
2457
|
}
|
|
889
|
-
|
|
890
|
-
ok: false,
|
|
891
|
-
error: `method ${req.method ?? ""} is not allowed`
|
|
892
|
-
});
|
|
2458
|
+
sendFailure(res, answer.status, answer.error);
|
|
893
2459
|
} catch (error) {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
2460
|
+
sendFailure(res, 400, error instanceof Error ? error.message : String(error));
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
return {
|
|
2464
|
+
annotation,
|
|
2465
|
+
close() {
|
|
2466
|
+
liveOpenSequence += 1;
|
|
2467
|
+
live?.close();
|
|
2468
|
+
live = void 0;
|
|
898
2469
|
}
|
|
899
2470
|
};
|
|
900
|
-
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Dispatch one guarded request to the operation its method names.
|
|
2474
|
+
* @param req - incoming request.
|
|
2475
|
+
* @param url - the request's URL.
|
|
2476
|
+
* @param deps - Session lookup.
|
|
2477
|
+
* @returns the answer to send.
|
|
2478
|
+
*/
|
|
2479
|
+
async function route(req, url, deps) {
|
|
2480
|
+
const address = url.searchParams.get("address") ?? "";
|
|
2481
|
+
if (req.method === "GET") {
|
|
2482
|
+
const target = await targetOf(address, deps);
|
|
2483
|
+
if (!target.ok) return target;
|
|
2484
|
+
return {
|
|
2485
|
+
ok: true,
|
|
2486
|
+
body: await readAnswer(target.body)
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
if (req.method === "POST") {
|
|
2490
|
+
const body = JSON.parse(await readBody(req));
|
|
2491
|
+
const target = await targetOf(typeof body["address"] === "string" ? body["address"] : "", deps);
|
|
2492
|
+
if (!target.ok) return target;
|
|
2493
|
+
return await saveAnswer(target.body, body);
|
|
2494
|
+
}
|
|
2495
|
+
if (req.method === "DELETE") {
|
|
2496
|
+
const target = await targetOf(address, deps);
|
|
2497
|
+
if (!target.ok) return target;
|
|
2498
|
+
return {
|
|
2499
|
+
ok: true,
|
|
2500
|
+
body: await removeAnswer(target.body)
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
return {
|
|
2504
|
+
ok: false,
|
|
2505
|
+
status: 405,
|
|
2506
|
+
error: `method ${req.method ?? ""} is not allowed`
|
|
2507
|
+
};
|
|
901
2508
|
}
|
|
902
2509
|
//#endregion
|
|
903
2510
|
//#region src/host/session-cwd.ts
|
|
@@ -969,23 +2576,23 @@ function apply(ctx) {
|
|
|
969
2576
|
workspaceRegistry: ctx.get("workspaceRegistry")
|
|
970
2577
|
});
|
|
971
2578
|
const handlers = createHandlers({ sessionCwd: (sessionId) => resolveSessionCwd(sessionId, sources()) });
|
|
972
|
-
ctx.effect(() =>
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
2579
|
+
ctx.effect(() => {
|
|
2580
|
+
const unregister = webServer.register({
|
|
2581
|
+
kind: "prefix",
|
|
2582
|
+
path: ROUTE_PREFIX,
|
|
2583
|
+
handler: handlers.annotation
|
|
2584
|
+
});
|
|
2585
|
+
return () => {
|
|
2586
|
+
unregister();
|
|
2587
|
+
handlers.close();
|
|
2588
|
+
};
|
|
2589
|
+
}, "dsh-figmark: annotation route");
|
|
977
2590
|
ctx.effect(() => systemPrompt.section({
|
|
978
2591
|
name: GUIDANCE_NAME,
|
|
979
2592
|
order: GUIDANCE_ORDER,
|
|
980
2593
|
text: GUIDANCE,
|
|
981
2594
|
stable: true
|
|
982
2595
|
}), "dsh-figmark: guidance");
|
|
983
|
-
ctx.effect(() => systemPrompt.section({
|
|
984
|
-
name: EDIT_GUIDANCE_NAME,
|
|
985
|
-
order: EDIT_GUIDANCE_ORDER,
|
|
986
|
-
text: EDIT_GUIDANCE,
|
|
987
|
-
stable: true
|
|
988
|
-
}), "dsh-figmark: edit guidance");
|
|
989
2596
|
}
|
|
990
2597
|
//#endregion
|
|
991
2598
|
export { apply, inject, name };
|