visualproof 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -11
- package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
- package/dist/assets/tips/phone-fingers.light.webp +0 -0
- package/dist/assets/tips/tour-agent.webp +0 -0
- package/dist/assets/tips/tour-arrange.webp +0 -0
- package/dist/assets/tips/tour-artwork.webp +0 -0
- package/dist/assets/tips/tour-checks.webp +0 -0
- package/dist/assets/tips/tour-export.webp +0 -0
- package/dist/assets/tips/tour-keys.webp +0 -0
- package/dist/assets/tips/tour-library.webp +0 -0
- package/dist/assets/tips/tour-project.webp +0 -0
- package/dist/assets/tips/tour-review.webp +0 -0
- package/dist/assets/tips/tour-share.webp +0 -0
- package/dist/assets/tips/tour-source.webp +0 -0
- package/dist/assets/tips/tour-target.webp +0 -0
- package/dist/assets/tips/tour-view.webp +0 -0
- package/dist/assets/tips/tour.json +289 -0
- package/dist/assets/tips/tour.webp +0 -0
- package/dist/mcp/visualproof-mcp.mjs +3 -1
- package/dist/server/agent.js +10 -5
- package/dist/server/children.js +85 -0
- package/dist/server/doctor.js +105 -42
- package/dist/server/events.js +10 -0
- package/dist/server/export.js +11 -4
- package/dist/server/extract.js +3 -2
- package/dist/server/figures.js +3 -2
- package/dist/server/film.js +380 -0
- package/dist/server/history.js +11 -1
- package/dist/server/hover.js +8 -5
- package/dist/server/live.js +66 -9
- package/dist/server/main.js +217 -16
- package/dist/server/media.js +114 -0
- package/dist/server/pipeline.js +6 -1
- package/dist/server/poppler.js +3 -2
- package/dist/server/readonlygate.js +4 -4
- package/dist/server/sourcestore.js +3 -2
- package/dist/server/status.js +3 -2
- package/dist/server/supervise.js +219 -0
- package/dist/server/updates.js +951 -0
- package/dist/server/usage.js +3 -1
- package/dist/server/version.js +183 -0
- package/dist/shared/timeline.js +5 -0
- package/dist/web/bundle/app.js +2299 -255
- package/dist/web/index.html +13 -1
- package/dist/web/src/canvas.js +22 -0
- package/dist/web/style.css +189 -1
- package/docs/agent-api/export.md +2 -2
- package/docs/agent-api/history.md +3 -2
- package/package.json +3 -1
package/dist/server/main.js
CHANGED
|
@@ -39,6 +39,12 @@ import { UPLOAD_PATH } from "./agentlibrary.js";
|
|
|
39
39
|
import { DEFAULT_LIBRARY_LIMIT, validSha } from "../shared/library.js";
|
|
40
40
|
import { doctor, packageVersion, printReport } from "./doctor.js";
|
|
41
41
|
import { READONLY_PATH } from "./readonly.js";
|
|
42
|
+
import { sendMedia, TIP_PATH, TIPS_DIR, tipClips, tourData, tourVtt } from "./media.js";
|
|
43
|
+
import { withoutReasons } from "../shared/timeline.js";
|
|
44
|
+
import { FROM_PACKAGE, PACKAGE_ROOT, UPDATE_MODES, Updates } from "./updates.js";
|
|
45
|
+
import { RESTART_STATUS, supervise } from "./supervise.js";
|
|
46
|
+
import { stopChildren } from "./children.js";
|
|
47
|
+
import { filmStatus, serveFilm, useFilm } from "./film.js";
|
|
42
48
|
import { finalAnswer, readOnlyAllows, ReadOnlyBounds } from "./readonlygate.js";
|
|
43
49
|
/** The usage, to stderr with status 2 for a command line it cannot read; asked for (`--help`),
|
|
44
50
|
* to stdout with status 0. */
|
|
@@ -47,7 +53,7 @@ function usage(code = 2) {
|
|
|
47
53
|
[--site dir] [--cache dir] [--graph saved.json]
|
|
48
54
|
[--identity-header X-Forwarded-User] [--editor URL-template]
|
|
49
55
|
[--agents read|advise|full] [--access-key-file file] [--public] [--jobs ${DEFAULT_JOBS}]
|
|
50
|
-
[--public-url https://host] [--library-limit GB]
|
|
56
|
+
[--public-url https://host] [--library-limit GB] [--updates auto|notify|off]
|
|
51
57
|
visualproof purge-file <lean-project> <sha256> [--site dir] [--cache dir]
|
|
52
58
|
visualproof export <lean-project> -o page.html [--label text] [--root Name]... [--graph saved.json]
|
|
53
59
|
[--jobs ${DEFAULT_JOBS}] [--site dir] [--cache dir]
|
|
@@ -70,7 +76,10 @@ function usage(code = 2) {
|
|
|
70
76
|
--cache the root of the caches, which a rebuild makes again: the project's (extractions, the
|
|
71
77
|
usage pass's results, prepared views, typeset documents, Lean's language server's
|
|
72
78
|
folder) in projects/<project>-<hash>/, the Lean programs compiled per toolchain in
|
|
73
|
-
lean/, the page's bundle in web
|
|
79
|
+
lean/, the page's bundle in web/, VisualProof's own releases in updates.json and the
|
|
80
|
+
patches downloaded in versions/ (code the servers run: keep the root yours alone),
|
|
81
|
+
and the tips' tour film in media/.
|
|
82
|
+
Default: ${cacheHome()}
|
|
74
83
|
(XDG_DATA_HOME and XDG_CACHE_HOME move both homes, on any platform)
|
|
75
84
|
|
|
76
85
|
--graph use a saved extraction (a projects/*/extract/*.json of the cache) instead of reading
|
|
@@ -127,6 +136,16 @@ function usage(code = 2) {
|
|
|
127
136
|
--public-url the address people reach this server at, when it is not the one their browser
|
|
128
137
|
sends (a proxy in front): the prompt Share makes for an agent tells it this address.
|
|
129
138
|
|
|
139
|
+
--updates what the server does with VisualProof's own releases, asked of the npm registry
|
|
140
|
+
(through npm) at start and once a day: auto (the default) — a patch of the version it
|
|
141
|
+
runs (0.4.x) is downloaded into the cache and taken at a quiet moment, nobody on a page,
|
|
142
|
+
open pages reloading where they were; a newer version (0.5.0) is told of, in the log
|
|
143
|
+
and the help, with the command that installs it, after which the server restarts into
|
|
144
|
+
it by itself; notify — told of, never downloaded; off — never asked (and no
|
|
145
|
+
supervisor process), and the tips' tour film never fetched: the server reaches the
|
|
146
|
+
network for nothing of its own. VISUALPROOF_UPDATES says the same. From a checkout,
|
|
147
|
+
never asked.
|
|
148
|
+
|
|
130
149
|
--library-limit the most the project's library of papers and images may take, in GB (default
|
|
131
150
|
${DEFAULT_LIBRARY_LIMIT / 1024 ** 3}); its files are kept in the site directory, by their hash.
|
|
132
151
|
|
|
@@ -149,7 +168,12 @@ function parseArgs(argv) {
|
|
|
149
168
|
}
|
|
150
169
|
if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor" && cmd !== "artwork")
|
|
151
170
|
usage();
|
|
152
|
-
const
|
|
171
|
+
const fromEnv = process.env.VISUALPROOF_UPDATES;
|
|
172
|
+
if (cmd === "serve" && fromEnv !== undefined && !UPDATE_MODES.includes(fromEnv)) {
|
|
173
|
+
console.error(`visualproof: VISUALPROOF_UPDATES=${fromEnv}: say auto, notify or off`);
|
|
174
|
+
process.exit(2);
|
|
175
|
+
}
|
|
176
|
+
const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false, recipe: null, png: false, svg: false, screen: { w: 1920, h: 1200 }, updates: UPDATE_MODES.includes(fromEnv) ? fromEnv : "auto" };
|
|
153
177
|
for (let i = 0; i < rest.length; i++) {
|
|
154
178
|
const a = rest[i];
|
|
155
179
|
const val = () => rest[++i] ?? usage();
|
|
@@ -203,6 +227,12 @@ function parseArgs(argv) {
|
|
|
203
227
|
usage();
|
|
204
228
|
o.jobs = n;
|
|
205
229
|
}
|
|
230
|
+
else if (a === "--updates" && cmd === "serve") {
|
|
231
|
+
const mode = val();
|
|
232
|
+
if (!UPDATE_MODES.includes(mode))
|
|
233
|
+
usage();
|
|
234
|
+
o.updates = mode;
|
|
235
|
+
}
|
|
206
236
|
else if (a === "--agents") {
|
|
207
237
|
const level = val();
|
|
208
238
|
if (!AGENT_LEVELS.includes(level))
|
|
@@ -270,6 +300,9 @@ export class App {
|
|
|
270
300
|
readOnlyOf = new WeakMap();
|
|
271
301
|
/** what read-only readers may cost the server: Lean's answers, their documents */
|
|
272
302
|
bounds;
|
|
303
|
+
/** VisualProof's own releases as this server follows them (server/updates.ts); null: the
|
|
304
|
+
* version alone is told (a test's server) */
|
|
305
|
+
updates = null;
|
|
273
306
|
constructor(live, hover, ids, agents, access, exporter = null, publicUrl = null, publicRead = false) {
|
|
274
307
|
this.live = live;
|
|
275
308
|
this.hover = hover;
|
|
@@ -286,6 +319,11 @@ export class App {
|
|
|
286
319
|
this.publicRead = publicRead && access !== null;
|
|
287
320
|
this.bounds = new ReadOnlyBounds((id) => this.exporter?.job(id)?.state === "running");
|
|
288
321
|
}
|
|
322
|
+
/** Whether a request is told all of the server's own releases (UpdateSpec decision 11): someone
|
|
323
|
+
* let in fully; not a read-only reader, nor an agent. */
|
|
324
|
+
toldAll(req) {
|
|
325
|
+
return this.readOnlyOf.get(req) === undefined && this.ids.kind(req) !== "agent";
|
|
326
|
+
}
|
|
289
327
|
/** The exported document (ExportSpec decisions 5–16): POST /api/export starts typesetting the
|
|
290
328
|
* document of a view, `{view, settings, version?}` (a build the Time Machine shows the view at;
|
|
291
329
|
* left out, the view's own), and answers with its job at once; the page asks how the
|
|
@@ -858,6 +896,23 @@ export class App {
|
|
|
858
896
|
// the fonts an artwork is drawn in (ExportSpec decision 19)
|
|
859
897
|
if (FONT_PATH.test(path))
|
|
860
898
|
return sendFile(req, res, join(WEBFONTS, path.slice("/fonts/".length)));
|
|
899
|
+
// the tips' clips and their key frames (GuideSpec decision 6), and which there are
|
|
900
|
+
if (path === "/tips/index.json")
|
|
901
|
+
return sendJson(res, { clips: tipClips() });
|
|
902
|
+
// the tour film, fetched by this server on its first play and kept checked in the cache
|
|
903
|
+
// (server/film.ts, UpdateSpec decision 14): else 503 while it comes, or 404, and the page shows
|
|
904
|
+
// its stills and captions; and its captions as WebVTT, for assistive technology
|
|
905
|
+
if (path === "/tips/tour.mp4" && !refused.has(res))
|
|
906
|
+
return void serveFilm(req, res, sendMedia);
|
|
907
|
+
if (path === "/tips/tour.en.vtt") {
|
|
908
|
+
const vtt = tourVtt(tourData());
|
|
909
|
+
if (vtt === null)
|
|
910
|
+
return sendJson(res, { error: "no captions" }, 404);
|
|
911
|
+
res.writeHead(200, { "content-type": "text/vtt; charset=utf-8", "cache-control": "no-cache" });
|
|
912
|
+
return void res.end(vtt);
|
|
913
|
+
}
|
|
914
|
+
if (TIP_PATH.test(path) && !refused.has(res))
|
|
915
|
+
return sendMedia(req, res, join(TIPS_DIR, path.slice("/tips/".length)));
|
|
861
916
|
// the page, and its workers: the one that lays out the Time Machine's past graphs
|
|
862
917
|
// (web/src/layoutworker.ts), the one that chooses the overview's edges (web/src/edgeworker.ts),
|
|
863
918
|
// and pdf.js's, which reads the library's PDFs for the reference window (AttachmentSpec decision 11)
|
|
@@ -866,8 +921,19 @@ export class App {
|
|
|
866
921
|
if (path.startsWith("/pdfjs/"))
|
|
867
922
|
return pdfjsAsset(res, path);
|
|
868
923
|
// with Lean's language server as it is (server/hover.ts): what is open, what crashed
|
|
869
|
-
|
|
870
|
-
|
|
924
|
+
// a read-only reader is told how the server is, not its log nor where its review is kept
|
|
925
|
+
if (path === "/api/status") {
|
|
926
|
+
const st = { ...live.status(), lean: this.hover.state() };
|
|
927
|
+
if (this.readOnlyOf.get(req) !== undefined) {
|
|
928
|
+
delete st.log;
|
|
929
|
+
st.review = { ...st.review, folder: undefined };
|
|
930
|
+
}
|
|
931
|
+
return sendJson(res, st);
|
|
932
|
+
}
|
|
933
|
+
// VisualProof's own version, and what of its releases those let in fully are told (UpdateSpec
|
|
934
|
+
// decision 11): a read-only reader and an agent are told the version alone
|
|
935
|
+
if (path === "/api/updates")
|
|
936
|
+
return sendJson(res, this.updates?.status(this.toldAll(req)) ?? { running: packageVersion() });
|
|
871
937
|
// the link that lets someone in, for a page that shares where it is (web/src/share.ts); a
|
|
872
938
|
// request gets here only once it is in
|
|
873
939
|
// a read-only reader shares the link they came by, or the address alone (--public)
|
|
@@ -987,12 +1053,14 @@ export class App {
|
|
|
987
1053
|
}
|
|
988
1054
|
if (path === "/api/events") {
|
|
989
1055
|
const session = url.searchParams.get("session");
|
|
990
|
-
return live.subscribe(res, validSession(session) ? session : null, this.ids.person(req)?.id ?? null, reader ?? null);
|
|
1056
|
+
return live.subscribe(res, validSession(session) ? session : null, this.ids.person(req)?.id ?? null, reader ?? null, this.toldAll(req));
|
|
991
1057
|
}
|
|
992
1058
|
// what happened (CollabSpec decision 5): what the server stored; the review's own history the
|
|
993
1059
|
// page reads from the review
|
|
994
|
-
if (path === "/api/activity")
|
|
995
|
-
|
|
1060
|
+
if (path === "/api/activity") {
|
|
1061
|
+
const events = live.timeline.events.slice(-2000);
|
|
1062
|
+
return sendJson(res, { events: this.toldAll(req) ? events : events.map(withoutReasons) });
|
|
1063
|
+
}
|
|
996
1064
|
// a layout batch an agent made, undone (AgentSpec decision 4): from a page's Activity, by anyone;
|
|
997
1065
|
// by a private reader, without their name (CollabSpec decision 8)
|
|
998
1066
|
if (path === "/api/layout/undo" && req.method === "POST") {
|
|
@@ -1022,8 +1090,10 @@ export class App {
|
|
|
1022
1090
|
return sendJson(res, { error: "not ready" }, 503);
|
|
1023
1091
|
// a page asks for the version it was told about; an older one is gone
|
|
1024
1092
|
const want = url.searchParams.get("v");
|
|
1025
|
-
|
|
1026
|
-
|
|
1093
|
+
// of this server's: one restarted since counts its versions again (UpdateSpec decision 8)
|
|
1094
|
+
const of = url.searchParams.get("i");
|
|
1095
|
+
if ((want && Number(want) !== g.version) || (of && of !== live.instance))
|
|
1096
|
+
return sendJson(res, { error: "superseded", version: g.version, instance: live.instance }, 409);
|
|
1027
1097
|
// a page asks for the edges packed (shared/edgecodec.ts; `pack=2` with the grammar site, a
|
|
1028
1098
|
// page from before it `pack=1`, which takes grammar for a proof); an agent gets them as they
|
|
1029
1099
|
// are
|
|
@@ -1327,8 +1397,21 @@ function packedGz(edgesGz, format) {
|
|
|
1327
1397
|
}
|
|
1328
1398
|
return p;
|
|
1329
1399
|
}
|
|
1400
|
+
/** What a stop signal does to a server: end it at once, until it can stop gracefully (serve). */
|
|
1401
|
+
let stopNow = () => process.exit(0);
|
|
1330
1402
|
async function main() {
|
|
1331
1403
|
const o = parseArgs(process.argv.slice(2));
|
|
1404
|
+
// an installed package's server runs under the supervisor, which stays while it restarts into
|
|
1405
|
+
// another version (UpdateSpec decision 5); `--updates off`, and a checkout, run it as they are
|
|
1406
|
+
if (o.cmd === "serve" && FROM_PACKAGE && o.updates !== "off" && process.env.VISUALPROOF_SUPERVISED !== "1") {
|
|
1407
|
+
const site = projectDirs(resolve(o.path), o.site, o.cache).site;
|
|
1408
|
+
process.exit(await supervise({ installedRoot: PACKAGE_ROOT, cacheRoot: o.cache ? resolve(o.cache) : cacheHome(), argv: process.argv.slice(2), site, mode: o.updates }));
|
|
1409
|
+
}
|
|
1410
|
+
// a stop asked before the server can stop gracefully (it reads the project, takes the site) ends
|
|
1411
|
+
// it at once, with status 0; once it can, the graceful stop does (`stopNow`, below)
|
|
1412
|
+
if (o.cmd === "serve")
|
|
1413
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
1414
|
+
process.on(sig, () => stopNow());
|
|
1332
1415
|
if (o.cmd === "doctor") {
|
|
1333
1416
|
const report = doctor({ path: o.path || null, roots: o.roots, site: o.site, cache: o.cache });
|
|
1334
1417
|
console.log(printReport(report, o.path || null));
|
|
@@ -1345,7 +1428,7 @@ async function main() {
|
|
|
1345
1428
|
catch (e) {
|
|
1346
1429
|
if (e instanceof ProjectError) {
|
|
1347
1430
|
console.error(`visualproof: ${e.message}`);
|
|
1348
|
-
|
|
1431
|
+
return endNotTheCode(1);
|
|
1349
1432
|
}
|
|
1350
1433
|
throw e;
|
|
1351
1434
|
}
|
|
@@ -1365,7 +1448,7 @@ async function main() {
|
|
|
1365
1448
|
if (!(e instanceof SiteBusy))
|
|
1366
1449
|
throw e;
|
|
1367
1450
|
console.error(`visualproof: ${e.message}`);
|
|
1368
|
-
|
|
1451
|
+
return endNotTheCode(1);
|
|
1369
1452
|
}
|
|
1370
1453
|
}
|
|
1371
1454
|
useBundleDir(bundleDirIn(o.cache ? resolve(o.cache) : cacheHome()));
|
|
@@ -1418,7 +1501,7 @@ async function main() {
|
|
|
1418
1501
|
}
|
|
1419
1502
|
catch (e) {
|
|
1420
1503
|
console.error(`visualproof: --access-key-file: ${e instanceof Error ? e.message : String(e)}`);
|
|
1421
|
-
|
|
1504
|
+
return endNotTheCode(2);
|
|
1422
1505
|
}
|
|
1423
1506
|
process.on("exit", () => {
|
|
1424
1507
|
hover.stop();
|
|
@@ -1429,10 +1512,77 @@ async function main() {
|
|
|
1429
1512
|
live.invites.close();
|
|
1430
1513
|
live.readOnly.close();
|
|
1431
1514
|
});
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
const app = new App(live, hover, ids, new AgentApi(live, hover, o.agents), key ? new Access(key.key) : null, new Exporter(dirs.cache), o.publicUrl, o.publicRead);
|
|
1515
|
+
const exporter = new Exporter(dirs.cache);
|
|
1516
|
+
const app = new App(live, hover, ids, new AgentApi(live, hover, o.agents), key ? new Access(key.key) : null, exporter, o.publicUrl, o.publicRead);
|
|
1435
1517
|
const server = createServer((req, res) => app.handle(req, res));
|
|
1518
|
+
const version = packageVersion();
|
|
1519
|
+
// run by the supervisor, which starts it again in another version (UpdateSpec decision 5)
|
|
1520
|
+
const supervised = typeof process.send === "function" && process.env.VISUALPROOF_SUPERVISED === "1";
|
|
1521
|
+
/** Stop (UpdateSpec decision 9): no new requests, the pages told why and their streams ended, the
|
|
1522
|
+
* programs the server runs stopped, then the process ends: status 0, or RESTART_STATUS for the
|
|
1523
|
+
* supervisor to start it again in another version. A second signal meanwhile changes nothing;
|
|
1524
|
+
* whatever still waits, it ends within 5 s. */
|
|
1525
|
+
let stopping = false;
|
|
1526
|
+
/** the status the process ends with: a stop asked during a restart makes it a stop (0), so that
|
|
1527
|
+
* the supervisor does not start it again */
|
|
1528
|
+
let exitStatus = 0;
|
|
1529
|
+
const stop = (status, why) => {
|
|
1530
|
+
if (stopping) {
|
|
1531
|
+
if ("stopped" in why)
|
|
1532
|
+
exitStatus = 0;
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
stopping = true;
|
|
1536
|
+
exitStatus = status;
|
|
1537
|
+
updates.stop();
|
|
1538
|
+
server.close();
|
|
1539
|
+
live.end(why);
|
|
1540
|
+
hover.stop();
|
|
1541
|
+
live.usage.cancel();
|
|
1542
|
+
server.closeIdleConnections();
|
|
1543
|
+
setTimeout(() => process.exit(exitStatus), 5000);
|
|
1544
|
+
void stopChildren(2000).then(() => {
|
|
1545
|
+
server.closeAllConnections();
|
|
1546
|
+
process.exit(exitStatus);
|
|
1547
|
+
});
|
|
1548
|
+
};
|
|
1549
|
+
const cacheRoot = o.cache ? resolve(o.cache) : cacheHome();
|
|
1550
|
+
const updates = new Updates({
|
|
1551
|
+
mode: o.updates,
|
|
1552
|
+
cacheRoot,
|
|
1553
|
+
canRestart: supervised && FROM_PACKAGE,
|
|
1554
|
+
check: FROM_PACKAGE,
|
|
1555
|
+
checkout: !FROM_PACKAGE,
|
|
1556
|
+
// nobody on a page, nothing running that a restart would cut short (UpdateSpec decision 6)
|
|
1557
|
+
quiet: () => live.pages === 0 && !live.busy && !exporter.busy,
|
|
1558
|
+
restart: (to) => {
|
|
1559
|
+
process.send?.({ vp: "restart", to });
|
|
1560
|
+
stop(RESTART_STATUS, { update: { from: version, to } });
|
|
1561
|
+
},
|
|
1562
|
+
// the server's console alone: the log pages read (GET /api/status) is no place for paths and
|
|
1563
|
+
// commands of this machine
|
|
1564
|
+
log: (l) => console.log(l),
|
|
1565
|
+
changed: () => live.updatesChanged(),
|
|
1566
|
+
running: version,
|
|
1567
|
+
// the supervisor's word, believed only from it
|
|
1568
|
+
installedRoot: (supervised && process.env.VISUALPROOF_INSTALLED) || PACKAGE_ROOT,
|
|
1569
|
+
site: dirs.site,
|
|
1570
|
+
// what the supervisor set aside without a record of it (a full disk), believed only from it
|
|
1571
|
+
also: new Set(supervised ? unrecordedOf(process.env.VISUALPROOF_UNRECORDED) : []),
|
|
1572
|
+
film: () => filmStatus(),
|
|
1573
|
+
});
|
|
1574
|
+
// the tips' tour film, fetched on its first play (UpdateSpec decision 14; never under --updates off)
|
|
1575
|
+
useFilm({ cacheRoot, mode: o.updates, log: (l) => console.log(l) });
|
|
1576
|
+
live.updateStatus = (full) => updates.status(full);
|
|
1577
|
+
app.updates = updates;
|
|
1578
|
+
stopNow = () => stop(0, { stopped: true });
|
|
1579
|
+
// the supervisor went: this server goes with it rather than serve on alone
|
|
1580
|
+
if (supervised)
|
|
1581
|
+
process.on("disconnect", () => stop(0, { stopped: true }));
|
|
1582
|
+
server.on("error", (e) => {
|
|
1583
|
+
console.error(`visualproof: ${e.code === "EADDRINUSE" ? `${o.host}:${o.port} is in use already: give --port another` : e.message}`);
|
|
1584
|
+
return endNotTheCode(1);
|
|
1585
|
+
});
|
|
1436
1586
|
server.listen(o.port, o.host, () => {
|
|
1437
1587
|
console.log(`VisualProof — ${project.name} (${targetsInWords(project)})`);
|
|
1438
1588
|
console.log(` http://${o.host}:${o.port}/`);
|
|
@@ -1443,9 +1593,60 @@ async function main() {
|
|
|
1443
1593
|
console.log(` agents: ${o.agents} — their API is http://${o.host}:${o.port}/api/agent`);
|
|
1444
1594
|
console.log(` usage pass: ${o.graph ? "none for a saved graph" : o.jobs > 0 ? `${o.jobs} module(s) at a time` : "off (--jobs 0)"}`);
|
|
1445
1595
|
console.log(` access: ${key ? `by its link only, /k/<the key in ${o.accessKeyFile}>${key.made ? " (a new key was made there)" : ""}${o.publicRead ? "; anyone else read-only (--public)" : ""}` : "anyone who reaches the port"}`);
|
|
1596
|
+
console.log(` version: ${version}; updates ${updatesInWords(o.updates, version, supervised)}`);
|
|
1597
|
+
recordVersion(live, version, supervised);
|
|
1598
|
+
process.send?.({ vp: "listening", version });
|
|
1599
|
+
updates.start();
|
|
1446
1600
|
});
|
|
1447
1601
|
live.start();
|
|
1448
1602
|
}
|
|
1603
|
+
/** What the server does with its releases, for its first lines (UpdateSpec decisions 2–4, 13). */
|
|
1604
|
+
function updatesInWords(mode, version, supervised) {
|
|
1605
|
+
if (mode === "off")
|
|
1606
|
+
return "off (--updates off): the registry is never asked";
|
|
1607
|
+
if (!FROM_PACKAGE)
|
|
1608
|
+
return "none from a checkout";
|
|
1609
|
+
const minor = version.split(".").slice(0, 2).join(".");
|
|
1610
|
+
if (mode === "notify")
|
|
1611
|
+
return `notify — newer versions told of, never downloaded${supervised ? "; one installed meanwhile is restarted into" : ""}`;
|
|
1612
|
+
return supervised ? `auto — a patch of ${minor} taken by itself at a quiet moment; a newer version told of` : "auto, but without the supervisor: newer versions told of";
|
|
1613
|
+
}
|
|
1614
|
+
/** The versions the supervisor says it set aside without a record (VISUALPROOF_UNRECORDED). */
|
|
1615
|
+
function unrecordedOf(text) {
|
|
1616
|
+
try {
|
|
1617
|
+
const v = JSON.parse(text ?? "[]");
|
|
1618
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
1619
|
+
}
|
|
1620
|
+
catch {
|
|
1621
|
+
return [];
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
/** The version that serves the site, recorded in the timeline where it is another than the last one
|
|
1625
|
+
* recorded (UpdateSpec decision 10), with what the supervisor set aside before this one started. */
|
|
1626
|
+
function recordVersion(live, version, supervised) {
|
|
1627
|
+
let setAside;
|
|
1628
|
+
try {
|
|
1629
|
+
const raw = JSON.parse((supervised && process.env.VISUALPROOF_SET_ASIDE) || "null");
|
|
1630
|
+
if (raw && typeof raw.version === "string" && typeof raw.why === "string")
|
|
1631
|
+
setAside = { version: raw.version, why: raw.why };
|
|
1632
|
+
}
|
|
1633
|
+
catch {
|
|
1634
|
+
// nothing set aside that can be read
|
|
1635
|
+
}
|
|
1636
|
+
const was = live.timeline.lastVersion() ?? live.history?.lastVersion() ?? null;
|
|
1637
|
+
if (was !== version || setAside)
|
|
1638
|
+
live.timeline.add({ kind: "tool", from: was, to: version, ...(setAside ? { setAside } : {}) });
|
|
1639
|
+
}
|
|
1640
|
+
/** End the server with `status`, the supervisor told first that what ends it is not its code (the
|
|
1641
|
+
* site is busy, the port is taken, the project cannot be read): a patch that ends so is not set
|
|
1642
|
+
* aside (UpdateSpec decision 7). The message is sent before the process ends, not lost with it. */
|
|
1643
|
+
function endNotTheCode(status) {
|
|
1644
|
+
process.exitCode = status;
|
|
1645
|
+
if (process.send)
|
|
1646
|
+
process.send({ vp: "environment" }, () => process.exit(status));
|
|
1647
|
+
else
|
|
1648
|
+
process.exit(status);
|
|
1649
|
+
}
|
|
1449
1650
|
/** The artwork command (ExportSpec decision 21): the build read as an export reads it, the picture a
|
|
1450
1651
|
* recipe says made of it (server/artcmd.ts), its files written; what it named said as the agents'
|
|
1451
1652
|
* API says it; problems said, and exit status 1. */
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// What the sheet's tips play (GuideSpec decision 6): the tour film's data, assets/tips/tour.json (the
|
|
2
|
+
// film pinned by its sha256, its chapters, its captions), its poster tour.webp and each chapter's
|
|
3
|
+
// still tour-<chapter>.webp; its captions as WebVTT, made from tour.json; the film itself,
|
|
4
|
+
// /tips/tour.mp4, which is not in the package (server/film.ts fetches it); and the phone's gestures
|
|
5
|
+
// (phone-fingers.light.mp4). Served as they are (they are compressed already) and with byte
|
|
6
|
+
// ranges, which Safari needs to play a video at all; and which clips there are.
|
|
7
|
+
import { closeSync, createReadStream, fstatSync, openSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { pipeline } from "node:stream";
|
|
10
|
+
import { WEBFONTS } from "./pipeline.js";
|
|
11
|
+
/** where the clips are: beside the artworks' fonts, in the checkout's src/assets or the package's dist/assets */
|
|
12
|
+
export const TIPS_DIR = join(dirname(WEBFONTS), "tips");
|
|
13
|
+
/** a clip's or its key frame's address, the film's data, poster and stills */
|
|
14
|
+
export const TIP_PATH = /^\/tips\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\.(?:light|dark)\.(?:mp4|webp)|tour(?:-[a-z0-9]+)?\.webp|tour\.json)$/;
|
|
15
|
+
/** A time of a WebVTT cue: hh:mm:ss.mmm. */
|
|
16
|
+
function vttTime(t) {
|
|
17
|
+
const ms = Math.max(0, Math.round(t * 1000));
|
|
18
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
19
|
+
return `${p(Math.floor(ms / 3_600_000))}:${p(Math.floor(ms / 60_000) % 60)}:${p(Math.floor(ms / 1000) % 60)}.${p(ms % 1000, 3)}`;
|
|
20
|
+
}
|
|
21
|
+
/** The film's captions as WebVTT, for assistive technology (the page draws its own from
|
|
22
|
+
* tour.json): each cue in order, its text on one line, nothing in it read as markup. Null where
|
|
23
|
+
* tour.json says none. */
|
|
24
|
+
export function tourVtt(raw) {
|
|
25
|
+
const caps = raw?.captions;
|
|
26
|
+
if (!Array.isArray(caps))
|
|
27
|
+
return null;
|
|
28
|
+
const cues = caps.filter((c) => !!c && typeof c.start === "number" && typeof c.end === "number" && c.end > c.start && typeof c.text === "string");
|
|
29
|
+
const text = (t) => t.replace(/[\r\n]+/g, " ").replace(/-->/g, "→").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
30
|
+
return "WEBVTT\n\n" + cues.map((c) => `${vttTime(c.start)} --> ${vttTime(c.end)}\n${text(c.text)}\n`).join("\n");
|
|
31
|
+
}
|
|
32
|
+
/** tour.json as the package holds it; null where there is none. */
|
|
33
|
+
export function tourData(dir = TIPS_DIR) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(join(dir, "tour.json"), "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const TYPES = { mp4: "video/mp4", webp: "image/webp", json: "application/json" };
|
|
42
|
+
/** The clips there are: those recorded in the light theme (the film is light only). */
|
|
43
|
+
export function tipClips(dir = TIPS_DIR) {
|
|
44
|
+
let files;
|
|
45
|
+
try {
|
|
46
|
+
files = readdirSync(dir);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
return files.flatMap((f) => {
|
|
52
|
+
const m = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.light\.mp4$/.exec(f);
|
|
53
|
+
return m ? [m[1]] : [];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Whether the request already has the content `etag` names. */
|
|
57
|
+
function fresh(req, etag) {
|
|
58
|
+
const have = req.headers["if-none-match"];
|
|
59
|
+
return !!have && have.split(",").some((t) => t.trim().replace(/^W\//, "") === etag);
|
|
60
|
+
}
|
|
61
|
+
function answer(res, code, headers = {}) {
|
|
62
|
+
res.writeHead(code, headers);
|
|
63
|
+
res.end();
|
|
64
|
+
}
|
|
65
|
+
/** A clip or a key frame: the whole file, or the range asked for (206), or what cannot be given
|
|
66
|
+
* (416). The file is opened before anything is answered, so that one that cannot be read is a
|
|
67
|
+
* 404 rather than a cut connection; it is closed however the answer ends — sent, refused, or the
|
|
68
|
+
* reader gone in the middle of it. */
|
|
69
|
+
export function sendMedia(req, res, file) {
|
|
70
|
+
let fd;
|
|
71
|
+
try {
|
|
72
|
+
fd = openSync(file, "r");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return answer(res, 404, { "content-type": "application/json", "cache-control": "no-store" });
|
|
76
|
+
}
|
|
77
|
+
let size, mtime;
|
|
78
|
+
try {
|
|
79
|
+
({ size, mtimeMs: mtime } = fstatSync(fd));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
closeSync(fd);
|
|
83
|
+
return answer(res, 404);
|
|
84
|
+
}
|
|
85
|
+
const etag = `"${size.toString(36)}-${Math.round(mtime).toString(36)}"`;
|
|
86
|
+
const head = { "content-type": TYPES[file.slice(file.lastIndexOf(".") + 1)] ?? "application/octet-stream", "cache-control": "no-cache", etag, "accept-ranges": "bytes" };
|
|
87
|
+
const done = (code, headers) => {
|
|
88
|
+
closeSync(fd);
|
|
89
|
+
answer(res, code, headers);
|
|
90
|
+
};
|
|
91
|
+
if (fresh(req, etag))
|
|
92
|
+
return done(304, head);
|
|
93
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? "").trim());
|
|
94
|
+
let start = 0, end = size - 1;
|
|
95
|
+
if (m && (m[1] || m[2])) {
|
|
96
|
+
// from a byte to another (or to the end), or the last so many
|
|
97
|
+
if (m[1])
|
|
98
|
+
[start, end] = [Number(m[1]), m[2] ? Math.min(Number(m[2]), size - 1) : size - 1];
|
|
99
|
+
else
|
|
100
|
+
start = Math.max(0, size - Number(m[2]));
|
|
101
|
+
if (start > end)
|
|
102
|
+
return done(416, { "content-range": `bytes */${size}` });
|
|
103
|
+
res.writeHead(206, { ...head, "content-range": `bytes ${start}-${end}/${size}`, "content-length": end - start + 1 });
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
res.writeHead(200, { ...head, "content-length": size });
|
|
107
|
+
if (req.method === "HEAD" || size === 0) {
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
return void res.end();
|
|
110
|
+
}
|
|
111
|
+
// the stream owns the descriptor from here: pipeline destroys it, and so closes the file, when
|
|
112
|
+
// the answer is sent, fails, or the reader goes away
|
|
113
|
+
pipeline(createReadStream(file, { fd, start, end, autoClose: true }), res, () => { });
|
|
114
|
+
}
|
package/dist/server/pipeline.js
CHANGED
|
@@ -11,6 +11,7 @@ import { cachedExtraction, extract, loadSaved, prune } from "./extract.js";
|
|
|
11
11
|
import { memoryOf } from "../shared/layout.js";
|
|
12
12
|
import { buildViewOffThread } from "./view.js";
|
|
13
13
|
import { cacheHome } from "./dirs.js";
|
|
14
|
+
import { packageVersion } from "./version.js";
|
|
14
15
|
/** the tool's own folder, `src/`; the repository is above it */
|
|
15
16
|
export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
17
|
/** the fonts the page draws an artwork's names and title in (ExportSpec decision 19): Source Sans 3
|
|
@@ -124,7 +125,9 @@ export async function ensureBundle() {
|
|
|
124
125
|
const dist = bundleDir();
|
|
125
126
|
const out = join(dist, "app.js");
|
|
126
127
|
let newest = 0;
|
|
127
|
-
|
|
128
|
+
// package.json too: the page is bundled with its version (UpdateSpec decision 8), which a page
|
|
129
|
+
// compares with its server's — a bundle of another version would have every new tab reload
|
|
130
|
+
for (const f of [...readdirRec(join(WEB, "src")), ...readdirRec(join(SRC, "shared")), join(REPO, "package.json")])
|
|
128
131
|
newest = Math.max(newest, statSync(f).mtimeMs);
|
|
129
132
|
const beside = ["layout.js", "edges.js", "pdfworker.js"].every((f) => existsSync(join(dist, f)));
|
|
130
133
|
if (existsSync(out) && beside && statSync(out).mtimeMs >= newest)
|
|
@@ -158,6 +161,8 @@ export async function bundlePage(outdir, sourcemap = true) {
|
|
|
158
161
|
// the owner reads in Safari: what Safari 16 lacks of the syntax (pdf.js's class static blocks; a
|
|
159
162
|
// regular expression's lookbehind, read at run time instead) is lowered
|
|
160
163
|
target: ["es2022", "safari16"],
|
|
164
|
+
// the version the page is of, which it compares with its server's (UpdateSpec decision 8)
|
|
165
|
+
define: { __VP_VERSION__: JSON.stringify(packageVersion()) },
|
|
161
166
|
sourcemap,
|
|
162
167
|
outdir,
|
|
163
168
|
logLevel: "warning",
|
package/dist/server/poppler.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// input: the tools are run without a shell, on a path of the server's own, with a time limit, and at
|
|
11
11
|
// most two at once.
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
|
+
import { tracked } from "./children.js";
|
|
13
14
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { join } from "node:path";
|
|
15
16
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
@@ -31,12 +32,12 @@ async function run(cmd, args, ms) {
|
|
|
31
32
|
running++;
|
|
32
33
|
try {
|
|
33
34
|
return await new Promise((ok, no) => {
|
|
34
|
-
execFile(cmd, args, { timeout: ms, maxBuffer: MAX_OUT, encoding: "buffer", windowsHide: true }, (err, out, errOut) => {
|
|
35
|
+
tracked(execFile(cmd, args, { timeout: ms, maxBuffer: MAX_OUT, encoding: "buffer", windowsHide: true }, (err, out, errOut) => {
|
|
35
36
|
if (err)
|
|
36
37
|
no(new Error(`${cmd} failed: ${(errOut?.toString("utf8").trim().split("\n").pop() ?? "") || err.message}`));
|
|
37
38
|
else
|
|
38
39
|
ok(out);
|
|
39
|
-
});
|
|
40
|
+
}));
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
finally {
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
export function readOnlyAllows(method, path) {
|
|
7
7
|
const read = method === "GET" || method === "HEAD";
|
|
8
8
|
if (read) {
|
|
9
|
-
// the page and its files
|
|
10
|
-
if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/") || path.startsWith("/fonts/"))
|
|
9
|
+
// the page and its files: its fonts, the tips' clips (GuideSpec decision 6)
|
|
10
|
+
if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/") || path.startsWith("/fonts/") || path.startsWith("/tips/"))
|
|
11
11
|
return true;
|
|
12
12
|
// the graph, its sources and Lean's answers, the review, what happened, the views, the history,
|
|
13
13
|
// the library, the printed document's state and files, who the reader is and the link they
|
|
14
|
-
// came by, and the event stream every change comes by
|
|
15
|
-
if (["/api/status", "/api/meta", "/api/edges", "/api/git", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
|
|
14
|
+
// came by, VisualProof's version, and the event stream every change comes by
|
|
15
|
+
if (["/api/status", "/api/updates", "/api/meta", "/api/edges", "/api/git", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
|
|
16
16
|
return true;
|
|
17
17
|
// a stream is opened by GET alone: a HEAD would take one of their places for nothing
|
|
18
18
|
if (path === "/api/events")
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// read-only: the build's commit, the commits made on top of it, the working tree and the blobs kept
|
|
25
25
|
// already. Nothing is ever written into the project or its `.git`.
|
|
26
26
|
import { spawn, spawnSync } from "node:child_process";
|
|
27
|
+
import { childEnv, tracked } from "./children.js";
|
|
27
28
|
import { createHash } from "node:crypto";
|
|
28
29
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
29
30
|
import { join } from "node:path";
|
|
@@ -156,13 +157,13 @@ const DECLARES = /^\s*(?:@\[[^\]]*\]\s*)*(?:(?:private|protected|noncomputable|p
|
|
|
156
157
|
// ------------------------------------------------------------------------------------------- git
|
|
157
158
|
/** Git, read-only and taking no lock, run from the project's folder: its output, or null. */
|
|
158
159
|
function gitSync(root, args) {
|
|
159
|
-
const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10_000, maxBuffer: 256 << 20, env: {
|
|
160
|
+
const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10_000, maxBuffer: 256 << 20, env: childEnv({ GIT_OPTIONAL_LOCKS: "0" }), stdio: ["ignore", "pipe", "ignore"] });
|
|
160
161
|
return r.status === 0 ? r.stdout : null;
|
|
161
162
|
}
|
|
162
163
|
/** The same, off the event loop, with `input` on its standard input: its output as bytes, or null. */
|
|
163
164
|
function git(root, args, input) {
|
|
164
165
|
return new Promise((ok) => {
|
|
165
|
-
const p = spawn("git", ["-C", root, ...args], { env: {
|
|
166
|
+
const p = tracked(spawn("git", ["-C", root, ...args], { env: childEnv({ GIT_OPTIONAL_LOCKS: "0" }), stdio: ["pipe", "pipe", "ignore"] }));
|
|
166
167
|
const out = [];
|
|
167
168
|
p.stdout.on("data", (b) => out.push(b));
|
|
168
169
|
p.on("error", () => ok(null));
|
package/dist/server/status.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// - the trace records the hash of the source it was built from. Lake rebuilds when that hash
|
|
11
11
|
// changes, not when the mtime does, so "stale" here compares the same hash (`lakeTextHash`).
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
|
+
import { tracked } from "./children.js";
|
|
13
14
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
15
|
import { join } from "node:path";
|
|
15
16
|
const IMPORT_RE = /^\s*(?:public\s+|private\s+)?(?:meta\s+)?import\s+(?:all\s+)?([^\s-]+)/;
|
|
@@ -84,7 +85,7 @@ export function scanModules(p) {
|
|
|
84
85
|
/** Modules a lean process is compiling right now, for this project's build directory. */
|
|
85
86
|
export function compiling(p) {
|
|
86
87
|
return new Promise((resolve) => {
|
|
87
|
-
execFile("ps", ["axo", "command"], { maxBuffer: 16 << 20 }, (err, stdout) => {
|
|
88
|
+
tracked(execFile("ps", ["axo", "command"], { maxBuffer: 16 << 20 }, (err, stdout) => {
|
|
88
89
|
const out = new Set();
|
|
89
90
|
if (err)
|
|
90
91
|
return resolve(out);
|
|
@@ -99,7 +100,7 @@ export function compiling(p) {
|
|
|
99
100
|
out.add(path.split("/").join("."));
|
|
100
101
|
}
|
|
101
102
|
resolve(out);
|
|
102
|
-
});
|
|
103
|
+
}));
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
const M = 0xc6a4a7935bd1e995n;
|