dsh-claude-slides 2.4.0 → 2.5.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.
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // claude-slides: local editor server (prebuilt; do not edit).
3
3
 
4
- import fs5 from "node:fs";
5
- import path4 from "node:path";
4
+ import fs7 from "node:fs";
5
+ import path6 from "node:path";
6
6
 
7
- import { createHash, randomBytes, randomUUID } from "node:crypto";
8
- import fs4 from "node:fs";
7
+ import { createHash as createHash2, randomBytes, randomUUID } from "node:crypto";
8
+ import fs6 from "node:fs";
9
9
  import fsp2 from "node:fs/promises";
10
10
  import http from "node:http";
11
- import path3 from "node:path";
11
+ import path5 from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
 
14
14
  import { spawn } from "node:child_process";
@@ -18,6 +18,9 @@ import os from "node:os";
18
18
  var COMMENTS_FILE = "comments.json";
19
19
  var COMMENTS_VERSION = 1;
20
20
  var LIMITS = { text: 1e4, label: 1e3, detail: 4e3, anchor: 2048, section: 200, name: 100 };
21
+ function sameAuthor(a, b) {
22
+ return a.kind === "user" && b.kind === "user" && (a.principal ?? "") === (b.principal ?? "");
23
+ }
21
24
  var CommentsError = class extends Error {
22
25
  code;
23
26
  status;
@@ -88,7 +91,10 @@ function parseDoc(text) {
88
91
  const threads = data.threads.filter(
89
92
  (t) => isRecord(t) && typeof t.id === "string" && typeof t.anchor === "string" && typeof t.label === "string" && (t.status === "open" || t.status === "resolved") && Array.isArray(t.messages) && t.messages.every((m) => isRecord(m) && typeof m.text === "string" && isRecord(m.author) && (m.author.kind === "user" || m.author.kind === "agent"))
90
93
  );
91
- for (const t of threads) if (!isRecord(t.location)) t.location = {};
94
+ for (const t of threads) {
95
+ if (!isRecord(t.location)) t.location = {};
96
+ for (const m of t.messages) if (m.author.principal !== void 0 && typeof m.author.principal !== "string") delete m.author.principal;
97
+ }
92
98
  return { version: COMMENTS_VERSION, threads };
93
99
  }
94
100
  var formatDoc = (doc) => `${JSON.stringify(doc, null, 2)}
@@ -127,10 +133,10 @@ function addMessage(thread, author, text, now) {
127
133
  }
128
134
  return message;
129
135
  }
130
- function editMessage(thread, messageId, text, now) {
136
+ function editMessage(thread, messageId, text, now, by) {
131
137
  const message = thread.messages.find((m) => m.id === messageId);
132
138
  if (!message) throw new CommentsError("MESSAGE_NOT_FOUND", `No message ${messageId} in ${thread.id}.`, 404);
133
- if (message.author.kind !== "user") throw new CommentsError("MESSAGE_NOT_EDITABLE", "Only your own messages can be edited.", 403);
139
+ if (message.author.kind !== "user" || by && !sameAuthor(message.author, by)) throw new CommentsError("MESSAGE_NOT_EDITABLE", "Only your own messages can be edited.", 403);
134
140
  if (message.text !== text) {
135
141
  message.text = text;
136
142
  message.editedAt = now;
@@ -332,9 +338,10 @@ function defaultUserName() {
332
338
  function createCommentsService(options) {
333
339
  const { root, log, agentHook } = options;
334
340
  const userName = (options.userName ?? defaultUserName()).slice(0, 100);
335
- const user = { kind: "user", name: userName };
341
+ const owner = { author: { kind: "user", name: userName }, canComment: true, canSend: true, admin: true };
342
+ const viewerOf = options.viewer ?? (() => owner);
336
343
  const allowWrite = options.allowWrite ?? (() => true);
337
- const clients = /* @__PURE__ */ new Set();
344
+ const clients = /* @__PURE__ */ new Map();
338
345
  const waiters = [];
339
346
  const hookRunning = /* @__PURE__ */ new Set();
340
347
  const requests = [];
@@ -354,9 +361,19 @@ function createCommentsService(options) {
354
361
  const hook = Boolean(agentHook);
355
362
  return { available: listening || busy || hook || at - lastWaitEnded < AGENT_GRACE_MS, listening, busy, hook };
356
363
  }
357
- async function snapshot() {
364
+ const meOf = (viewer) => ({
365
+ name: viewer.author.name,
366
+ ...viewer.author.principal ? { principal: viewer.author.principal } : {},
367
+ canComment: viewer.canComment,
368
+ canSend: viewer.canSend,
369
+ admin: viewer.admin
370
+ });
371
+ async function shared() {
358
372
  lastDoc = await readComments(root);
359
- return { threads: lastDoc.threads, agent: presence(), me: { name: userName } };
373
+ return { threads: lastDoc.threads, agent: presence() };
374
+ }
375
+ async function snapshot(viewer) {
376
+ return { ...await shared(), me: meOf(viewer) };
360
377
  }
361
378
  const frame = (snap) => `event: comments
362
379
  data: ${JSON.stringify(snap)}
@@ -365,18 +382,18 @@ data: ${JSON.stringify(snap)}
365
382
  function broadcast() {
366
383
  queue = queue.then(async () => {
367
384
  if (closed) return;
368
- let snap;
385
+ let base;
369
386
  try {
370
- snap = await snapshot();
387
+ base = await shared();
371
388
  } catch (error) {
372
389
  log(`comments: reading ${COMMENTS_FILE} failed: ${error.message}`);
373
390
  return;
374
391
  }
375
- const text = frame(snap);
376
- lastPresence = JSON.stringify(snap.agent);
392
+ const text = JSON.stringify(base);
393
+ lastPresence = JSON.stringify(base.agent);
377
394
  if (text === lastSent) return;
378
395
  lastSent = text;
379
- for (const client of clients) client.write(text);
396
+ for (const [client, viewer] of clients) client.write(frame({ ...base, me: meOf(viewer) }));
380
397
  });
381
398
  return queue;
382
399
  }
@@ -532,16 +549,21 @@ data: ${JSON.stringify(snap)}
532
549
  await broadcast();
533
550
  if (sent) await deliver();
534
551
  }
535
- async function create(request, response) {
552
+ function need(allowed, message) {
553
+ if (!allowed) throw new CommentsError("NOT_ALLOWED", message, 403);
554
+ }
555
+ const ONLY_OWNER_SENDS = "Only the owner can send comments to the agent.";
556
+ async function create(request, response, viewer) {
536
557
  const input = readNewThread(await readJson(request));
537
558
  if (!input) throw new CommentsError("INVALID_COMMENT", "expected {anchor, label, text} with a non-empty text");
559
+ need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
538
560
  let location = {};
539
561
  try {
540
562
  location = await options.locate?.(input) ?? {};
541
563
  } catch (error) {
542
564
  log(`comments: could not resolve where a comment points: ${error.message}`);
543
565
  }
544
- const created = await updateComments(root, (doc) => addThread(doc, input, location, user, now()));
566
+ const created = await updateComments(root, (doc) => addThread(doc, input, location, viewer.author, now()));
545
567
  log(`comments: ${created.id} added on ${created.label}${location.file ? ` (${location.file})` : ""}${input.send ? ", sent to the agent" : ""}`);
546
568
  sendJson(response, 201, { thread: created });
547
569
  await afterChange(Boolean(input.send));
@@ -556,7 +578,7 @@ data: ${JSON.stringify(snap)}
556
578
  sendJson(response, 202, { request: queued });
557
579
  await deliver();
558
580
  }
559
- async function events(request, response) {
581
+ async function events(request, response, viewer) {
560
582
  response.writeHead(200, {
561
583
  "Content-Type": "text/event-stream; charset=utf-8",
562
584
  "Cache-Control": "no-store",
@@ -564,8 +586,8 @@ data: ${JSON.stringify(snap)}
564
586
  "X-Accel-Buffering": "no"
565
587
  });
566
588
  response.write(": connected\n\n");
567
- response.write(frame(await snapshot()));
568
- clients.add(response);
589
+ response.write(frame(await snapshot(viewer)));
590
+ clients.set(response, viewer);
569
591
  const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS);
570
592
  keepAlive.unref?.();
571
593
  response.on("close", () => {
@@ -601,14 +623,26 @@ data: ${JSON.stringify(snap)}
601
623
  request.resume();
602
624
  throw new CommentsError("NOT_ALLOWED", "cross-origin request", 403);
603
625
  }
626
+ const viewer = viewerOf(request);
627
+ if (!viewer) {
628
+ request.resume();
629
+ throw new CommentsError("NOT_ALLOWED", "not shared with you", 403);
630
+ }
631
+ const commenting = !reading && parts[0] !== "agent" && parts[1] !== "send";
632
+ if (commenting && !viewer.canComment) {
633
+ request.resume();
634
+ throw new CommentsError("NOT_ALLOWED", "You can read the comments here, not add or change them.", 403);
635
+ }
604
636
  if (parts.length === 0) {
605
- if (reading) return sendJson(response, 200, await snapshot());
606
- if (method === "POST") return await create(request, response);
637
+ if (reading) return sendJson(response, 200, await snapshot(viewer));
638
+ if (method === "POST") return await create(request, response, viewer);
607
639
  } else if (parts.length === 1 && parts[0] === "events") {
608
- if (reading) return await events(request, response);
640
+ if (reading) return await events(request, response, viewer);
609
641
  } else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "wait") {
642
+ need(viewer.canSend, "Only the owner's agent waits for comments.");
610
643
  if (reading) return await wait(request, response, url);
611
644
  } else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "requests") {
645
+ need(viewer.canSend, ONLY_OWNER_SENDS);
612
646
  if (method === "POST") return await ask(request, response);
613
647
  } else if (parts.length === 1) {
614
648
  const id = parts[0];
@@ -627,6 +661,8 @@ data: ${JSON.stringify(snap)}
627
661
  if (method === "DELETE") {
628
662
  await updateComments(root, (doc) => {
629
663
  const found = findThread(doc, id);
664
+ const first = found.messages[0];
665
+ need(viewer.admin || first !== void 0 && sameAuthor(first.author, viewer.author), "Only the person who started this comment, or the owner, can delete it.");
630
666
  doc.threads.splice(doc.threads.indexOf(found), 1);
631
667
  });
632
668
  log(`comments: ${id} deleted`);
@@ -638,9 +674,10 @@ data: ${JSON.stringify(snap)}
638
674
  const id = parts[0];
639
675
  const input = readReply(await readJson(request));
640
676
  if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
677
+ need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
641
678
  const t = await updateComments(root, (doc) => {
642
679
  const found = findThread(doc, id);
643
- addMessage(found, user, input.text, now());
680
+ addMessage(found, viewer.author, input.text, now());
644
681
  if (input.send) sendToAgent(found, now());
645
682
  return found;
646
683
  });
@@ -654,9 +691,10 @@ data: ${JSON.stringify(snap)}
654
691
  const messageId = parts[2];
655
692
  const input = readEdit(await readJson(request));
656
693
  if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
694
+ need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
657
695
  const t = await updateComments(root, (doc) => {
658
696
  const found = findThread(doc, id);
659
- editMessage(found, messageId, input.text, now());
697
+ editMessage(found, messageId, input.text, now(), viewer.author);
660
698
  if (input.send && canSendToAgent(found)) sendToAgent(found, now());
661
699
  return found;
662
700
  });
@@ -668,6 +706,7 @@ data: ${JSON.stringify(snap)}
668
706
  if (method === "POST") {
669
707
  const id = parts[0];
670
708
  request.resume();
709
+ need(viewer.canSend, ONLY_OWNER_SENDS);
671
710
  const t = await updateComments(root, (doc) => {
672
711
  const found = findThread(doc, id);
673
712
  if (!canSendToAgent(found)) throw new CommentsError("NOTHING_TO_SEND", "the agent already has everything in this comment", 409);
@@ -696,13 +735,15 @@ data: ${JSON.stringify(snap)}
696
735
  clearTimeout(debounce);
697
736
  watcher?.close();
698
737
  for (const waiter of [...waiters]) finishWaiter(waiter, { threads: [], closed: true });
699
- for (const client of clients) client.end();
738
+ for (const client of clients.keys()) client.end();
700
739
  clients.clear();
701
740
  }
702
741
  return { handle, presence, deliver, close };
703
742
  }
704
743
 
705
744
  import fs3 from "node:fs";
745
+ import net from "node:net";
746
+ import os2 from "node:os";
706
747
  import path2 from "node:path";
707
748
  var STATE_FILE = ".artifact-server.json";
708
749
  function readState(root) {
@@ -749,6 +790,54 @@ async function listen(server2, port, host) {
749
790
  const address = server2.address();
750
791
  return typeof address === "object" && address ? address.port : port;
751
792
  }
793
+ var DEFAULT_PORTS = { slides: 4710, design: 4720, "design-system": 4730, dashboard: 4740, docs: 4750, motion: 4760 };
794
+ var PORT_TRIES = 100;
795
+ var PortsTaken = class extends Error {
796
+ code = "PORT_IN_USE";
797
+ from;
798
+ to;
799
+ constructor(from, to) {
800
+ super(`${from === to ? `Port ${from} is` : `Ports ${from} to ${to} are all`} in use; pick another --port (0 takes any free one).`);
801
+ this.from = from;
802
+ this.to = to;
803
+ }
804
+ };
805
+ function lanAddresses() {
806
+ const out = [];
807
+ for (const list of Object.values(os2.networkInterfaces())) {
808
+ for (const entry of list ?? []) if (entry.family === "IPv4" && !entry.internal && !entry.address.startsWith("169.254.")) out.push(entry.address);
809
+ }
810
+ return [...new Set(out)];
811
+ }
812
+ function answers(host, port) {
813
+ return new Promise((resolve) => {
814
+ const socket = net.connect({ host, port });
815
+ const done = (taken) => {
816
+ socket.destroy();
817
+ resolve(taken);
818
+ };
819
+ socket.setTimeout(300, () => done(true));
820
+ socket.once("connect", () => done(true));
821
+ socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
822
+ });
823
+ }
824
+ async function listenFree(server2, from, host, first) {
825
+ if (from === 0) return listen(server2, 0, host);
826
+ const to = Math.min(from + PORT_TRIES - 1, 65535);
827
+ const range = Array.from({ length: to - from + 1 }, (_, i) => from + i);
828
+ const ports = first ? [first, ...range.filter((port) => port !== first)] : range;
829
+ const wildcard = host === "0.0.0.0" || host === "::";
830
+ const probes = [.../* @__PURE__ */ new Set([...wildcard ? [] : [host], "127.0.0.1", "::1", ...lanAddresses()])];
831
+ for (const port of ports) {
832
+ if ((await Promise.all(probes.map((address) => answers(address, port)))).some(Boolean)) continue;
833
+ try {
834
+ return await listen(server2, port, host);
835
+ } catch (error) {
836
+ if (error.code !== "EADDRINUSE") throw error;
837
+ }
838
+ }
839
+ throw new PortsTaken(from, to);
840
+ }
752
841
  function serverOrigin(host, port) {
753
842
  const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
754
843
  return `http://${shown}:${port}`;
@@ -813,6 +902,575 @@ function sendFile(request, response, file, headers) {
813
902
  }
814
903
  var errorText = (error) => error instanceof Error ? error.message : String(error);
815
904
 
905
+ import { createHash } from "node:crypto";
906
+ import fs5 from "node:fs";
907
+ import os3 from "node:os";
908
+ import path4 from "node:path";
909
+
910
+ import crypto from "node:crypto";
911
+ import fs4 from "node:fs";
912
+ import path3 from "node:path";
913
+ var GUEST_ROLES = ["editor", "commenter", "viewer"];
914
+ var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
915
+ var SHARING_FILE = ".sharing.json";
916
+ var MAX_GUESTS = 200;
917
+ var MAX_NAME = 40;
918
+ var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
919
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
920
+ function fresh() {
921
+ return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
922
+ }
923
+ function cleanName(raw) {
924
+ const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f@<>]/g, "").replace(/\s+/g, " ").trim() : "";
925
+ return [...name].slice(0, MAX_NAME).join("");
926
+ }
927
+ function guestUser(guest) {
928
+ return { id: `guest-${guest.id}`, name: guest.name, email: `${guest.name}@${guest.id}.guest` };
929
+ }
930
+ function isLoopback(address) {
931
+ if (!address) return false;
932
+ const a = address.startsWith("::ffff:") ? address.slice(7) : address;
933
+ return a === "::1" || a.startsWith("127.");
934
+ }
935
+ var Sharing = class _Sharing {
936
+ file;
937
+ state;
938
+ constructor(root) {
939
+ this.file = path3.join(root, SHARING_FILE);
940
+ this.state = _Sharing.read(this.file);
941
+ }
942
+ static read(file) {
943
+ let raw;
944
+ try {
945
+ raw = JSON.parse(fs4.readFileSync(file, "utf8"));
946
+ } catch {
947
+ return fresh();
948
+ }
949
+ const state = fresh();
950
+ if (!isRecord2(raw)) return state;
951
+ state.lan = raw.lan === true;
952
+ if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
953
+ if (isRecord2(raw.links)) {
954
+ for (const role of GUEST_ROLES) {
955
+ const link = raw.links[role];
956
+ if (isRecord2(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
957
+ }
958
+ }
959
+ if (Array.isArray(raw.guests)) {
960
+ for (const g of raw.guests) {
961
+ if (!isRecord2(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
962
+ state.guests.push({
963
+ id: g.id,
964
+ secret: g.secret,
965
+ name: cleanName(g.name) || "Guest",
966
+ role: g.role,
967
+ link: g.link,
968
+ joined: typeof g.joined === "string" ? g.joined : (/* @__PURE__ */ new Date(0)).toISOString(),
969
+ ...typeof g.seen === "string" ? { seen: g.seen } : {}
970
+ });
971
+ }
972
+ }
973
+ return state;
974
+ }
975
+ save() {
976
+ const tmp = `${this.file}.${process.pid}.tmp`;
977
+ fs4.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
978
+ `, { mode: 384 });
979
+ fs4.renameSync(tmp, this.file);
980
+ }
981
+ /** The role whose link has this token, while that link is on. */
982
+ linkRole(tokenGiven) {
983
+ for (const role of GUEST_ROLES) {
984
+ const link = this.state.links[role];
985
+ if (link.on && tokenGiven.length === link.token.length && crypto.timingSafeEqual(Buffer.from(tokenGiven), Buffer.from(link.token))) return role;
986
+ }
987
+ return null;
988
+ }
989
+ /** The guest a cookie names, if they still have access (their link on and not reset). */
990
+ guest(secret) {
991
+ if (!secret) return null;
992
+ const guest = this.state.guests.find((g) => g.secret === secret);
993
+ return guest && this.valid(guest) ? guest : null;
994
+ }
995
+ valid(guest) {
996
+ const link = this.state.links[guest.role];
997
+ return link.on && link.token === guest.link && this.state.guests.includes(guest);
998
+ }
999
+ join(role, name) {
1000
+ const guest = { id: token(4), secret: token(24), name: cleanName(name) || "Guest", role, link: this.state.links[role].token, joined: (/* @__PURE__ */ new Date()).toISOString() };
1001
+ this.state.guests.push(guest);
1002
+ if (this.state.guests.length > MAX_GUESTS) this.state.guests.splice(0, this.state.guests.length - MAX_GUESTS);
1003
+ this.save();
1004
+ return guest;
1005
+ }
1006
+ /** Marks a guest seen now (saved at most once a minute per guest). */
1007
+ seen(guest) {
1008
+ const now = /* @__PURE__ */ new Date();
1009
+ if (guest.seen && now.getTime() - Date.parse(guest.seen) < 6e4) return;
1010
+ guest.seen = now.toISOString();
1011
+ this.save();
1012
+ }
1013
+ setLan(on, port) {
1014
+ this.state.lan = on;
1015
+ if (on && port) this.state.port = port;
1016
+ this.save();
1017
+ }
1018
+ setLink(role, on) {
1019
+ this.state.links[role].on = on;
1020
+ this.save();
1021
+ }
1022
+ /** A new token for the link: the old link stops working and the guests who came through it are dropped. */
1023
+ resetLink(role) {
1024
+ const old = this.state.links[role].token;
1025
+ this.state.links[role] = { token: token(), on: this.state.links[role].on };
1026
+ this.state.guests = this.state.guests.filter((g) => g.link !== old);
1027
+ this.save();
1028
+ }
1029
+ /**
1030
+ * Gives a guest another role. They now hang on that role's link (turning it off or resetting it drops them), so a role
1031
+ * whose link is off would shut them out: false then, as for a guest who isn't there.
1032
+ */
1033
+ setRole(id, role) {
1034
+ const guest = this.state.guests.find((g) => g.id === id);
1035
+ if (!guest || !this.state.links[role].on) return false;
1036
+ guest.role = role;
1037
+ guest.link = this.state.links[role].token;
1038
+ this.save();
1039
+ return true;
1040
+ }
1041
+ removeGuest(id) {
1042
+ const before = this.state.guests.length;
1043
+ this.state.guests = this.state.guests.filter((g) => g.id !== id);
1044
+ if (this.state.guests.length === before) return false;
1045
+ this.save();
1046
+ return true;
1047
+ }
1048
+ };
1049
+
1050
+ var NOUNS = {
1051
+ docs: { en: "doc", zh: "\u6587\u6863", zhThis: "\u8FD9\u7BC7\u6587\u6863" },
1052
+ slides: { en: "deck", zh: "\u6F14\u793A\u6587\u7A3F", zhThis: "\u8FD9\u4EFD\u6F14\u793A\u6587\u7A3F" },
1053
+ design: { en: "design", zh: "\u8BBE\u8BA1\u7A3F", zhThis: "\u8FD9\u4EFD\u8BBE\u8BA1\u7A3F" },
1054
+ "design-system": { en: "design system", zh: "\u8BBE\u8BA1\u7CFB\u7EDF", zhThis: "\u8FD9\u4E2A\u8BBE\u8BA1\u7CFB\u7EDF" },
1055
+ dashboard: { en: "dashboard", zh: "\u4EEA\u8868\u76D8", zhThis: "\u8FD9\u4E2A\u4EEA\u8868\u76D8" },
1056
+ motion: { en: "animation", zh: "\u52A8\u753B", zhThis: "\u8FD9\u4E2A\u52A8\u753B" }
1057
+ };
1058
+ var ShareError = class extends Error {
1059
+ };
1060
+ var canEditAs = (role) => role === "owner" || role === "editor";
1061
+ function commentsViewer(viewer) {
1062
+ return {
1063
+ author: viewer.guest ? { kind: "user", name: viewer.user.name, principal: viewer.user.email } : { kind: "user", name: viewer.user.name },
1064
+ canComment: viewer.role !== "viewer",
1065
+ canSend: viewer.role === "owner",
1066
+ admin: viewer.role === "owner"
1067
+ };
1068
+ }
1069
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1070
+ var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1071
+ var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
1072
+ function ownHosts(host, port) {
1073
+ const named = host === "0.0.0.0" || host === "::" ? [] : [host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`];
1074
+ return [.../* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, ...named])];
1075
+ }
1076
+ function localOwner() {
1077
+ let name = "You";
1078
+ try {
1079
+ name = os3.userInfo().username.trim() || name;
1080
+ } catch {
1081
+ }
1082
+ return { id: "local", name, email: `${name}@localhost` };
1083
+ }
1084
+ var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash("sha256").update(path4.resolve(root)).digest("hex").slice(0, 12)}`;
1085
+ var ShareHost = class {
1086
+ sharing;
1087
+ options;
1088
+ /** The port the server listens on (set by `listening`); the LAN listeners take the same one. */
1089
+ port = 0;
1090
+ hosts = /* @__PURE__ */ new Set();
1091
+ /** One listener per LAN address while the artifact is open to the network. */
1092
+ lanServers = /* @__PURE__ */ new Map();
1093
+ live = /* @__PURE__ */ new Set();
1094
+ constructor(options) {
1095
+ this.options = options;
1096
+ this.sharing = new Sharing(options.root);
1097
+ }
1098
+ /**
1099
+ * Starts `server` on the first free port from `port` (a shared artifact tries the port its links were given out with
1100
+ * first, so they keep working, unless the port was given), shares it on the network with `lan` (or as it was left)
1101
+ * and answers the port taken. If this throws, the caller closes what it started.
1102
+ */
1103
+ async listen(server2, { port, host, lan = false, portGiven = false }) {
1104
+ const { state } = this.sharing;
1105
+ const linksPort = !portGiven && (lan || state.lan) && state.port ? state.port : void 0;
1106
+ const taken = await listenFree(server2, port, host, linksPort);
1107
+ if (linksPort && taken !== linksPort) this.options.log(`port ${linksPort} (the shared links') is in use; took ${taken}: give the new links out again`);
1108
+ else if (!linksPort && port && taken !== port) this.options.log(`port ${port} is in use; took ${taken}`);
1109
+ if (lan && !this.sharing.state.lan) this.sharing.setLan(true, taken);
1110
+ await this.listening(taken, ownHosts(host, taken));
1111
+ return taken;
1112
+ }
1113
+ /** The artifact at each LAN address it is shared on ([] while it is not). */
1114
+ lanUrls() {
1115
+ return [...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`);
1116
+ }
1117
+ /** The main listener is up on `port` (answering `hosts`): open to the network now if the artifact was left shared. */
1118
+ async listening(port, hosts) {
1119
+ this.port = port;
1120
+ this.hosts = new Set(hosts);
1121
+ if (this.sharing.state.lan) await this.setLan(true);
1122
+ }
1123
+ /** Open to the local network now. */
1124
+ get shared() {
1125
+ return this.lanServers.size > 0;
1126
+ }
1127
+ /** A Host header this server answers: its own names, and while shared, its LAN addresses and machine name. */
1128
+ hostAllowed(request) {
1129
+ const host = request.headers.host ?? "";
1130
+ if (this.hosts.has(host)) return true;
1131
+ if (!this.lanServers.size) return false;
1132
+ const suffix = `:${this.port}`;
1133
+ if (!host.endsWith(suffix)) return false;
1134
+ const name = host.slice(0, -suffix.length).toLowerCase();
1135
+ const machine = os3.hostname().toLowerCase();
1136
+ return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
1137
+ }
1138
+ /**
1139
+ * Opens the artifact to the local network (a listener on each LAN address, on the server's port) or closes it.
1140
+ * Returns the addresses it listens on. Guests already in keep their place for when it opens again.
1141
+ */
1142
+ async setLan(on) {
1143
+ if (!on) {
1144
+ this.closeLan();
1145
+ for (const live of [...this.live]) if (live.viewer.guest) live.close("unshared");
1146
+ if (this.sharing.state.lan) this.sharing.setLan(false);
1147
+ return [];
1148
+ }
1149
+ for (const address of lanAddresses()) {
1150
+ if (this.lanServers.has(address)) continue;
1151
+ const server2 = this.options.createServer();
1152
+ try {
1153
+ await listen(server2, this.port, address);
1154
+ this.lanServers.set(address, server2);
1155
+ } catch (error) {
1156
+ server2.close();
1157
+ this.options.log(`not listening on ${address}:${this.port}: ${error.message}`);
1158
+ }
1159
+ }
1160
+ if (!this.sharing.state.lan || this.sharing.state.port !== this.port) this.sharing.setLan(true, this.port);
1161
+ this.options.log(`shared on the local network: ${[...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`).join(", ") || "(no LAN address)"}`);
1162
+ return [...this.lanServers.keys()];
1163
+ }
1164
+ closeLan() {
1165
+ for (const server2 of this.lanServers.values()) {
1166
+ server2.close();
1167
+ server2.closeAllConnections?.();
1168
+ }
1169
+ this.lanServers.clear();
1170
+ }
1171
+ /** Stops the LAN listeners (the server closes its own). */
1172
+ close() {
1173
+ this.closeLan();
1174
+ }
1175
+ // ---- who is asking ---------------------------------------------------------------------------------------------
1176
+ cookie(request) {
1177
+ for (const part of (request.headers.cookie ?? "").split(";")) {
1178
+ const eq = part.indexOf("=");
1179
+ if (eq > 0 && part.slice(0, eq).trim() === this.options.cookieName) return part.slice(eq + 1).trim();
1180
+ }
1181
+ return void 0;
1182
+ }
1183
+ /**
1184
+ * Who a request is: the owner over loopback, a guest by their cookie (while their link is on and the artifact is
1185
+ * shared), else nobody. Loopback is always the owner, so trying a link on this computer can't lock the owner out of
1186
+ * their own artifact; a guest comes over a LAN address (this computer's own included: that is how the owner sees
1187
+ * what a role sees).
1188
+ */
1189
+ viewerOf(request) {
1190
+ if (isLoopback(request.socket.remoteAddress)) return { role: "owner", user: this.options.owner };
1191
+ const guest = this.lanServers.size ? this.sharing.guest(this.cookie(request)) : null;
1192
+ if (!guest) return null;
1193
+ this.sharing.seen(guest);
1194
+ return { role: guest.role, user: guestUser(guest), guest };
1195
+ }
1196
+ /** Keeps track of a stream or socket `viewer` holds open; call the returned function when it closes. */
1197
+ attach(viewer, close) {
1198
+ const live = { viewer, close };
1199
+ this.live.add(live);
1200
+ return () => this.live.delete(live);
1201
+ }
1202
+ /** `attach` for an HTTP stream (server-sent events): it ends when its person loses access. */
1203
+ hold(viewer, response) {
1204
+ const detach = this.attach(viewer, () => response.end());
1205
+ response.on("close", detach);
1206
+ }
1207
+ /** Still allowed in: the owner, or a guest whose link is on and who was not removed. */
1208
+ stillIn(viewer) {
1209
+ return !viewer.guest || this.shared && this.sharing.valid(viewer.guest);
1210
+ }
1211
+ /** Ends the streams of guests who lost access (a link turned off or reset, a guest removed). */
1212
+ dropRevoked() {
1213
+ for (const live of [...this.live]) if (live.viewer.guest && !this.sharing.valid(live.viewer.guest)) live.close("revoked");
1214
+ }
1215
+ me(viewer) {
1216
+ return { role: viewer.role, id: viewer.user.id, name: viewer.user.name, email: viewer.user.email };
1217
+ }
1218
+ // ---- routes ----------------------------------------------------------------------------------------------------
1219
+ /** Answers the share link and /__share paths; false for any other path. */
1220
+ async route(request, response) {
1221
+ const p = new URL(request.url ?? "/", "http://local").pathname;
1222
+ const method = request.method ?? "GET";
1223
+ const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
1224
+ const ours = link || p === "/__share" || p.startsWith("/__share/");
1225
+ if (!ours) return false;
1226
+ if (method === "POST" && !sameOrigin(request)) {
1227
+ request.resume();
1228
+ sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
1229
+ return true;
1230
+ }
1231
+ if (link) {
1232
+ if (method === "GET" || method === "POST") await this.join(request, response, method, link[1]);
1233
+ else response.writeHead(405, { Allow: "GET, POST" }).end();
1234
+ return true;
1235
+ }
1236
+ const viewer = this.viewerOf(request);
1237
+ if (!viewer) {
1238
+ this.denied(request, response);
1239
+ return true;
1240
+ }
1241
+ if (method === "GET" && p === "/__share/me") return sendJson2(response, 200, this.me(viewer)), true;
1242
+ if (method === "GET" && p === "/__share/boot.js") {
1243
+ const body = this.bootScript(viewer);
1244
+ response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).end(body);
1245
+ return true;
1246
+ }
1247
+ if (method === "GET" && p === "/__share/invite") return sendJson2(response, 200, this.invites(viewer, request.headers.host ?? "")), true;
1248
+ if (method === "GET" && p === "/__share/ui.js") {
1249
+ fs5.stat(this.options.uiFile, (error, stat) => {
1250
+ if (error || !stat.isFile()) return sendJson2(response, 404, { code: "not_found", message: "The Share panel is missing." });
1251
+ sendFile(request, response, this.options.uiFile, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": stat.size, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
1252
+ });
1253
+ return true;
1254
+ }
1255
+ if (viewer.role !== "owner") return sendJson2(response, 403, { code: "not_granted", message: "Only the owner can do this." }), true;
1256
+ if (p === "/__share") return await this.shareApi(request, response, method), true;
1257
+ sendJson2(response, 404, { code: "not_found", message: "Not found" });
1258
+ return true;
1259
+ }
1260
+ /** The tags a page puts first in its <head>: the boot script and the Share panel (the owner's, or a guest's). */
1261
+ headTags(_viewer) {
1262
+ let tags = '<script src="/__share/boot.js"></script>';
1263
+ if (fs5.existsSync(this.options.uiFile)) tags += '<script type="module" src="/__share/ui.js"></script>';
1264
+ return tags;
1265
+ }
1266
+ /** `html` with the head tags first in its <head>; null when it has none. */
1267
+ injectHead(html, viewer) {
1268
+ const at = html.search(/<head(\s[^>]*)?>/i);
1269
+ if (at < 0) return null;
1270
+ const end = html.indexOf(">", at) + 1;
1271
+ return html.slice(0, end) + this.headTags(viewer) + html.slice(end);
1272
+ }
1273
+ /** What the page runs before anything else (see SECURE_CONTEXT_SHIM and GUEST_WATCH). */
1274
+ bootScript(viewer) {
1275
+ const share = { ...this.me(viewer), noun: this.options.noun, title: this.options.title(), ...this.options.reactions ? { reactions: true } : {} };
1276
+ let js = `${SECURE_CONTEXT_SHIM}window.__baoyuShare=${script(share)};`;
1277
+ for (const [name, value] of Object.entries(this.options.globals?.(viewer) ?? {})) js += `window[${script(name)}]=${script(value)};`;
1278
+ if (viewer.guest) js += GUEST_WATCH;
1279
+ return js;
1280
+ }
1281
+ /** Nobody's request: a page for a browser's navigation, JSON for anything else. */
1282
+ denied(request, response) {
1283
+ request.resume();
1284
+ const navigation = (request.method === "GET" || request.method === "HEAD") && /\btext\/html\b/.test(request.headers.accept ?? "");
1285
+ if (!navigation) return sendJson2(response, 403, { code: "not_granted", message: `This ${this.options.noun.en} is not shared with you.` });
1286
+ const t = this.words(request);
1287
+ this.sendPage(response, 403, t.noAccessTitle, `<h1>${esc(t.noAccessTitle)}</h1><p>${esc(t.noAccess)}</p>${this.ownerHint(request, t)}`);
1288
+ }
1289
+ /** A small page of our own (the join form, the refusal), in the reader's language. */
1290
+ sendPage(response, status, title, body, headers = {}) {
1291
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>${PAGE_CSS}</style></head><body><main>${body}</main></body></html>`;
1292
+ response.writeHead(status, {
1293
+ "Content-Type": "text/html; charset=utf-8",
1294
+ "Cache-Control": "no-store",
1295
+ "X-Content-Type-Options": "nosniff",
1296
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
1297
+ ...headers
1298
+ }).end(html);
1299
+ }
1300
+ /** On the computer that shares the artifact, over its LAN address: the way back to the owner's page. */
1301
+ ownerHint(request, t) {
1302
+ const from = (request.socket.remoteAddress ?? "").replace(/^::ffff:/, "");
1303
+ if (!this.lanServers.has(from)) return "";
1304
+ const url = `http://127.0.0.1:${this.port}/`;
1305
+ return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
1306
+ }
1307
+ /** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
1308
+ async join(request, response, method, token2) {
1309
+ const t = this.words(request);
1310
+ const role = this.sharing.linkRole(token2);
1311
+ const local = isLoopback(request.socket.remoteAddress);
1312
+ const form = method === "POST" ? await readBody(request, 8 << 10).then((body) => body.toString("utf8"), () => "") : "";
1313
+ const title = this.options.title();
1314
+ if (!role || !local && !this.lanServers.size) return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
1315
+ if (local) {
1316
+ const lan = [...this.lanServers.keys()][0];
1317
+ const there = lan ? `http://${lan}:${this.port}/s/${token2}` : "";
1318
+ const tryIt = there ? `<p>${esc(t.tryRole(t.roles[role]))} <a href="${esc(there)}">${esc(there)}</a></p>` : "";
1319
+ return this.sendPage(response, 200, title, `<h1>${esc(title)}</h1><p>${esc(t.youOwn)} <a href="/">${esc(t.openAsOwner)}</a></p>${tryIt}`);
1320
+ }
1321
+ const current = this.sharing.guest(this.cookie(request));
1322
+ if (method === "GET" && current && current.link === this.sharing.state.links[role].token) {
1323
+ response.writeHead(303, { Location: "/", "Cache-Control": "no-store" }).end();
1324
+ return;
1325
+ }
1326
+ if (method === "GET") {
1327
+ return this.sendPage(
1328
+ response,
1329
+ 200,
1330
+ title,
1331
+ `<h1>${esc(title)}</h1><p>${esc(t.invited(t.roles[role]))}</p><form method="post"><label for="n">${esc(t.yourName)}</label><input id="n" name="name" maxlength="${MAX_NAME}" value="${esc(current?.name ?? "")}" required autofocus autocomplete="name"><button type="submit">${esc(t.open)}</button></form><p class="note">${esc(t.nameNote)}</p>`
1332
+ );
1333
+ }
1334
+ const name = new URLSearchParams(form).get("name") ?? "";
1335
+ const guest = this.sharing.join(role, name);
1336
+ this.options.log(`share: ${guest.name} joined as ${role} (${request.socket.remoteAddress})`);
1337
+ const cookie = `${this.options.cookieName}=${guest.secret}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
1338
+ response.writeHead(303, { Location: "/", "Set-Cookie": cookie, "Cache-Control": "no-store" }).end();
1339
+ }
1340
+ /**
1341
+ * The links this person may pass on: their own role's and the ones below it that are on (the owner: every one on), at
1342
+ * the address they came by (`host`, a Host header hostAllowed let in). A guest could forward the link they joined
1343
+ * with anyway; a lower one gives no more than that. Who joins through one shows in the owner's panel like anyone else.
1344
+ */
1345
+ invites(viewer, host) {
1346
+ const { links } = this.sharing.state;
1347
+ const from = viewer.role === "owner" ? 0 : GUEST_ROLES.indexOf(viewer.role);
1348
+ return { links: GUEST_ROLES.slice(from).filter((role) => links[role].on).map((role) => ({ role, url: `http://${host}/s/${links[role].token}` })) };
1349
+ }
1350
+ /** Links, addresses and people, for the share panel and `artifact.mjs share`. */
1351
+ shareView() {
1352
+ const addresses = [...this.lanServers.keys()];
1353
+ const base = addresses.length ? `http://${addresses[0]}:${this.port}` : `http://127.0.0.1:${this.port}`;
1354
+ const online = /* @__PURE__ */ new Set();
1355
+ for (const live of this.live) if (live.viewer.guest) online.add(live.viewer.guest.id);
1356
+ const { links, guests } = this.sharing.state;
1357
+ return {
1358
+ title: this.options.title(),
1359
+ lan: this.lanServers.size > 0,
1360
+ port: this.port,
1361
+ addresses,
1362
+ local: `http://127.0.0.1:${this.port}/`,
1363
+ links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `${base}/s/${links[role].token}` }])),
1364
+ guests: guests.filter((g) => this.sharing.valid(g) || links[g.role].token === g.link).map((g) => ({ id: g.id, name: g.name, role: g.role, joined: g.joined, ...g.seen ? { seen: g.seen } : {}, online: online.has(g.id), active: this.sharing.valid(g) }))
1365
+ };
1366
+ }
1367
+ /**
1368
+ * The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
1369
+ * who came through it stop working), `role` {guest, role} (a guest's role, while that role's link is on), `remove`
1370
+ * {guest}. Answers the view after.
1371
+ */
1372
+ async share(action) {
1373
+ const role = action.role;
1374
+ switch (action.action) {
1375
+ case "lan": {
1376
+ const addresses = await this.setLan(action.on === true);
1377
+ if (action.on === true && !addresses.length) throw new ShareError("This computer has no local network address to share on.");
1378
+ break;
1379
+ }
1380
+ case "link":
1381
+ if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1382
+ this.sharing.setLink(role, action.on === true);
1383
+ break;
1384
+ case "reset":
1385
+ if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1386
+ this.sharing.resetLink(role);
1387
+ break;
1388
+ case "role": {
1389
+ if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1390
+ if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
1391
+ if (!this.sharing.setRole(action.guest, role)) throw new ShareError(`The ${role} link is off: turn it on first.`);
1392
+ for (const live of [...this.live]) if (live.viewer.guest?.id === action.guest) live.close("revoked");
1393
+ break;
1394
+ }
1395
+ case "remove":
1396
+ if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
1397
+ break;
1398
+ default:
1399
+ throw new ShareError("action: lan, link, reset, role or remove");
1400
+ }
1401
+ this.options.log(`share: ${JSON.stringify(action)}`);
1402
+ this.dropRevoked();
1403
+ return this.shareView();
1404
+ }
1405
+ async shareApi(request, response, method) {
1406
+ if (method === "GET") return sendJson2(response, 200, this.shareView());
1407
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: `${method} is not allowed here.` });
1408
+ let action;
1409
+ try {
1410
+ action = JSON.parse((await readBody(request, 64 << 10)).toString("utf8"));
1411
+ } catch {
1412
+ return sendJson2(response, 400, { code: "invalid_argument", message: "The call is not JSON." });
1413
+ }
1414
+ if (!isRecord3(action)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {action, ...}." });
1415
+ try {
1416
+ return sendJson2(response, 200, await this.share(action));
1417
+ } catch (error) {
1418
+ if (error instanceof ShareError) return sendJson2(response, 400, { code: "invalid_argument", message: error.message });
1419
+ throw error;
1420
+ }
1421
+ }
1422
+ words(request) {
1423
+ const zh = /^zh\b/i.test(request.headers["accept-language"] ?? "");
1424
+ return (zh ? ZH : EN)(this.options.noun);
1425
+ }
1426
+ };
1427
+ var SECURE_CONTEXT_SHIM = "if(window.crypto&&!crypto.randomUUID)crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16)),h='',i;b[6]=b[6]&15|64;b[8]=b[8]&63|128;for(i=0;i<16;i++){h+=(b[i]+256).toString(16).slice(1);if(i==3||i==5||i==7||i==9)h+='-'}return h};";
1428
+ var GUEST_WATCH = "setInterval(function(){fetch('/__share/me',{cache:'no-store'}).then(function(r){if(r.status===403)return location.reload();return r.json().then(function(m){if(m&&m.role&&m.role!==window.__baoyuShare.role)location.reload()})},function(){})},5000);";
1429
+ var PAGE_CSS = `
1430
+ :root { color-scheme: light dark; --bg: #f7f6f3; --card: #fff; --fg: #1f1e1d; --muted: #6b6a66; --line: #e3e1dc; --accent: #c96442; }
1431
+ @media (prefers-color-scheme: dark) { :root { --bg: #1f1e1d; --card: #2a2927; --fg: #f2f1ee; --muted: #a8a6a0; --line: #3a3936; } }
1432
+ * { box-sizing: border-box; }
1433
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; padding: 16px; }
1434
+ main { width: min(420px, 100%); background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 28px; }
1435
+ h1 { font-size: 20px; margin: 0 0 8px; word-break: break-word; }
1436
+ p { margin: 0 0 16px; color: var(--muted); }
1437
+ label { display: block; font-size: 13px; margin: 0 0 6px; color: var(--muted); }
1438
+ input { width: 100%; font: inherit; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: inherit; margin: 0 0 14px; }
1439
+ button { width: 100%; font: inherit; font-weight: 600; padding: 10px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
1440
+ .note { font-size: 12px; margin: 14px 0 0; }
1441
+ a { color: var(--accent); overflow-wrap: anywhere; }
1442
+ `;
1443
+ var EN = (noun) => ({
1444
+ noAccessTitle: "No access",
1445
+ noAccess: `This ${noun.en} is not shared with you. Ask its owner for a link.`,
1446
+ linkGoneTitle: "This link does not work",
1447
+ linkGone: `The link was turned off or replaced, or the ${noun.en} is no longer shared. Ask its owner for a new one.`,
1448
+ invited: (what) => `You were invited to ${what} this ${noun.en}.`,
1449
+ yourName: "Your name (others see it on your comments and edits)",
1450
+ open: `Open the ${noun.en}`,
1451
+ nameNote: "This browser keeps your place; the owner can remove it at any time.",
1452
+ roles: { editor: "edit", commenter: "comment on", viewer: "view" },
1453
+ youOwn: `This is your ${noun.en}: on this computer you are its owner.`,
1454
+ openAsOwner: "Open it",
1455
+ tryRole: (what) => `To see it as someone invited to ${what} it does, open the link at the network address, here or on another device:`,
1456
+ ownerHere: `You are on the computer that shares this ${noun.en}. Open it as the owner at`
1457
+ });
1458
+ var ZH = (noun) => ({
1459
+ noAccessTitle: "\u65E0\u6743\u8BBF\u95EE",
1460
+ noAccess: `${noun.zhThis}\u6CA1\u6709\u5206\u4EAB\u7ED9\u4F60\u3002\u8BF7\u5411\u6240\u6709\u8005\u8981\u4E00\u4E2A\u94FE\u63A5\u3002`,
1461
+ linkGoneTitle: "\u94FE\u63A5\u5DF2\u5931\u6548",
1462
+ linkGone: `\u8FD9\u4E2A\u94FE\u63A5\u5DF2\u88AB\u5173\u95ED\u6216\u91CD\u7F6E\uFF0C\u6216\u8005${noun.zhThis}\u5DF2\u505C\u6B62\u5206\u4EAB\u3002\u8BF7\u5411\u6240\u6709\u8005\u8981\u4E00\u4E2A\u65B0\u94FE\u63A5\u3002`,
1463
+ invited: (what) => `\u4F60\u88AB\u9080\u8BF7${what}${noun.zhThis}\u3002`,
1464
+ yourName: "\u4F60\u7684\u540D\u5B57\uFF08\u522B\u4EBA\u4F1A\u5728\u4F60\u7684\u8BC4\u8BBA\u548C\u7F16\u8F91\u4E0A\u770B\u5230\u5B83\uFF09",
1465
+ open: `\u6253\u5F00${noun.zh}`,
1466
+ nameNote: "\u8FD9\u4E2A\u6D4F\u89C8\u5668\u4F1A\u8BB0\u4F4F\u4F60\u7684\u8EAB\u4EFD\uFF1B\u6240\u6709\u8005\u53EF\u4EE5\u968F\u65F6\u79FB\u9664\u3002",
1467
+ roles: { editor: "\u7F16\u8F91", commenter: "\u8BC4\u8BBA", viewer: "\u67E5\u770B" },
1468
+ youOwn: `\u8FD9\u662F\u4F60\u7684${noun.zh}\uFF1A\u5728\u8FD9\u53F0\u7535\u8111\u4E0A\u4F60\u662F\u6240\u6709\u8005\u3002`,
1469
+ openAsOwner: `\u6253\u5F00${noun.zh}`,
1470
+ tryRole: (what) => `\u60F3\u770B\u770B\u53D7\u9080${what}\u7684\u4EBA\u770B\u5230\u4EC0\u4E48\uFF0C\u7528\u5C40\u57DF\u7F51\u5730\u5740\u6253\u5F00\u8FD9\u4E2A\u94FE\u63A5\uFF08\u5728\u8FD9\u53F0\u7535\u8111\u6216\u522B\u7684\u8BBE\u5907\u4E0A\u90FD\u884C\uFF09\uFF1A`,
1471
+ ownerHere: `\u4F60\u5728\u5206\u4EAB${noun.zhThis}\u7684\u7535\u8111\u4E0A\u3002\u4EE5\u6240\u6709\u8005\u8EAB\u4EFD\u6253\u5F00\uFF1A`
1472
+ });
1473
+
816
1474
  var KIND = "";
817
1475
  var NOUN = "";
818
1476
  var INDEX_FILE = "";
@@ -903,7 +1561,8 @@ var SSE_KEEPALIVE_MS2 = 25e3;
903
1561
  var WATCH_DEBOUNCE_MS2 = 300;
904
1562
  var POLL_MS2 = 2e3;
905
1563
  var CHANGE_LOG_SIZE = 64;
906
- var sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
1564
+ var CLIENT_ID = /^[A-Za-z0-9_-]{8,64}$/;
1565
+ var sha256 = (bytes) => createHash2("sha256").update(bytes).digest("hex");
907
1566
  function contentTypeOf(file) {
908
1567
  const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
909
1568
  return MIME[ext] ?? "application/octet-stream";
@@ -926,7 +1585,7 @@ function inlineScriptHashes(html) {
926
1585
  const hashes = [];
927
1586
  for (const match of html.matchAll(/<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi)) {
928
1587
  if (/\ssrc\s*=/i.test(match[1] ?? "")) continue;
929
- hashes.push("sha256-" + createHash("sha256").update(match[2] ?? "", "utf8").digest("base64"));
1588
+ hashes.push("sha256-" + createHash2("sha256").update(match[2] ?? "", "utf8").digest("base64"));
930
1589
  }
931
1590
  return hashes;
932
1591
  }
@@ -959,7 +1618,7 @@ async function collectFiles(root, limit) {
959
1618
  const seen = /* @__PURE__ */ new Set();
960
1619
  let truncated = false;
961
1620
  const visit = async (relDir, descend) => {
962
- const dir = relDir === "" ? root : path3.join(root, ...relDir.split("/"));
1621
+ const dir = relDir === "" ? root : path5.join(root, ...relDir.split("/"));
963
1622
  let entries;
964
1623
  try {
965
1624
  entries = await fsp2.readdir(dir, { withFileTypes: true });
@@ -990,8 +1649,8 @@ async function collectFiles(root, limit) {
990
1649
  return { rels, truncated };
991
1650
  }
992
1651
  async function writeFileAtomic(file, bytes) {
993
- await fsp2.mkdir(path3.dirname(file), { recursive: true });
994
- const tmp = path3.join(path3.dirname(file), `.${path3.basename(file)}.${randomUUID()}.tmp`);
1652
+ await fsp2.mkdir(path5.dirname(file), { recursive: true });
1653
+ const tmp = path5.join(path5.dirname(file), `.${path5.basename(file)}.${randomUUID()}.tmp`);
995
1654
  try {
996
1655
  await fsp2.writeFile(tmp, bytes);
997
1656
  await fsp2.rename(tmp, file);
@@ -1033,11 +1692,11 @@ var Store = class {
1033
1692
  async diskPath(rel) {
1034
1693
  const realRoot = await fsp2.realpath(this.root).catch(() => null);
1035
1694
  if (realRoot === null) return null;
1036
- const file = path3.join(realRoot, ...rel.split("/"));
1037
- for (let dir = path3.dirname(file); ; dir = path3.dirname(dir)) {
1695
+ const file = path5.join(realRoot, ...rel.split("/"));
1696
+ for (let dir = path5.dirname(file); ; dir = path5.dirname(dir)) {
1038
1697
  const real = await fsp2.realpath(dir).catch(() => null);
1039
1698
  if (real === null) {
1040
- if (path3.dirname(dir) === dir) return null;
1699
+ if (path5.dirname(dir) === dir) return null;
1041
1700
  continue;
1042
1701
  }
1043
1702
  if (real !== dir) return null;
@@ -1150,7 +1809,7 @@ var Store = class {
1150
1809
  }
1151
1810
  async findBlobFile(id) {
1152
1811
  try {
1153
- const names = await fsp2.readdir(path3.join(this.root, LAYOUT.blobDir));
1812
+ const names = await fsp2.readdir(path5.join(this.root, LAYOUT.blobDir));
1154
1813
  const name = names.find((n) => n.startsWith(`${id}.`) && !n.endsWith(".tmp"));
1155
1814
  if (name === void 0) return null;
1156
1815
  const file = await this.diskPath(`${LAYOUT.blobDir}/${name}`);
@@ -1205,13 +1864,13 @@ function watchArtifact(store, onChange, onError) {
1205
1864
  const directories = /* @__PURE__ */ new Map();
1206
1865
  const visit = (rel, descend) => {
1207
1866
  if (directories.size >= maxDirectories) return;
1208
- const dir = rel === "" ? store.root : path3.join(store.root, ...rel.split("/"));
1209
- if (directories.has(dir) || !fs4.existsSync(dir)) return;
1210
- const info = fs4.lstatSync(dir);
1867
+ const dir = rel === "" ? store.root : path5.join(store.root, ...rel.split("/"));
1868
+ if (directories.has(dir) || !fs6.existsSync(dir)) return;
1869
+ const info = fs6.lstatSync(dir);
1211
1870
  if (!info.isDirectory()) return;
1212
1871
  directories.set(dir, `${info.dev}:${info.ino}`);
1213
1872
  if (!descend) return;
1214
- for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
1873
+ for (const entry of fs6.readdirSync(dir, { withFileTypes: true })) {
1215
1874
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1216
1875
  const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
1217
1876
  if (LAYOUT.descendInto(child)) visit(child, true);
@@ -1228,19 +1887,19 @@ function watchArtifact(store, onChange, onError) {
1228
1887
  if (watchers.has(dir)) continue;
1229
1888
  let watcher;
1230
1889
  try {
1231
- watcher = fs4.watch(dir, (event, name) => {
1890
+ watcher = fs6.watch(dir, (event, name) => {
1232
1891
  if (stopped) return;
1233
1892
  if (name) {
1234
- const changed = path3.join(dir, String(name));
1893
+ const changed = path5.join(dir, String(name));
1235
1894
  if (event === "rename" && watchers.has(changed)) {
1236
1895
  for (const [watchedDir, item] of watchers) {
1237
- if (watchedDir === changed || watchedDir.startsWith(changed + path3.sep)) {
1896
+ if (watchedDir === changed || watchedDir.startsWith(changed + path5.sep)) {
1238
1897
  item.watcher.close();
1239
1898
  watchers.delete(watchedDir);
1240
1899
  }
1241
1900
  }
1242
1901
  }
1243
- const rel = path3.relative(store.root, changed).split(path3.sep).join("/");
1902
+ const rel = path5.relative(store.root, changed).split(path5.sep).join("/");
1244
1903
  if (isIgnoredPath(rel) || !LAYOUT.watches(rel)) return;
1245
1904
  }
1246
1905
  schedule();
@@ -1274,7 +1933,7 @@ function watchArtifact(store, onChange, onError) {
1274
1933
  const { rels } = await collectFiles(store.root, LAYOUT.maxFiles);
1275
1934
  const parts = [];
1276
1935
  for (const rel of rels) {
1277
- const info = await fsp2.lstat(path3.join(store.root, ...rel.split("/"))).catch(() => null);
1936
+ const info = await fsp2.lstat(path5.join(store.root, ...rel.split("/"))).catch(() => null);
1278
1937
  parts.push(info ? `${rel}\0${info.size}\0${info.mtimeMs}\0${info.ino}` : `${rel}\0-`);
1279
1938
  }
1280
1939
  return parts.join("\n");
@@ -1303,23 +1962,39 @@ function watchArtifact(store, onChange, onError) {
1303
1962
  };
1304
1963
  }
1305
1964
  var publishError = (response, status, code, message) => sendJson2(response, status, { code, message });
1306
- async function startServer({ root, port = 0, host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
1307
- `), agentHook }) {
1308
- const project = path3.join(root, "project");
1965
+ async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
1966
+ `), agentHook, lan = false, portGiven = false }) {
1967
+ const project = path5.join(root, "project");
1309
1968
  const stamp = (line) => log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}`);
1310
1969
  const projectDir = await fsp2.realpath(project);
1311
1970
  const store = new Store(projectDir, stamp);
1971
+ const owner = localOwner();
1972
+ const share = new ShareHost({
1973
+ root,
1974
+ cookieName: guestCookieName(KIND, root),
1975
+ owner,
1976
+ noun: NOUNS[KIND] ?? { en: NOUN, zh: NOUN, zhThis: NOUN },
1977
+ title: () => artifactTitle(root, projectDir),
1978
+ log: stamp,
1979
+ createServer: () => http.createServer(handle),
1980
+ uiFile: path5.join(EDITOR_DIR, "share-ui.js")
1981
+ });
1312
1982
  const comments = createCommentsService({
1313
1983
  root,
1314
1984
  log: stamp,
1315
1985
  locate: commentLocation ? (input) => commentLocation(input, projectDir) : void 0,
1316
1986
  agentHook,
1317
- allowWrite: sameOrigin
1987
+ userName: owner.name,
1988
+ allowWrite: sameOrigin,
1989
+ viewer: (request) => {
1990
+ const viewer = share.viewerOf(request);
1991
+ return viewer ? commentsViewer(viewer) : null;
1992
+ }
1318
1993
  });
1319
1994
  const grant = randomBytes(24).toString("base64url");
1320
1995
  const editorPath = `/${KIND}/${grant}/`;
1321
1996
  const boot = randomBytes(6).toString("hex");
1322
- const clients = /* @__PURE__ */ new Set();
1997
+ const clients = /* @__PURE__ */ new Map();
1323
1998
  const changes = [];
1324
1999
  let changeSeq = 0;
1325
2000
  const pageCache = /* @__PURE__ */ new Map();
@@ -1329,18 +2004,23 @@ event: ${event.type}
1329
2004
  data: ${JSON.stringify(event)}
1330
2005
 
1331
2006
  `;
2007
+ const changed = (event, from) => {
2008
+ changeSeq += 1;
2009
+ changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {} });
2010
+ if (changes.length > CHANGE_LOG_SIZE) changes.shift();
2011
+ for (const [client, id] of clients) if (!from || id !== from) client.write(frame(event));
2012
+ };
1332
2013
  const stopWatch = watchArtifact(
1333
2014
  store,
1334
2015
  (event) => {
1335
- changeSeq += 1;
1336
- changes.push({ seq: changeSeq, paths: event.paths });
1337
- if (changes.length > CHANGE_LOG_SIZE) changes.shift();
1338
2016
  stamp(`changed on disk: ${event.paths.join(", ")}${clients.size ? "" : " (no page open)"}`);
1339
- for (const client of clients) client.write(frame(event));
2017
+ changed(event);
1340
2018
  },
1341
2019
  (error) => stamp(`watching failed: ${errorText(error)}`)
1342
2020
  );
1343
- const events = async (request, response) => {
2021
+ const events = async (request, response, viewer) => {
2022
+ const given = new URL(request.url ?? "/", "http://local").searchParams.get("client") ?? "";
2023
+ const client = CLIENT_ID.test(given) ? given : "";
1344
2024
  response.writeHead(200, {
1345
2025
  "Content-Type": "text/event-stream; charset=utf-8",
1346
2026
  "Cache-Control": "no-store",
@@ -1350,16 +2030,18 @@ data: ${JSON.stringify(event)}
1350
2030
  response.write(": connected\n\n");
1351
2031
  const last = /^([0-9a-f]+)\.(\d+)$/.exec(String(request.headers["last-event-id"] ?? ""));
1352
2032
  const missedSince = last && last[1] === boot ? Number(last[2]) : null;
1353
- if (missedSince !== null && missedSince < changeSeq) {
1354
- const missed = changes.filter((c) => c.seq > missedSince);
1355
- const paths = [...new Set((missed.length ? missed : changes).flatMap((c) => c.paths))];
2033
+ const truncated = missedSince !== null && changes.length > 0 && changes[0].seq > missedSince + 1;
2034
+ const missed = missedSince === null ? [] : truncated ? changes : changes.filter((c) => c.seq > missedSince && (!client || c.from !== client));
2035
+ if (missed.length) {
2036
+ const paths = [...new Set(missed.flatMap((c) => c.paths))];
1356
2037
  response.write(frame({ type: "external-change", paths, ver: (await store.listing()).ver }));
1357
2038
  } else {
1358
2039
  response.write(`id: ${eventId()}
1359
2040
 
1360
2041
  `);
1361
2042
  }
1362
- clients.add(response);
2043
+ clients.set(response, client);
2044
+ share.hold(viewer, response);
1363
2045
  const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
1364
2046
  keepAlive.unref?.();
1365
2047
  const done = () => {
@@ -1389,11 +2071,11 @@ data: ${JSON.stringify(event)}
1389
2071
  "Content-Security-Policy": DATA_CSP
1390
2072
  });
1391
2073
  };
1392
- const serveEditor = async (request, response, rest) => {
2074
+ const serveEditor = async (request, response, rest, viewer) => {
1393
2075
  const parts = rest === "" ? ["index.html"] : rest.split("/");
1394
2076
  const invalid = parts.some((p) => p === "" || p === "." || p === ".." || p.startsWith(".") || p.includes("\\")) || parts[0] === "node";
1395
2077
  const realDist = invalid ? null : await fsp2.realpath(EDITOR_DIR).catch(() => null);
1396
- const file = realDist === null ? null : path3.join(realDist, ...parts);
2078
+ const file = realDist === null ? null : path5.join(realDist, ...parts);
1397
2079
  const real = file === null ? null : await fsp2.realpath(file).catch(() => null);
1398
2080
  const info = real === null ? null : await fsp2.lstat(real).catch(() => null);
1399
2081
  if (file === null || real !== file || !info?.isFile()) {
@@ -1404,25 +2086,26 @@ data: ${JSON.stringify(event)}
1404
2086
  response.writeHead(404, { "Cache-Control": "no-store" }).end();
1405
2087
  return;
1406
2088
  }
1407
- const ext = path3.extname(file).toLowerCase();
2089
+ const ext = path5.extname(file).toLowerCase();
1408
2090
  const type = STATIC_TYPES[ext] ?? "application/octet-stream";
1409
2091
  if (ext === ".html") {
1410
2092
  const key = parts.join("/");
1411
2093
  let page = pageCache.get(key);
1412
2094
  if (!page || page.mtimeMs !== info.mtimeMs) {
1413
2095
  const html = await fsp2.readFile(file, "utf8");
1414
- page = { mtimeMs: info.mtimeMs, body: Buffer.from(html), csp: pageCsp(pageScriptSrc(html)) };
2096
+ page = { mtimeMs: info.mtimeMs, html, csp: pageCsp(pageScriptSrc(html)) };
1415
2097
  pageCache.set(key, page);
1416
2098
  }
2099
+ const body = Buffer.from(share.injectHead(page.html, viewer) ?? page.html);
1417
2100
  response.writeHead(200, {
1418
2101
  "Content-Type": type,
1419
- "Content-Length": page.body.length,
2102
+ "Content-Length": body.length,
1420
2103
  "Cache-Control": "no-cache",
1421
2104
  "X-Content-Type-Options": "nosniff",
1422
2105
  "Referrer-Policy": "no-referrer",
1423
2106
  "Content-Security-Policy": page.csp
1424
2107
  });
1425
- response.end(request.method === "HEAD" ? void 0 : page.body);
2108
+ response.end(request.method === "HEAD" ? void 0 : body);
1426
2109
  return;
1427
2110
  }
1428
2111
  sendFile(request, response, file, {
@@ -1453,6 +2136,10 @@ data: ${JSON.stringify(event)}
1453
2136
  const names = Object.keys(files).map((p) => p.replace(/^project\//, ""));
1454
2137
  stamp(`saved from the page (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""}): ${names.join(", ")}`);
1455
2138
  sendJson2(response, outcome.status, outcome.body);
2139
+ if (outcome.status === 200 && clients.size > 0) {
2140
+ const from = String(request.headers["x-deck-client"] ?? "");
2141
+ changed({ type: "external-change", paths: Object.keys(files), ver: String(outcome.body.version ?? "") }, CLIENT_ID.test(from) ? from : void 0);
2142
+ }
1456
2143
  };
1457
2144
  const upload = async (request, response) => {
1458
2145
  const contentType = request.headers["content-type"] ?? "application/octet-stream";
@@ -1466,9 +2153,18 @@ data: ${JSON.stringify(event)}
1466
2153
  stamp(`uploaded from the page: ${result.url} (${contentType}, ${bytes.length} bytes)`);
1467
2154
  sendJson2(response, 200, result);
1468
2155
  };
1469
- const route = async (request, response, rest) => {
2156
+ const session = (viewer) => {
2157
+ const extras = sessionExtras();
2158
+ if (viewer.guest && "userName" in extras) extras.userName = viewer.user.name;
2159
+ return { canEdit: canEditAs(viewer.role), ...extras, role: viewer.role };
2160
+ };
2161
+ const route = async (request, response, rest, viewer) => {
1470
2162
  const method = request.method ?? "GET";
1471
- if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) return comments.handle(request, response, rest.slice(COMMENTS_ROUTE.length));
2163
+ if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
2164
+ const sub = rest.slice(COMMENTS_ROUTE.length);
2165
+ if (sub === "/events") share.hold(viewer, response);
2166
+ return comments.handle(request, response, sub);
2167
+ }
1472
2168
  if (rest === ROUTES.publish || rest === ROUTES.assets) {
1473
2169
  if (method !== "POST") {
1474
2170
  response.writeHead(405, { Allow: "POST" }).end();
@@ -1479,6 +2175,11 @@ data: ${JSON.stringify(event)}
1479
2175
  publishError(response, 403, "not_writer", "cross-origin request");
1480
2176
  return;
1481
2177
  }
2178
+ if (!canEditAs(viewer.role)) {
2179
+ request.resume();
2180
+ publishError(response, 403, "not_writer", `You can view this ${NOUN} but not edit it.`);
2181
+ return;
2182
+ }
1482
2183
  if (rest === ROUTES.publish) await publish(request, response);
1483
2184
  else await upload(request, response);
1484
2185
  return;
@@ -1488,8 +2189,8 @@ data: ${JSON.stringify(event)}
1488
2189
  return;
1489
2190
  }
1490
2191
  if (rest === ROUTES.listing) return sendJson2(response, 200, await store.listing());
1491
- if (rest === ROUTES.session) return sendJson2(response, 200, { canEdit: true, ...sessionExtras() });
1492
- if (rest === ROUTES.events) return events(request, response);
2192
+ if (rest === ROUTES.session) return sendJson2(response, 200, session(viewer));
2193
+ if (rest === ROUTES.events) return events(request, response, viewer);
1493
2194
  if (rest.startsWith("project/")) {
1494
2195
  const bytes = await store.readProjectFile(rest);
1495
2196
  if (bytes === null) {
@@ -1508,7 +2209,7 @@ data: ${JSON.stringify(event)}
1508
2209
  }
1509
2210
  const blob = BLOB_PATH.exec(rest);
1510
2211
  if (blob) return serveBlob(request, response, blob[1]);
1511
- return serveEditor(request, response, rest);
2212
+ return serveEditor(request, response, rest, viewer);
1512
2213
  };
1513
2214
  const guard = async (response, work) => {
1514
2215
  try {
@@ -1523,17 +2224,25 @@ data: ${JSON.stringify(event)}
1523
2224
  else response.destroy();
1524
2225
  }
1525
2226
  };
1526
- const server2 = http.createServer((request, response) => {
1527
- const url2 = new URL(request.url ?? "/", "http://localhost");
1528
- const pathname = url2.pathname;
1529
- if (pathname === "/" || pathname === "/index.html") {
1530
- response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
2227
+ async function handle(request, response) {
2228
+ if (!share.hostAllowed(request)) {
2229
+ request.resume();
2230
+ response.writeHead(421, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Misdirected request");
1531
2231
  return;
1532
2232
  }
2233
+ const url2 = new URL(request.url ?? "/", "http://localhost");
2234
+ const pathname = url2.pathname;
1533
2235
  if (pathname === "/favicon.ico") {
1534
2236
  response.writeHead(204).end();
1535
2237
  return;
1536
2238
  }
2239
+ if (await share.route(request, response)) return;
2240
+ const viewer = share.viewerOf(request);
2241
+ if (!viewer) return share.denied(request, response);
2242
+ if (pathname === "/" || pathname === "/index.html") {
2243
+ response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
2244
+ return;
2245
+ }
1537
2246
  const rootBlob = BLOB_PATH.exec(pathname.slice(1));
1538
2247
  if (rootBlob) {
1539
2248
  void guard(response, () => serveBlob(request, response, rootBlob[1]));
@@ -1555,30 +2264,41 @@ data: ${JSON.stringify(event)}
1555
2264
  response.writeHead(400).end();
1556
2265
  return;
1557
2266
  }
1558
- void guard(response, () => route(request, response, rest));
1559
- });
2267
+ void guard(response, () => route(request, response, rest, viewer));
2268
+ }
2269
+ const server2 = http.createServer((request, response) => void guard(response, () => handle(request, response)));
1560
2270
  const close = () => {
1561
2271
  stopWatch();
1562
2272
  comments.close();
1563
- for (const client of clients) client.end();
2273
+ share.close();
2274
+ for (const client of clients.keys()) client.end();
1564
2275
  clients.clear();
1565
2276
  server2.close();
1566
2277
  server2.closeAllConnections?.();
1567
2278
  };
1568
- const actualPort = await listen(server2, port, host).catch((error) => {
2279
+ const actualPort = await share.listen(server2, { port, host, lan, portGiven }).catch((error) => {
1569
2280
  close();
1570
2281
  throw error;
1571
2282
  });
1572
2283
  const url = `${serverOrigin(host, actualPort)}${editorPath}`;
1573
2284
  stamp(`serving ${project} at ${url}`);
1574
- return { url, port: actualPort, grant, close };
2285
+ const lanUrls = share.lanUrls();
2286
+ return { url, port: actualPort, grant, close, ...lanUrls.length ? { lan: lanUrls } : {} };
2287
+ }
2288
+ function artifactTitle(root, projectDir) {
2289
+ try {
2290
+ const index = JSON.parse(fs6.readFileSync(path5.join(projectDir, INDEX_FILE), "utf8"));
2291
+ for (const value of [index.title, index.name]) if (typeof value === "string" && value.trim() && value.trim() !== "project") return value.trim();
2292
+ } catch {
2293
+ }
2294
+ return path5.basename(root);
1575
2295
  }
1576
2296
  var runningServer2 = (root) => runningServer(root, KIND, ROUTES.session);
1577
- async function serveForeground({ root, port, host, agentHook }) {
1578
- const running = await startServer({ root, port, host, agentHook });
2297
+ async function serveForeground({ root, port, host, agentHook, lan, portGiven }) {
2298
+ const running = await startServer({ root, port, host, agentHook, lan, portGiven });
1579
2299
  const state = { pid: process.pid, url: running.url, port: running.port, root, kind: KIND, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
1580
2300
  recordServer(state, running.close);
1581
- return state;
2301
+ return { ...state, ...running.lan ? { lan: running.lan } : {} };
1582
2302
  }
1583
2303
  async function main(argv) {
1584
2304
  const positional = [];
@@ -1587,26 +2307,27 @@ async function main(argv) {
1587
2307
  const arg = argv[i];
1588
2308
  if (!arg.startsWith("--")) positional.push(arg);
1589
2309
  else if (arg.includes("=")) flags.set(arg.slice(2, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1));
2310
+ else if (arg === "--lan") flags.set("lan", "true");
1590
2311
  else flags.set(arg.slice(2), argv[++i] ?? "");
1591
2312
  }
1592
2313
  if (positional.length !== 1 || flags.has("help")) {
1593
2314
  process.stderr.write(
1594
- `Usage: node ${path3.basename(SCRIPT)} <root> [--port n] [--host h] [--agent-hook "<command>"]
2315
+ `Usage: node ${path5.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
1595
2316
  <root> holds project/${INDEX_FILE}.
1596
2317
  `
1597
2318
  );
1598
2319
  return 2;
1599
2320
  }
1600
- let root = path3.resolve(positional[0]);
1601
- if (path3.basename(root) === "project" && fs4.existsSync(path3.join(root, INDEX_FILE)) && !fs4.existsSync(path3.join(root, "project"))) {
1602
- root = path3.dirname(root);
2321
+ let root = path5.resolve(positional[0]);
2322
+ if (path5.basename(root) === "project" && fs6.existsSync(path5.join(root, INDEX_FILE)) && !fs6.existsSync(path5.join(root, "project"))) {
2323
+ root = path5.dirname(root);
1603
2324
  }
1604
- if (!fs4.statSync(path3.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
1605
- process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path3.join(root, "project", INDEX_FILE)} does not exist.` })}
2325
+ if (!fs6.statSync(path5.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
2326
+ process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path5.join(root, "project", INDEX_FILE)} does not exist.` })}
1606
2327
  `);
1607
2328
  return 2;
1608
2329
  }
1609
- const port = Number(flags.get("port") ?? 0);
2330
+ const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORTS[KIND];
1610
2331
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
1611
2332
  process.stderr.write(`${JSON.stringify({ error: "INVALID_PORT", message: "--port is a number from 0 to 65535." })}
1612
2333
  `);
@@ -1619,14 +2340,14 @@ async function main(argv) {
1619
2340
  return 0;
1620
2341
  }
1621
2342
  const agentHook = flags.get("agent-hook") || void 0;
1622
- const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook });
1623
- process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid }, null, 2)}
2343
+ const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook, lan: flags.has("lan"), portGiven: flags.has("port") });
2344
+ process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid, ...state.lan ? { lan: state.lan } : {} }, null, 2)}
1624
2345
  `);
1625
2346
  return -1;
1626
2347
  }
1627
2348
  function isMain(moduleUrl) {
1628
2349
  try {
1629
- return Boolean(process.argv[1]) && pathToFileURL(fs4.realpathSync(process.argv[1])).href === moduleUrl;
2350
+ return Boolean(process.argv[1]) && pathToFileURL(fs6.realpathSync(process.argv[1])).href === moduleUrl;
1630
2351
  } catch {
1631
2352
  return false;
1632
2353
  }
@@ -1640,7 +2361,7 @@ function createArtifactServer(kind, moduleUrl) {
1640
2361
  sessionExtras = kind.sessionExtras;
1641
2362
  commentLocation = kind.commentLocation;
1642
2363
  SCRIPT = fileURLToPath(moduleUrl);
1643
- EDITOR_DIR = path3.join(path3.dirname(SCRIPT), "editor");
2364
+ EDITOR_DIR = path5.join(path5.dirname(SCRIPT), "editor");
1644
2365
  const runIfMain = () => {
1645
2366
  if (!isMain(moduleUrl)) return;
1646
2367
  main(process.argv.slice(2)).then(
@@ -1648,7 +2369,8 @@ function createArtifactServer(kind, moduleUrl) {
1648
2369
  if (code >= 0) process.exitCode = code;
1649
2370
  },
1650
2371
  (error) => {
1651
- process.stderr.write(`${JSON.stringify({ error: "SERVER_FAILED", message: errorText(error) })}
2372
+ const code = error.code === "PORT_IN_USE" ? "PORT_IN_USE" : "SERVER_FAILED";
2373
+ process.stderr.write(`${JSON.stringify({ error: code, message: errorText(error) })}
1652
2374
  `);
1653
2375
  process.exitCode = 2;
1654
2376
  }
@@ -1690,13 +2412,13 @@ function commentLocation2(input, projectDir) {
1690
2412
  let slideId = match[2];
1691
2413
  if (!slideId) {
1692
2414
  try {
1693
- const order = JSON.parse(fs5.readFileSync(path4.join(projectDir, INDEX_FILE2), "utf8")).order;
2415
+ const order = JSON.parse(fs7.readFileSync(path6.join(projectDir, INDEX_FILE2), "utf8")).order;
1694
2416
  if (Array.isArray(order) && typeof order[slide - 1] === "string") slideId = order[slide - 1];
1695
2417
  } catch {
1696
2418
  }
1697
2419
  }
1698
2420
  const file = slideId ? projectFile(`slides/${slideId}.html`) : void 0;
1699
- if (slideId && file && fs5.existsSync(path4.join(projectDir, "slides", `${slideId}.html`))) return { file, slide, slideId };
2421
+ if (slideId && file && fs7.existsSync(path6.join(projectDir, "slides", `${slideId}.html`))) return { file, slide, slideId };
1700
2422
  return { file: "project/deck.json", slide };
1701
2423
  }
1702
2424
  var server = createArtifactServer(