justskill-design 0.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- package/scripts/install.mjs +95 -0
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/node/server.mjs — the local canvas service.
|
|
3
|
+
*
|
|
4
|
+
* Invariants this file upholds.
|
|
5
|
+
*
|
|
6
|
+
* 1. Loopback only. The listener binds 127.0.0.1, and the module prints
|
|
7
|
+
* nothing: it returns `{ url, close, port, token, closed }` and the verb
|
|
8
|
+
* prints the URL.
|
|
9
|
+
* 2. Four guards on every request: a loopback `Host` (the DNS-rebinding
|
|
10
|
+
* guard), an `Origin` that is loopback and equals the `Host` header when
|
|
11
|
+
* one is present, a capability token on `/api/*` only, and a 4 MiB body
|
|
12
|
+
* cap with `Content-Type: application/json` on POST. Statics are
|
|
13
|
+
* deliberately token-free: a browser cannot put a header on a module
|
|
14
|
+
* import, and a token in the module URL would leak into every `import`.
|
|
15
|
+
* 3. The browser parses and renders the design. This service sends the FILE
|
|
16
|
+
* TEXT (`/api/file`) and metadata (`/api/state`) — never a document tree,
|
|
17
|
+
* so the two sides cannot disagree about what the file means, and a
|
|
18
|
+
* keystroke does not ship the whole document.
|
|
19
|
+
* 4. One writer path, the funnel of lib/node/verbs/add.mjs: re-read the file,
|
|
20
|
+
* validate it, apply the ops, validate again, refuse unless the edit adds
|
|
21
|
+
* no NEW error (unless `force`), normalize only what changed, `writeAtomic`,
|
|
22
|
+
* bump the revision, broadcast `changed`, return the receipt. The file on
|
|
23
|
+
* disk is the authority: a `baseRevision` that does not match is reported
|
|
24
|
+
* as `rebased: true` and the ops are applied to the current text anyway.
|
|
25
|
+
* 5. `readOnly` is a report, not a lock. It is true when the file does not
|
|
26
|
+
* parse or does not validate, so the canvas can still show the design, the
|
|
27
|
+
* problems and the raw text. Ops are refused only when there is no
|
|
28
|
+
* document to apply them to; `/api/save` keeps working, which is how a
|
|
29
|
+
* broken file is repaired from the canvas.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import crypto from "node:crypto";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import http from "node:http";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import process from "node:process";
|
|
37
|
+
|
|
38
|
+
import { CliError, loadExport, readDesignFile } from "./cli.mjs";
|
|
39
|
+
import { jsonOps } from "./op-json.mjs";
|
|
40
|
+
import { readText, revisionOf, walkWatch, writeAtomic } from "./fs.mjs";
|
|
41
|
+
import {
|
|
42
|
+
PACKAGE_ROOT, SHELL_FILE, buildRoutes, contentTypeOf, isInside, routeFor,
|
|
43
|
+
} from "./static.mjs";
|
|
44
|
+
|
|
45
|
+
/** The largest request body accepted, in bytes. */
|
|
46
|
+
export const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
47
|
+
/** Undo steps kept in memory. Past this the oldest step is dropped. */
|
|
48
|
+
export const MAX_HISTORY = 100;
|
|
49
|
+
|
|
50
|
+
const GOODBYE_GRACE_MS = 3000;
|
|
51
|
+
const PING_MS = 15000;
|
|
52
|
+
const TTL_TICK_MAX_MS = 15000;
|
|
53
|
+
|
|
54
|
+
/* ------------------------------------------------------------------ *
|
|
55
|
+
* Guards
|
|
56
|
+
* ------------------------------------------------------------------ */
|
|
57
|
+
|
|
58
|
+
/** The host name of a `Host` header, with any port removed. */
|
|
59
|
+
export function hostNameOf(host) {
|
|
60
|
+
const raw = String(host === undefined || host === null ? "" : host).trim().toLowerCase();
|
|
61
|
+
if (raw === "") return null;
|
|
62
|
+
if (raw.startsWith("[")) {
|
|
63
|
+
const end = raw.indexOf("]");
|
|
64
|
+
return end === -1 ? null : raw.slice(0, end + 1);
|
|
65
|
+
}
|
|
66
|
+
if (raw.includes(":")) {
|
|
67
|
+
const at = raw.lastIndexOf(":");
|
|
68
|
+
const port = raw.slice(at + 1);
|
|
69
|
+
return /^\d+$/.test(port) ? raw.slice(0, at) : raw; // a bare `::1` has no port
|
|
70
|
+
}
|
|
71
|
+
return raw;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Only loopback names, whatever the port. This is the DNS-rebinding guard. */
|
|
75
|
+
export function isLoopbackHost(host) {
|
|
76
|
+
const name = hostNameOf(host);
|
|
77
|
+
return name === "127.0.0.1" || name === "localhost" || name === "[::1]" || name === "::1";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* True when the request may proceed: an absent `Origin` (the CLI has none), or
|
|
82
|
+
* a loopback origin whose host equals the `Host` header.
|
|
83
|
+
*/
|
|
84
|
+
export function isAllowedOrigin(origin, host) {
|
|
85
|
+
if (origin === undefined || origin === null || origin === "") return true;
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = new URL(String(origin));
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (!isLoopbackHost(parsed.host)) return false;
|
|
93
|
+
return parsed.host.toLowerCase() === String(host || "").trim().toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isJsonContentType(value) {
|
|
97
|
+
return /^application\/(?:[A-Za-z0-9.+-]+\+)?json\s*(;|$)/i.test(String(value || "").trim());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Constant-time token comparison; a length mismatch is a mismatch. */
|
|
101
|
+
function tokenMatches(provided, token) {
|
|
102
|
+
if (typeof provided !== "string" || provided.length === 0) return false;
|
|
103
|
+
const a = Buffer.from(provided, "utf8");
|
|
104
|
+
const b = Buffer.from(token, "utf8");
|
|
105
|
+
if (a.length !== b.length) return false;
|
|
106
|
+
try {
|
|
107
|
+
return crypto.timingSafeEqual(a, b);
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------------------------------------------ *
|
|
114
|
+
* Responses
|
|
115
|
+
* ------------------------------------------------------------------ */
|
|
116
|
+
|
|
117
|
+
function json(res, status, payload, extra = {}) {
|
|
118
|
+
const body = `${JSON.stringify(payload)}\n`;
|
|
119
|
+
const headers = {
|
|
120
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
121
|
+
"Content-Length": Buffer.byteLength(body),
|
|
122
|
+
"Cache-Control": "no-store",
|
|
123
|
+
...extra,
|
|
124
|
+
};
|
|
125
|
+
// The canvas records the revision of every answer, and a header survives a
|
|
126
|
+
// payload it does not want to read. `/api/file` sets it explicitly as well.
|
|
127
|
+
if (payload && typeof payload.revision === "string" && payload.revision !== "") {
|
|
128
|
+
headers["X-Design-Revision"] = payload.revision;
|
|
129
|
+
}
|
|
130
|
+
res.writeHead(status, headers);
|
|
131
|
+
res.end(body);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function text(res, status, body, contentType = "text/plain; charset=utf-8", extra = {}) {
|
|
135
|
+
res.writeHead(status, {
|
|
136
|
+
"Content-Type": contentType,
|
|
137
|
+
"Content-Length": Buffer.byteLength(body),
|
|
138
|
+
"Cache-Control": "no-store",
|
|
139
|
+
...extra,
|
|
140
|
+
});
|
|
141
|
+
res.end(body);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Answer, then keep reading the request so the client sees the answer. */
|
|
145
|
+
function answerAndDrain(req, res, status, payload) {
|
|
146
|
+
json(res, status, payload);
|
|
147
|
+
try {
|
|
148
|
+
if (!req.readableEnded) req.resume();
|
|
149
|
+
} catch {
|
|
150
|
+
/* the request is already gone */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function escapeHtml(value) {
|
|
155
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ------------------------------------------------------------------ *
|
|
159
|
+
* The service
|
|
160
|
+
* ------------------------------------------------------------------ */
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start the canvas service for one design file.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} options
|
|
166
|
+
* @param {string} options.file the design file to serve
|
|
167
|
+
* @param {number} [options.port] 0 picks a free port (default 0)
|
|
168
|
+
* @param {number} [options.ttlMinutes] idle minutes before closing; 0 or absent means never
|
|
169
|
+
* @param {boolean} [options.force] write even when an edit adds a new error
|
|
170
|
+
* @param {boolean} [options.log] append a JSONL change log beside the file
|
|
171
|
+
* @param {(line: string) => void} [options.onLog] human-readable lines; nothing is printed here
|
|
172
|
+
* @param {boolean} [options.open] accepted and ignored: opening a browser is the verb's job
|
|
173
|
+
* @returns {Promise<{url: string, port: number, token: string, file: string, close: (reason?: string) => void, closed: Promise<string>}>}
|
|
174
|
+
* @throws {FsError} the file cannot be read — the caller maps that to exit 2
|
|
175
|
+
* @throws {CliError} the module could not be loaded
|
|
176
|
+
*/
|
|
177
|
+
export async function startServer(options = {}) {
|
|
178
|
+
if (typeof options.file !== "string" || options.file === "") {
|
|
179
|
+
throw new CliError("startServer() needs a design file", 2, "serve/no-file");
|
|
180
|
+
}
|
|
181
|
+
const file = path.resolve(options.file);
|
|
182
|
+
|
|
183
|
+
const ttlMinutes = Number(options.ttlMinutes ?? 0);
|
|
184
|
+
const ttlMs = Number.isFinite(ttlMinutes) && ttlMinutes > 0 ? ttlMinutes * 60000 : 0;
|
|
185
|
+
const force = options.force === true;
|
|
186
|
+
const writeLog = options.log === true;
|
|
187
|
+
const onLog = typeof options.onLog === "function" ? options.onLog : () => {};
|
|
188
|
+
const log = (line) => {
|
|
189
|
+
try {
|
|
190
|
+
onLog(String(line));
|
|
191
|
+
} catch {
|
|
192
|
+
/* a listener that throws must not stop the service */
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
196
|
+
const routes = buildRoutes(PACKAGE_ROOT);
|
|
197
|
+
|
|
198
|
+
// The lib/ modules are loaded the same way a verb loads them, so a rename is
|
|
199
|
+
// reported as a CliError instead of crashing halfway through a request.
|
|
200
|
+
const deps = {
|
|
201
|
+
applyOps: await loadExport("ops.mjs", ["applyOps"], "applyOps"),
|
|
202
|
+
buildIdIndex: await loadExport("ops.mjs", ["buildIdIndex"], "buildIdIndex"),
|
|
203
|
+
validateDesign: await loadExport("validate.mjs", ["validateDesign"], "validateDesign"),
|
|
204
|
+
newErrors: await loadExport("validate.mjs", ["newErrors"], "newErrors"),
|
|
205
|
+
errorKeys: await loadExport("validate.mjs", ["errorKeys"], "errorKeys"),
|
|
206
|
+
diagnosticKey: await loadExport("validate.mjs", ["diagnosticKey"], "diagnosticKey"),
|
|
207
|
+
normalizeDocument: await loadExport("normalize.mjs", ["normalizeDocument"], "normalizeDocument"),
|
|
208
|
+
serialize: await loadExport("serialize.mjs", ["serialize"], "serialize"),
|
|
209
|
+
parse: await loadExport("parse.mjs", ["parse", "parseHtml"], "parse"),
|
|
210
|
+
detectFormat: await loadExport("detect.mjs", ["detectFormat"], "detectFormat"),
|
|
211
|
+
exportPage: await loadExport("export-html.mjs", ["exportPage"], "exportPage"),
|
|
212
|
+
exportAllPages: await loadExport("export-html.mjs", ["exportAllPages"], "exportAllPages"),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// A missing or unreadable file is a start-up failure; an invalid one is not.
|
|
216
|
+
readText(file);
|
|
217
|
+
|
|
218
|
+
const clients = new Set();
|
|
219
|
+
const undoStack = [];
|
|
220
|
+
const redoStack = [];
|
|
221
|
+
|
|
222
|
+
/** What the clients were last told. `/api/state` and `/api/file` re-read. */
|
|
223
|
+
let state = { text: "", revision: null, doc: null, diagnostics: [], readOnly: true, readOnlyReason: "not read yet" };
|
|
224
|
+
|
|
225
|
+
let lastActivity = Date.now();
|
|
226
|
+
let closed = false;
|
|
227
|
+
let closeReason = null;
|
|
228
|
+
let closeResolve = null;
|
|
229
|
+
const closedPromise = new Promise((resolve) => {
|
|
230
|
+
closeResolve = resolve;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const touch = () => {
|
|
234
|
+
lastActivity = Date.now();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const logPath = path.join(
|
|
238
|
+
path.dirname(file),
|
|
239
|
+
`${path.basename(file, path.extname(file))}.canvas-log.jsonl`,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
/* ---- reading ---------------------------------------------------- */
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read the file and everything the canvas needs to know about it.
|
|
246
|
+
*
|
|
247
|
+
* The text is always returned, parsed or not: a file with a syntax error is
|
|
248
|
+
* exactly when the human wants to see it. A parse failure becomes `readOnly`
|
|
249
|
+
* with the reason, never a thrown error.
|
|
250
|
+
*
|
|
251
|
+
* @throws {FsError} when the file itself cannot be read
|
|
252
|
+
*/
|
|
253
|
+
async function current() {
|
|
254
|
+
const source = readText(file);
|
|
255
|
+
const revision = revisionOf(source);
|
|
256
|
+
try {
|
|
257
|
+
const loaded = await readDesignFile(file);
|
|
258
|
+
const diagnostics = deps.validateDesign(loaded.doc);
|
|
259
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
260
|
+
return {
|
|
261
|
+
text: source,
|
|
262
|
+
revision,
|
|
263
|
+
doc: loaded.doc,
|
|
264
|
+
diagnostics,
|
|
265
|
+
readOnly: errors.length > 0,
|
|
266
|
+
readOnlyReason: errors.length ? reasonFromErrors(errors) : null,
|
|
267
|
+
};
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
text: source,
|
|
271
|
+
revision,
|
|
272
|
+
doc: null,
|
|
273
|
+
diagnostics: [],
|
|
274
|
+
readOnly: true,
|
|
275
|
+
readOnlyReason: reasonFromError(error),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function adopt(snapshot) {
|
|
281
|
+
state = snapshot;
|
|
282
|
+
return snapshot;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function reasonFromErrors(errors) {
|
|
286
|
+
const first = errors[0];
|
|
287
|
+
const head = `${first.code}: ${first.message}`;
|
|
288
|
+
const rest = errors.length - 1;
|
|
289
|
+
if (rest <= 0) return head;
|
|
290
|
+
return `${head} (+${rest} more error${rest === 1 ? "" : "s"})`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function reasonFromError(error) {
|
|
294
|
+
const code = (error && error.code) || (error && error.name) || "error";
|
|
295
|
+
return `${code}: ${error && error.message ? error.message : String(error)}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The metadata shape of `/api/state`; the document is never part of it. */
|
|
299
|
+
function statePayload(snapshot, withToken = false) {
|
|
300
|
+
const payload = {
|
|
301
|
+
file: path.basename(file),
|
|
302
|
+
path: file,
|
|
303
|
+
revision: snapshot.revision,
|
|
304
|
+
readOnly: snapshot.readOnly === true,
|
|
305
|
+
readOnlyReason: snapshot.readOnlyReason ?? null,
|
|
306
|
+
canUndo: undoStack.length > 0,
|
|
307
|
+
canRedo: redoStack.length > 0,
|
|
308
|
+
};
|
|
309
|
+
if (withToken) payload.token = token;
|
|
310
|
+
return payload;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* ---- telling the clients ---------------------------------------- */
|
|
314
|
+
|
|
315
|
+
function broadcast(event, payload) {
|
|
316
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
317
|
+
for (const client of [...clients]) {
|
|
318
|
+
try {
|
|
319
|
+
client.res.write(frame);
|
|
320
|
+
} catch {
|
|
321
|
+
clearInterval(client.ping);
|
|
322
|
+
clients.delete(client);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function logChange(entry) {
|
|
328
|
+
if (!writeLog) return;
|
|
329
|
+
try {
|
|
330
|
+
fs.appendFileSync(logPath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
331
|
+
} catch (error) {
|
|
332
|
+
log(`warning: cannot write the change log: ${error.message}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ---- the write funnel ------------------------------------------- */
|
|
337
|
+
|
|
338
|
+
/** The `data-id` of an element, or null. */
|
|
339
|
+
function idOf(node) {
|
|
340
|
+
const attr = (node.attrs || []).find((entry) => entry.name === "data-id");
|
|
341
|
+
const value = attr ? String(attr.value ?? "") : "";
|
|
342
|
+
return value === "" ? null : value;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The elements this edit touched: every op target, whatever the op inserted,
|
|
347
|
+
* and a node renamed by `set data-id`.
|
|
348
|
+
*/
|
|
349
|
+
function touchedIds(beforeDoc, nextDoc, applied) {
|
|
350
|
+
const ids = new Set();
|
|
351
|
+
for (const op of applied) {
|
|
352
|
+
if (op && typeof op.target === "string" && op.target !== "") ids.add(op.target);
|
|
353
|
+
if (op && op.op === "set" && op.prop === "data-id" && typeof op.value === "string") ids.add(op.value);
|
|
354
|
+
}
|
|
355
|
+
const before = deps.buildIdIndex(beforeDoc);
|
|
356
|
+
for (const id of deps.buildIdIndex(nextDoc).keys()) {
|
|
357
|
+
if (!before.has(id)) ids.add(id);
|
|
358
|
+
}
|
|
359
|
+
return ids;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Normalize the elements the edit touched, and nothing else.
|
|
364
|
+
*
|
|
365
|
+
* `normalizeDocument` walks a whole tree, so it is handed one touched subtree
|
|
366
|
+
* at a time. Its text output is discarded: the text is produced once, by
|
|
367
|
+
* diffing the whole document against the one that was read, which is what
|
|
368
|
+
* keeps every untouched byte where it was (format-v3 §5.1, "minimal change").
|
|
369
|
+
*/
|
|
370
|
+
function normalizeTouched(doc, ids) {
|
|
371
|
+
const normalized = [];
|
|
372
|
+
const dropped = [];
|
|
373
|
+
if (ids.size === 0) return { normalized, dropped };
|
|
374
|
+
|
|
375
|
+
const visit = (node) => {
|
|
376
|
+
for (const child of node.children || []) {
|
|
377
|
+
if (!child || child.kind !== "element") continue;
|
|
378
|
+
const id = idOf(child);
|
|
379
|
+
if (id !== null && ids.has(id)) {
|
|
380
|
+
const result = deps.normalizeDocument(child, { serialize: () => "" });
|
|
381
|
+
normalized.push(...result.normalized);
|
|
382
|
+
dropped.push(...result.dropped);
|
|
383
|
+
continue; // the subtree is normalized whole
|
|
384
|
+
}
|
|
385
|
+
visit(child);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
visit(doc);
|
|
389
|
+
return { normalized, dropped };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Steps 6 and 7 of the funnel: normalize only what changed, serialize against
|
|
394
|
+
* the document that was read, write atomically, adopt the new state.
|
|
395
|
+
*/
|
|
396
|
+
function commit(snap, result, applied) {
|
|
397
|
+
const norm = normalizeTouched(result.doc, touchedIds(snap.doc, result.doc, applied));
|
|
398
|
+
const text = deps.serialize(snap.doc, result.doc);
|
|
399
|
+
const diagnostics = deps.validateDesign(result.doc);
|
|
400
|
+
const changed = text !== snap.text;
|
|
401
|
+
if (changed) writeAtomic(file, text);
|
|
402
|
+
const revision = revisionOf(text);
|
|
403
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
404
|
+
const committed = adopt({
|
|
405
|
+
text,
|
|
406
|
+
revision,
|
|
407
|
+
doc: result.doc,
|
|
408
|
+
diagnostics,
|
|
409
|
+
readOnly: errors.length > 0,
|
|
410
|
+
readOnlyReason: errors.length ? reasonFromErrors(errors) : null,
|
|
411
|
+
});
|
|
412
|
+
return {
|
|
413
|
+
text,
|
|
414
|
+
revision,
|
|
415
|
+
diagnostics,
|
|
416
|
+
changed,
|
|
417
|
+
normalized: norm.normalized,
|
|
418
|
+
dropped: norm.dropped,
|
|
419
|
+
state: committed,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Apply ops to the file: the funnel of lib/node/verbs/add.mjs, with the
|
|
425
|
+
* canvas's concurrency rule on top. `baseRevision` never decides what is
|
|
426
|
+
* written — the file on disk is the authority — it only reports staleness.
|
|
427
|
+
*/
|
|
428
|
+
async function writeOps(res, body) {
|
|
429
|
+
const ops = Array.isArray(body.ops) ? body.ops : null;
|
|
430
|
+
if (ops === null || ops.length === 0) {
|
|
431
|
+
json(res, 400, { ok: false, code: "ops/empty", message: 'Send { baseRevision, ops: [...] } with at least one op.' });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const baseRevision = typeof body.baseRevision === "string" ? body.baseRevision : null;
|
|
435
|
+
|
|
436
|
+
let snap;
|
|
437
|
+
try {
|
|
438
|
+
snap = adopt(await current());
|
|
439
|
+
} catch (error) {
|
|
440
|
+
json(res, 409, { ok: false, code: error.code || "fs/unreadable", message: error.message, ...statePayload(state) });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const rebased = baseRevision !== null && baseRevision !== snap.revision;
|
|
445
|
+
|
|
446
|
+
if (!snap.doc) {
|
|
447
|
+
json(res, 409, {
|
|
448
|
+
ok: false,
|
|
449
|
+
code: "file/unparseable",
|
|
450
|
+
message: `The file does not parse, so there is no document to apply ops to. ${snap.readOnlyReason}`,
|
|
451
|
+
diagnostics: [],
|
|
452
|
+
rebased,
|
|
453
|
+
changed: false,
|
|
454
|
+
...statePayload(snap),
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const before = snap.diagnostics;
|
|
460
|
+
let result;
|
|
461
|
+
try {
|
|
462
|
+
result = deps.applyOps(snap.doc, ops);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error && error.name === "OpsError") {
|
|
465
|
+
json(res, 409, {
|
|
466
|
+
ok: false,
|
|
467
|
+
code: error.code || "ops/bad-op",
|
|
468
|
+
message: error.message,
|
|
469
|
+
opIndex: Number.isInteger(error.opIndex) ? error.opIndex : null,
|
|
470
|
+
diagnostics: [],
|
|
471
|
+
rebased,
|
|
472
|
+
changed: false,
|
|
473
|
+
...statePayload(snap),
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Step 5: refuse unless the edit introduces no new error.
|
|
481
|
+
const applied = deps.validateDesign(result.doc);
|
|
482
|
+
const introduced = deps.newErrors(before, applied);
|
|
483
|
+
if (introduced.length > 0 && !force) {
|
|
484
|
+
json(res, 409, {
|
|
485
|
+
ok: false,
|
|
486
|
+
code: "cli/would-introduce-errors",
|
|
487
|
+
message: `The change would introduce ${introduced.length} new error${introduced.length === 1 ? "" : "s"}.`,
|
|
488
|
+
diagnostics: introduced,
|
|
489
|
+
rebased,
|
|
490
|
+
changed: false,
|
|
491
|
+
...statePayload(snap),
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Steps 6 and 7: normalize what the edit touched, serialize against the
|
|
497
|
+
// document that was read, write atomically.
|
|
498
|
+
const done = commit(snap, result, result.applied);
|
|
499
|
+
|
|
500
|
+
pushHistory(undoStack, {
|
|
501
|
+
ops: result.applied,
|
|
502
|
+
inverse: result.inverse,
|
|
503
|
+
beforeKeys: deps.errorKeys(before),
|
|
504
|
+
afterKeys: deps.errorKeys(done.diagnostics),
|
|
505
|
+
revision: done.revision,
|
|
506
|
+
});
|
|
507
|
+
redoStack.length = 0;
|
|
508
|
+
|
|
509
|
+
if (done.changed) broadcast("changed", { revision: done.revision, reason: "ops" });
|
|
510
|
+
logChange({ ts: new Date().toISOString(), source: "ops", revision: done.revision, ops: jsonOps(result.applied), inverse: jsonOps(result.inverse) });
|
|
511
|
+
|
|
512
|
+
json(res, 200, {
|
|
513
|
+
ok: true,
|
|
514
|
+
revision: done.revision,
|
|
515
|
+
rebased,
|
|
516
|
+
changed: done.changed,
|
|
517
|
+
applied: jsonOps(result.applied),
|
|
518
|
+
inverse: jsonOps(result.inverse),
|
|
519
|
+
normalized: done.normalized,
|
|
520
|
+
dropped: done.dropped,
|
|
521
|
+
diagnostics: done.diagnostics,
|
|
522
|
+
...statePayload(done.state),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Undo or redo one step. The stacks hold op lists, not snapshots: reversing a
|
|
528
|
+
* gesture is one op list, and applying it to whatever the file says now keeps
|
|
529
|
+
* a concurrent agent edit.
|
|
530
|
+
*
|
|
531
|
+
* An undo restores a state that was already accepted, including any error that
|
|
532
|
+
* state carried, so the refusal test is against the union of the errors the
|
|
533
|
+
* file has now and the errors the restored state had — never against zero.
|
|
534
|
+
*/
|
|
535
|
+
async function writeHistory(res, direction) {
|
|
536
|
+
const undoing = direction === "undo";
|
|
537
|
+
const from = undoing ? undoStack : redoStack;
|
|
538
|
+
const to = undoing ? redoStack : undoStack;
|
|
539
|
+
|
|
540
|
+
if (from.length === 0) {
|
|
541
|
+
json(res, 200, {
|
|
542
|
+
ok: false,
|
|
543
|
+
code: "history/empty",
|
|
544
|
+
message: `Nothing to ${direction}.`,
|
|
545
|
+
revision: state.revision,
|
|
546
|
+
rebased: false,
|
|
547
|
+
changed: false,
|
|
548
|
+
inverse: [],
|
|
549
|
+
diagnostics: state.diagnostics,
|
|
550
|
+
...statePayload(state),
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let snap;
|
|
556
|
+
try {
|
|
557
|
+
snap = adopt(await current());
|
|
558
|
+
} catch (error) {
|
|
559
|
+
json(res, 409, { ok: false, code: error.code || "fs/unreadable", message: error.message, ...statePayload(state) });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (!snap.doc) {
|
|
563
|
+
json(res, 409, {
|
|
564
|
+
ok: false,
|
|
565
|
+
code: "file/unparseable",
|
|
566
|
+
message: `The file does not parse, so there is nothing to ${direction}. ${snap.readOnlyReason}`,
|
|
567
|
+
diagnostics: [],
|
|
568
|
+
changed: false,
|
|
569
|
+
...statePayload(snap),
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const step = from[from.length - 1];
|
|
575
|
+
const rebased = snap.revision !== step.revision;
|
|
576
|
+
|
|
577
|
+
let result;
|
|
578
|
+
try {
|
|
579
|
+
result = deps.applyOps(snap.doc, step.inverse);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
json(res, 409, {
|
|
582
|
+
ok: false,
|
|
583
|
+
code: error.code || "ops/bad-op",
|
|
584
|
+
message: `Cannot ${direction}: ${error.message}`,
|
|
585
|
+
opIndex: Number.isInteger(error.opIndex) ? error.opIndex : null,
|
|
586
|
+
diagnostics: [],
|
|
587
|
+
rebased,
|
|
588
|
+
changed: false,
|
|
589
|
+
...statePayload(snap),
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const after = deps.validateDesign(result.doc);
|
|
595
|
+
const known = new Set([...deps.errorKeys(snap.diagnostics), ...(undoing ? step.beforeKeys : step.afterKeys)]);
|
|
596
|
+
const introduced = after.filter((d) => d.severity === "error" && !known.has(deps.diagnosticKey(d)));
|
|
597
|
+
if (introduced.length > 0 && !force) {
|
|
598
|
+
json(res, 409, {
|
|
599
|
+
ok: false,
|
|
600
|
+
code: "cli/would-introduce-errors",
|
|
601
|
+
message: `The ${direction} would introduce ${introduced.length} new error${introduced.length === 1 ? "" : "s"}.`,
|
|
602
|
+
diagnostics: introduced,
|
|
603
|
+
rebased,
|
|
604
|
+
changed: false,
|
|
605
|
+
...statePayload(snap),
|
|
606
|
+
});
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const done = commit(snap, result, result.applied);
|
|
611
|
+
|
|
612
|
+
from.pop();
|
|
613
|
+
pushHistory(to, {
|
|
614
|
+
ops: result.applied,
|
|
615
|
+
inverse: result.inverse,
|
|
616
|
+
beforeKeys: deps.errorKeys(snap.diagnostics),
|
|
617
|
+
afterKeys: deps.errorKeys(done.diagnostics),
|
|
618
|
+
revision: done.revision,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
if (done.changed) broadcast("changed", { revision: done.revision, reason: direction });
|
|
622
|
+
logChange({ ts: new Date().toISOString(), source: direction, revision: done.revision, ops: jsonOps(result.applied), inverse: jsonOps(result.inverse) });
|
|
623
|
+
|
|
624
|
+
json(res, 200, {
|
|
625
|
+
ok: true,
|
|
626
|
+
revision: done.revision,
|
|
627
|
+
rebased,
|
|
628
|
+
changed: done.changed,
|
|
629
|
+
applied: jsonOps(result.applied),
|
|
630
|
+
inverse: jsonOps(result.inverse),
|
|
631
|
+
normalized: done.normalized,
|
|
632
|
+
dropped: done.dropped,
|
|
633
|
+
diagnostics: done.diagnostics,
|
|
634
|
+
...statePayload(done.state),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function pushHistory(stack, entry) {
|
|
639
|
+
stack.push(entry);
|
|
640
|
+
while (stack.length > MAX_HISTORY) stack.shift();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* A whole-text save. It exists so a file that does not parse can still be
|
|
645
|
+
* repaired from the canvas: the submitted text is parsed, checked against the
|
|
646
|
+
* errors the current file already has, and written only when it adds none.
|
|
647
|
+
*
|
|
648
|
+
* A stale `baseRevision` is reported, not obeyed, exactly as for ops — the
|
|
649
|
+
* human pressed save on the text they can see. The history is cleared,
|
|
650
|
+
* because every stored inverse describes a document that no longer exists.
|
|
651
|
+
*/
|
|
652
|
+
async function writeSave(res, body) {
|
|
653
|
+
if (typeof body.text !== "string") {
|
|
654
|
+
json(res, 400, { ok: false, code: "save/no-text", message: "Send { baseRevision, text } with text as a string." });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const text = body.text;
|
|
658
|
+
|
|
659
|
+
let snap;
|
|
660
|
+
try {
|
|
661
|
+
snap = adopt(await current());
|
|
662
|
+
} catch (error) {
|
|
663
|
+
json(res, 409, { ok: false, code: error.code || "fs/unreadable", message: error.message, ...statePayload(state) });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const rebased = typeof body.baseRevision === "string" && snap.revision !== null && body.baseRevision !== snap.revision;
|
|
668
|
+
const format = deps.detectFormat(file, text);
|
|
669
|
+
if (format !== "html") {
|
|
670
|
+
json(res, 409, {
|
|
671
|
+
ok: false,
|
|
672
|
+
code: "save/format",
|
|
673
|
+
message: `A whole-text save writes HTML. ${path.basename(file)} is read as ${format}.`,
|
|
674
|
+
diagnostics: [],
|
|
675
|
+
rebased,
|
|
676
|
+
changed: false,
|
|
677
|
+
...statePayload(snap),
|
|
678
|
+
});
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let doc;
|
|
683
|
+
try {
|
|
684
|
+
doc = deps.parse(text, { file, format, source: text });
|
|
685
|
+
if (doc && typeof doc.then === "function") doc = await doc;
|
|
686
|
+
} catch (error) {
|
|
687
|
+
json(res, 400, {
|
|
688
|
+
ok: false,
|
|
689
|
+
code: "save/parse",
|
|
690
|
+
message: `The text does not parse: ${error.message}`,
|
|
691
|
+
diagnostics: [],
|
|
692
|
+
rebased,
|
|
693
|
+
changed: false,
|
|
694
|
+
...statePayload(snap),
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (!doc || typeof doc !== "object") {
|
|
699
|
+
json(res, 400, { ok: false, code: "save/parse", message: "The text produced no document.", diagnostics: [], rebased, changed: false, ...statePayload(snap) });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const diagnostics = deps.validateDesign(doc);
|
|
704
|
+
const introduced = deps.newErrors(snap.doc ? snap.diagnostics : [], diagnostics);
|
|
705
|
+
if (introduced.length > 0 && !force) {
|
|
706
|
+
json(res, 409, {
|
|
707
|
+
ok: false,
|
|
708
|
+
code: "cli/would-introduce-errors",
|
|
709
|
+
message: `The save would introduce ${introduced.length} new error${introduced.length === 1 ? "" : "s"}.`,
|
|
710
|
+
diagnostics: introduced,
|
|
711
|
+
rebased,
|
|
712
|
+
changed: false,
|
|
713
|
+
...statePayload(snap),
|
|
714
|
+
});
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const changed = text !== snap.text;
|
|
719
|
+
if (changed) writeAtomic(file, text);
|
|
720
|
+
const revision = revisionOf(text);
|
|
721
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
722
|
+
const committed = adopt({
|
|
723
|
+
text,
|
|
724
|
+
revision,
|
|
725
|
+
doc,
|
|
726
|
+
diagnostics,
|
|
727
|
+
readOnly: errors.length > 0,
|
|
728
|
+
readOnlyReason: errors.length ? reasonFromErrors(errors) : null,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
undoStack.length = 0;
|
|
732
|
+
redoStack.length = 0;
|
|
733
|
+
|
|
734
|
+
if (changed) broadcast("changed", { revision, reason: "save" });
|
|
735
|
+
logChange({ ts: new Date().toISOString(), source: "save", revision, bytes: Buffer.byteLength(text, "utf8") });
|
|
736
|
+
|
|
737
|
+
json(res, 200, {
|
|
738
|
+
ok: true,
|
|
739
|
+
revision,
|
|
740
|
+
rebased,
|
|
741
|
+
changed,
|
|
742
|
+
applied: [],
|
|
743
|
+
inverse: [],
|
|
744
|
+
diagnostics,
|
|
745
|
+
...statePayload(committed),
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/* ---- HTTP ------------------------------------------------------- */
|
|
750
|
+
|
|
751
|
+
function handleStatic(req, res, url) {
|
|
752
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
753
|
+
text(res, 405, "Method not allowed\n");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const hit = routeFor(routes, url.pathname);
|
|
757
|
+
if (!hit) {
|
|
758
|
+
text(res, 404, "Not found\n");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (!isInside(PACKAGE_ROOT, hit.file)) {
|
|
762
|
+
text(res, 403, "Forbidden\n");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
let body;
|
|
767
|
+
try {
|
|
768
|
+
body = fs.readFileSync(hit.file);
|
|
769
|
+
} catch {
|
|
770
|
+
if (hit.route === "/") {
|
|
771
|
+
// The shell is not built yet. The service still explains itself rather
|
|
772
|
+
// than answering a bare 404 at the URL the human was told to open.
|
|
773
|
+
const html = shellPlaceholder();
|
|
774
|
+
text(res, 200, html, "text/html; charset=utf-8");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
text(res, 404, "Not found\n");
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
res.writeHead(200, {
|
|
782
|
+
"Content-Type": contentTypeOf(hit.file),
|
|
783
|
+
"Content-Length": body.length,
|
|
784
|
+
"Cache-Control": "no-store",
|
|
785
|
+
"X-Content-Type-Options": "nosniff",
|
|
786
|
+
});
|
|
787
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* `POST /api/export` — write a deliverable next to the design file.
|
|
792
|
+
*
|
|
793
|
+
* The canvas cannot write files, so the service writes them. The pages come from
|
|
794
|
+
* `lib/export-html.mjs`, the same functions `design.mjs export` calls, so a deliverable
|
|
795
|
+
* taken from the canvas and one taken from the command line are the same bytes.
|
|
796
|
+
*
|
|
797
|
+
* ## Why every file is named `.export.html`
|
|
798
|
+
*
|
|
799
|
+
* In this format the design file is an HTML file too. `home.html` next to `design.html`
|
|
800
|
+
* is therefore ambiguous: it could be the deliverable for the page `home`, or another
|
|
801
|
+
* design. It is also what a person would call the file, which is how the ambiguity gets
|
|
802
|
+
* created in the first place. `<pageId>.export.html` cannot be mistaken for either.
|
|
803
|
+
*
|
|
804
|
+
* The one target that is refused outright is the design file itself: a layout whose page
|
|
805
|
+
* id and file name line up (page `home` in `home.export.html`) would otherwise overwrite
|
|
806
|
+
* the design with its own export.
|
|
807
|
+
*/
|
|
808
|
+
async function writeExport(res, body) {
|
|
809
|
+
let snap;
|
|
810
|
+
try {
|
|
811
|
+
snap = adopt(await current());
|
|
812
|
+
} catch (error) {
|
|
813
|
+
json(res, 409, { ok: false, code: error.code || "fs/unreadable", message: error.message, ...statePayload(state) });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (snap.readOnly === true || !snap.doc) {
|
|
818
|
+
json(res, 409, {
|
|
819
|
+
ok: false,
|
|
820
|
+
code: "export/read-only",
|
|
821
|
+
message: `The file does not parse, so there is nothing to export. ${snap.readOnlyReason ?? ""}`.trim(),
|
|
822
|
+
diagnostics: snap.diagnostics ?? [],
|
|
823
|
+
...statePayload(snap),
|
|
824
|
+
});
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const options = {
|
|
829
|
+
resolveTokens: body.resolveTokens === true,
|
|
830
|
+
page: typeof body.page === "string" && body.page !== "" ? body.page : undefined,
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
let results;
|
|
834
|
+
try {
|
|
835
|
+
if (body.all === true) {
|
|
836
|
+
results = deps.exportAllPages(snap.doc, options).map((entry) => ({ id: entry.page.id, html: entry.html }));
|
|
837
|
+
} else {
|
|
838
|
+
const one = deps.exportPage(snap.doc, options);
|
|
839
|
+
results = [{ id: one.page.id, html: one.html }];
|
|
840
|
+
}
|
|
841
|
+
} catch (error) {
|
|
842
|
+
json(res, 409, {
|
|
843
|
+
ok: false,
|
|
844
|
+
code: error.code || "export/failed",
|
|
845
|
+
message: error.message,
|
|
846
|
+
hint: error.hint ?? null,
|
|
847
|
+
diagnostics: [],
|
|
848
|
+
...statePayload(snap),
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const dir = path.dirname(file);
|
|
854
|
+
const written = [];
|
|
855
|
+
for (const entry of results) {
|
|
856
|
+
const target = path.join(dir, `${entry.id}.export.html`);
|
|
857
|
+
if (path.resolve(target) === path.resolve(file)) {
|
|
858
|
+
json(res, 409, {
|
|
859
|
+
ok: false,
|
|
860
|
+
code: "export/would-overwrite-design",
|
|
861
|
+
message: `Exporting page ${entry.id} would write over ${path.basename(file)}.`,
|
|
862
|
+
hint: "Rename the page or the design file; the export is named <page>.export.html.",
|
|
863
|
+
diagnostics: [],
|
|
864
|
+
...statePayload(snap),
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
writeAtomic(target, entry.html);
|
|
869
|
+
written.push({ page: entry.id, path: target, bytes: Buffer.byteLength(entry.html, "utf8") });
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
json(res, 200, { ok: true, files: written, ...statePayload(snap) });
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function shellPlaceholder() {
|
|
876
|
+
return `<!doctype html>
|
|
877
|
+
<html lang="en">
|
|
878
|
+
<head><meta charset="utf-8"><title>justSkill canvas</title></head>
|
|
879
|
+
<body style="font:14px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;background:#0b0d12;color:#e5e7eb;padding:40px;max-width:60ch">
|
|
880
|
+
<h1 style="font-size:16px">The canvas shell is not built yet</h1>
|
|
881
|
+
<p>This service is running, but <code>${escapeHtml(SHELL_FILE)}</code> does not exist.</p>
|
|
882
|
+
<p>The design file is served as text at <code>/api/file?t=<token></code>; its metadata is at <code>/api/state?t=<token></code>.</p>
|
|
883
|
+
</body>
|
|
884
|
+
</html>
|
|
885
|
+
`;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function readJsonBody(req, res) {
|
|
889
|
+
const declared = Number(req.headers["content-length"] ?? 0);
|
|
890
|
+
const contentType = req.headers["content-type"];
|
|
891
|
+
|
|
892
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
893
|
+
answerAndDrain(req, res, 413, {
|
|
894
|
+
ok: false,
|
|
895
|
+
code: "body/too-large",
|
|
896
|
+
message: `The body is limited to ${MAX_BODY_BYTES} bytes.`,
|
|
897
|
+
});
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
if (declared > 0 && !isJsonContentType(contentType)) {
|
|
901
|
+
answerAndDrain(req, res, 415, {
|
|
902
|
+
ok: false,
|
|
903
|
+
code: "body/content-type",
|
|
904
|
+
message: "Send Content-Type: application/json.",
|
|
905
|
+
});
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const chunks = [];
|
|
910
|
+
let size = 0;
|
|
911
|
+
try {
|
|
912
|
+
for await (const chunk of req) {
|
|
913
|
+
size += chunk.length;
|
|
914
|
+
if (size > MAX_BODY_BYTES) {
|
|
915
|
+
answerAndDrain(req, res, 413, {
|
|
916
|
+
ok: false,
|
|
917
|
+
code: "body/too-large",
|
|
918
|
+
message: `The body is limited to ${MAX_BODY_BYTES} bytes.`,
|
|
919
|
+
});
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
chunks.push(chunk);
|
|
923
|
+
}
|
|
924
|
+
} catch {
|
|
925
|
+
try {
|
|
926
|
+
res.end();
|
|
927
|
+
} catch {
|
|
928
|
+
/* the client is gone */
|
|
929
|
+
}
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
934
|
+
if (raw.trim() === "") return {};
|
|
935
|
+
|
|
936
|
+
if (!isJsonContentType(contentType)) {
|
|
937
|
+
json(res, 415, { ok: false, code: "body/content-type", message: "Send Content-Type: application/json." });
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
let parsed;
|
|
941
|
+
try {
|
|
942
|
+
parsed = JSON.parse(raw);
|
|
943
|
+
} catch (error) {
|
|
944
|
+
json(res, 400, { ok: false, code: "body/bad-json", message: error.message });
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
948
|
+
json(res, 400, { ok: false, code: "body/bad-json", message: "The body must be a JSON object." });
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
return parsed;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function handle(req, res) {
|
|
955
|
+
// A keep-alive socket can outlive close(), and a service that has already
|
|
956
|
+
// said goodbye must not answer on it. Drop the request instead.
|
|
957
|
+
if (closed) {
|
|
958
|
+
req.destroy();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
touch();
|
|
962
|
+
|
|
963
|
+
let url;
|
|
964
|
+
try {
|
|
965
|
+
url = new URL(String(req.url || "/"), `http://${req.headers.host || "127.0.0.1"}`);
|
|
966
|
+
} catch {
|
|
967
|
+
text(res, 400, "Bad request\n");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Guard 1: the DNS-rebinding guard, on every request, static or not.
|
|
972
|
+
if (!isLoopbackHost(req.headers.host)) {
|
|
973
|
+
json(res, 403, { ok: false, code: "host-not-allowed", message: `Host ${JSON.stringify(req.headers.host ?? "")} is not loopback.` });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Guard 2: when an Origin is present it must be loopback and match the Host.
|
|
978
|
+
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
|
979
|
+
json(res, 403, { ok: false, code: "origin-not-allowed", message: `Blocked origin ${JSON.stringify(req.headers.origin ?? "")}.` });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const route = url.pathname;
|
|
984
|
+
|
|
985
|
+
// Statics are token-free on purpose: the browser cannot put a header on a
|
|
986
|
+
// module import, and a token in the URL would travel with every import.
|
|
987
|
+
if (!route.startsWith("/api/")) {
|
|
988
|
+
handleStatic(req, res, url);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Guard 3: the capability token.
|
|
993
|
+
const provided = url.searchParams.get("t") || req.headers["x-design-token"];
|
|
994
|
+
if (!tokenMatches(provided, token)) {
|
|
995
|
+
json(res, 401, { ok: false, code: "bad-token", message: "Missing or wrong access token." });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (route === "/api/events" && req.method === "GET") {
|
|
1000
|
+
res.writeHead(200, {
|
|
1001
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1002
|
+
"Cache-Control": "no-store",
|
|
1003
|
+
Connection: "keep-alive",
|
|
1004
|
+
});
|
|
1005
|
+
// A client arriving cancels a goodbye that a reload already announced.
|
|
1006
|
+
// `beforeunload` cannot tell a reload from a closed tab, and a service
|
|
1007
|
+
// that dies on F5 is a service the author has to restart by hand.
|
|
1008
|
+
cancelGoodbye();
|
|
1009
|
+
const client = { res, ping: null };
|
|
1010
|
+
clients.add(client);
|
|
1011
|
+
res.write(`event: hello\ndata: ${JSON.stringify(statePayload(state))}\n\n`);
|
|
1012
|
+
client.ping = setInterval(() => {
|
|
1013
|
+
touch();
|
|
1014
|
+
try {
|
|
1015
|
+
res.write(": ping\n\n");
|
|
1016
|
+
} catch {
|
|
1017
|
+
/* the client is gone; `close` on the request cleans up */
|
|
1018
|
+
}
|
|
1019
|
+
}, PING_MS);
|
|
1020
|
+
req.on("close", () => {
|
|
1021
|
+
clearInterval(client.ping);
|
|
1022
|
+
clients.delete(client);
|
|
1023
|
+
});
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (route === "/api/state" && req.method === "GET") {
|
|
1028
|
+
let snapshot;
|
|
1029
|
+
try {
|
|
1030
|
+
snapshot = adopt(await current());
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
snapshot = {
|
|
1033
|
+
text: null,
|
|
1034
|
+
revision: null,
|
|
1035
|
+
doc: null,
|
|
1036
|
+
diagnostics: [],
|
|
1037
|
+
readOnly: true,
|
|
1038
|
+
readOnlyReason: reasonFromError(error),
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
json(res, 200, statePayload(snapshot, true));
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (route === "/api/file" && req.method === "GET") {
|
|
1046
|
+
let snapshot;
|
|
1047
|
+
try {
|
|
1048
|
+
snapshot = adopt(await current());
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
json(res, 409, { ok: false, code: error.code || "fs/unreadable", message: error.message, ...statePayload(state) });
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
text(res, 200, snapshot.text, "text/plain; charset=utf-8", {
|
|
1054
|
+
"X-Design-Revision": String(snapshot.revision),
|
|
1055
|
+
"X-Content-Type-Options": "nosniff",
|
|
1056
|
+
});
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (req.method !== "POST") {
|
|
1061
|
+
json(res, 405, { ok: false, code: "method-not-allowed", message: `${req.method} is not allowed on ${route}.` });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Guard 4: the body cap and the JSON-only rule.
|
|
1066
|
+
const body = await readJsonBody(req, res);
|
|
1067
|
+
if (body === null) return;
|
|
1068
|
+
|
|
1069
|
+
switch (route) {
|
|
1070
|
+
case "/api/ops":
|
|
1071
|
+
await writeOps(res, body);
|
|
1072
|
+
return;
|
|
1073
|
+
case "/api/undo":
|
|
1074
|
+
await writeHistory(res, "undo");
|
|
1075
|
+
return;
|
|
1076
|
+
case "/api/redo":
|
|
1077
|
+
await writeHistory(res, "redo");
|
|
1078
|
+
return;
|
|
1079
|
+
case "/api/save":
|
|
1080
|
+
await writeSave(res, body);
|
|
1081
|
+
return;
|
|
1082
|
+
case "/api/export":
|
|
1083
|
+
await writeExport(res, body);
|
|
1084
|
+
return;
|
|
1085
|
+
case "/api/bye":
|
|
1086
|
+
json(res, 200, { ok: true, message: "The canvas service is closing." });
|
|
1087
|
+
res.on("finish", () => goodbye());
|
|
1088
|
+
return;
|
|
1089
|
+
default:
|
|
1090
|
+
json(res, 404, { ok: false, code: "not-found", message: route });
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const server = http.createServer((req, res) => {
|
|
1096
|
+
handle(req, res).catch((error) => {
|
|
1097
|
+
log(`error: ${error && error.message ? error.message : error}`);
|
|
1098
|
+
if (!res.headersSent) {
|
|
1099
|
+
json(res, 500, { ok: false, code: "internal", message: error && error.message ? error.message : String(error) });
|
|
1100
|
+
} else {
|
|
1101
|
+
try {
|
|
1102
|
+
res.end();
|
|
1103
|
+
} catch {
|
|
1104
|
+
/* the response is already gone */
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
/* ---- watching the file ------------------------------------------ */
|
|
1111
|
+
|
|
1112
|
+
// Watch the directory, not the file: an atomic write replaces the file by
|
|
1113
|
+
// rename, which detaches a watcher bound to the old inode.
|
|
1114
|
+
let watcherClose = null;
|
|
1115
|
+
const base = path.basename(file);
|
|
1116
|
+
const onDiskChange = () => {
|
|
1117
|
+
if (closed) return;
|
|
1118
|
+
let source;
|
|
1119
|
+
try {
|
|
1120
|
+
source = readText(file);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
broadcast("changed", { revision: null, reason: "unreadable" });
|
|
1123
|
+
log(`warning: cannot read ${file}: ${error.message}`);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const revision = revisionOf(source);
|
|
1127
|
+
if (revision === state.revision) return; // our own write, or a no-op touch
|
|
1128
|
+
state = { ...state, text: source, revision };
|
|
1129
|
+
broadcast("changed", { revision, reason: "disk" });
|
|
1130
|
+
log(`changed on disk: revision ${revision}`);
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
try {
|
|
1134
|
+
watcherClose = walkWatch(path.dirname(file), (events) => {
|
|
1135
|
+
if (events.some((event) => !event.filename || event.filename === base)) onDiskChange();
|
|
1136
|
+
});
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
log(`warning: cannot watch ${path.dirname(file)}: ${error.message}`);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/* ---- lifecycle --------------------------------------------------- */
|
|
1142
|
+
|
|
1143
|
+
let goodbyeTimer = null;
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The canvas said goodbye. Wait before believing it.
|
|
1147
|
+
*
|
|
1148
|
+
* `beforeunload` fires on a reload too, and the reload reconnects a moment
|
|
1149
|
+
* later. Closing on the spot turned every refresh into a dead port. A new SSE
|
|
1150
|
+
* client in the window below cancels the close; if none arrives, the tab
|
|
1151
|
+
* really is gone.
|
|
1152
|
+
*/
|
|
1153
|
+
function goodbye() {
|
|
1154
|
+
if (closed || goodbyeTimer) return;
|
|
1155
|
+
goodbyeTimer = setTimeout(() => {
|
|
1156
|
+
goodbyeTimer = null;
|
|
1157
|
+
if (clients.size === 0) close("the browser closed the canvas");
|
|
1158
|
+
}, GOODBYE_GRACE_MS);
|
|
1159
|
+
goodbyeTimer.unref?.();
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function cancelGoodbye() {
|
|
1163
|
+
if (!goodbyeTimer) return;
|
|
1164
|
+
clearTimeout(goodbyeTimer);
|
|
1165
|
+
goodbyeTimer = null;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function close(reason = "closed") {
|
|
1169
|
+
if (closed) return;
|
|
1170
|
+
closed = true;
|
|
1171
|
+
closeReason = String(reason);
|
|
1172
|
+
|
|
1173
|
+
if (ttlTimer) clearInterval(ttlTimer);
|
|
1174
|
+
if (watcherClose) {
|
|
1175
|
+
try {
|
|
1176
|
+
watcherClose();
|
|
1177
|
+
} catch {
|
|
1178
|
+
/* already closed */
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
process.off("SIGINT", onSigint);
|
|
1182
|
+
process.off("SIGTERM", onSigterm);
|
|
1183
|
+
|
|
1184
|
+
for (const client of clients) {
|
|
1185
|
+
clearInterval(client.ping);
|
|
1186
|
+
try {
|
|
1187
|
+
client.res.write(`event: bye\ndata: ${JSON.stringify({ reason: closeReason })}\n\n`);
|
|
1188
|
+
client.res.end();
|
|
1189
|
+
} catch {
|
|
1190
|
+
/* the client is gone */
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
clients.clear();
|
|
1194
|
+
|
|
1195
|
+
server.close();
|
|
1196
|
+
// Drop idle keep-alive sockets now. One could otherwise be reused by a
|
|
1197
|
+
// request that arrives after close, and answered by a service that is gone.
|
|
1198
|
+
try {
|
|
1199
|
+
server.closeIdleConnections?.();
|
|
1200
|
+
} catch {
|
|
1201
|
+
/* older Node has no such method */
|
|
1202
|
+
}
|
|
1203
|
+
// The delay lets a response that is still in flight flush before the rest
|
|
1204
|
+
// of the connections are dropped.
|
|
1205
|
+
const reap = setTimeout(() => {
|
|
1206
|
+
try {
|
|
1207
|
+
server.closeAllConnections?.();
|
|
1208
|
+
} catch {
|
|
1209
|
+
/* older Node has no such method */
|
|
1210
|
+
}
|
|
1211
|
+
}, 100);
|
|
1212
|
+
reap.unref?.();
|
|
1213
|
+
|
|
1214
|
+
log(`closed: ${closeReason}`);
|
|
1215
|
+
closeResolve(closeReason);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const onSigint = () => close("signal SIGINT");
|
|
1219
|
+
const onSigterm = () => close("signal SIGTERM");
|
|
1220
|
+
process.on("SIGINT", onSigint);
|
|
1221
|
+
process.on("SIGTERM", onSigterm);
|
|
1222
|
+
|
|
1223
|
+
let ttlTimer = null;
|
|
1224
|
+
if (ttlMs > 0) {
|
|
1225
|
+
const every = Math.max(50, Math.min(TTL_TICK_MAX_MS, Math.floor(ttlMs / 4)));
|
|
1226
|
+
ttlTimer = setInterval(() => {
|
|
1227
|
+
if (closed) return;
|
|
1228
|
+
if (clients.size > 0) return; // an open SSE client is activity
|
|
1229
|
+
if (Date.now() - lastActivity >= ttlMs) close(`idle for ${ttlMinutes} minute${ttlMinutes === 1 ? "" : "s"}`);
|
|
1230
|
+
}, every);
|
|
1231
|
+
ttlTimer.unref?.();
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const port = await new Promise((resolve, reject) => {
|
|
1235
|
+
server.once("error", reject);
|
|
1236
|
+
server.listen(options.port ?? 0, "127.0.0.1", () => {
|
|
1237
|
+
const address = server.address();
|
|
1238
|
+
resolve(typeof address === "object" && address ? address.port : 0);
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
// Read once before the first client can ask, so `/api/state` is never blank.
|
|
1243
|
+
try {
|
|
1244
|
+
adopt(await current());
|
|
1245
|
+
} catch {
|
|
1246
|
+
/* the file was readable a moment ago; the first request re-reads it */
|
|
1247
|
+
}
|
|
1248
|
+
if (state.readOnly) {
|
|
1249
|
+
log(`warning: ${state.readOnlyReason}`);
|
|
1250
|
+
log("the canvas opens read-only until the file is valid again");
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const url = `http://127.0.0.1:${port}/?t=${token}`;
|
|
1254
|
+
return {
|
|
1255
|
+
url,
|
|
1256
|
+
port,
|
|
1257
|
+
token,
|
|
1258
|
+
file,
|
|
1259
|
+
routes,
|
|
1260
|
+
close,
|
|
1261
|
+
closed: closedPromise,
|
|
1262
|
+
state: () => state,
|
|
1263
|
+
};
|
|
1264
|
+
}
|