visualproof 0.3.0 → 0.4.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.
Files changed (49) hide show
  1. package/README.md +52 -11
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/mcp/visualproof-mcp.mjs +3 -1
  20. package/dist/server/agent.js +10 -5
  21. package/dist/server/children.js +85 -0
  22. package/dist/server/doctor.js +105 -42
  23. package/dist/server/events.js +10 -0
  24. package/dist/server/export.js +11 -4
  25. package/dist/server/extract.js +3 -2
  26. package/dist/server/figures.js +3 -2
  27. package/dist/server/film.js +380 -0
  28. package/dist/server/history.js +11 -1
  29. package/dist/server/hover.js +8 -5
  30. package/dist/server/live.js +66 -9
  31. package/dist/server/main.js +217 -16
  32. package/dist/server/media.js +114 -0
  33. package/dist/server/pipeline.js +6 -1
  34. package/dist/server/poppler.js +3 -2
  35. package/dist/server/readonlygate.js +4 -4
  36. package/dist/server/sourcestore.js +3 -2
  37. package/dist/server/status.js +3 -2
  38. package/dist/server/supervise.js +219 -0
  39. package/dist/server/updates.js +951 -0
  40. package/dist/server/usage.js +3 -1
  41. package/dist/server/version.js +183 -0
  42. package/dist/shared/timeline.js +5 -0
  43. package/dist/web/bundle/app.js +2298 -254
  44. package/dist/web/index.html +13 -1
  45. package/dist/web/src/canvas.js +22 -0
  46. package/dist/web/style.css +189 -1
  47. package/docs/agent-api/export.md +2 -2
  48. package/docs/agent-api/history.md +3 -2
  49. package/package.json +3 -1
@@ -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/. Default: ${cacheHome()}
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 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 } };
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
- if (path === "/api/status")
870
- return sendJson(res, { ...live.status(), lean: this.hover.state() });
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
- return sendJson(res, { events: live.timeline.events.slice(-2000) });
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
- if (want && Number(want) !== g.version)
1026
- return sendJson(res, { error: "superseded", version: g.version }, 409);
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
- process.exit(1);
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
- process.exit(1);
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
- process.exit(2);
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
- for (const sig of ["SIGINT", "SIGTERM"])
1433
- process.on(sig, () => process.exit(0));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ }
@@ -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
- for (const f of [...readdirRec(join(WEB, "src")), ...readdirRec(join(SRC, "shared"))])
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",
@@ -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: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "ignore"] });
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: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["pipe", "pipe", "ignore"] });
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));
@@ -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;