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.
- package/LICENSE +5 -0
- package/NOTICE.md +58 -0
- package/README.md +1 -1
- package/lib/client.js +1 -1
- package/lib/index.js +1 -1
- package/package.json +4 -3
- package/skills/claude-slides/SKILL.md +14 -1
- package/skills/claude-slides/scripts/artifact.mjs +316 -78
- package/skills/claude-slides/scripts/editor/assets/{index-qb2rdK_o.js → index-DULUou9H.js} +93 -93
- package/skills/claude-slides/scripts/editor/assets/{woff2-decoder-DPKKHboC.js → woff2-decoder-B0JzUtP5.js} +1 -1
- package/skills/claude-slides/scripts/editor/index.html +2 -2
- package/skills/claude-slides/scripts/editor/node/compiler.cjs +7 -0
- package/skills/claude-slides/scripts/editor/share-ui.js +83 -0
- package/skills/claude-slides/scripts/server.mjs +820 -98
|
@@ -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
|
|
5
|
-
import
|
|
4
|
+
import fs7 from "node:fs";
|
|
5
|
+
import path6 from "node:path";
|
|
6
6
|
|
|
7
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
-
import
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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()
|
|
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
|
|
385
|
+
let base;
|
|
369
386
|
try {
|
|
370
|
-
|
|
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 =
|
|
376
|
-
lastPresence = JSON.stringify(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
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-" +
|
|
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 :
|
|
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(
|
|
994
|
-
const 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 =
|
|
1037
|
-
for (let 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 (
|
|
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(
|
|
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 :
|
|
1209
|
-
if (directories.has(dir) || !
|
|
1210
|
-
const info =
|
|
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
|
|
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 =
|
|
1890
|
+
watcher = fs6.watch(dir, (event, name) => {
|
|
1232
1891
|
if (stopped) return;
|
|
1233
1892
|
if (name) {
|
|
1234
|
-
const changed =
|
|
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 +
|
|
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 =
|
|
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(
|
|
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 =
|
|
1307
|
-
`), agentHook }) {
|
|
1308
|
-
const 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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.
|
|
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 :
|
|
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 =
|
|
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,
|
|
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":
|
|
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 :
|
|
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
|
|
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}/`))
|
|
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,
|
|
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
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 =
|
|
1601
|
-
if (
|
|
1602
|
-
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 (!
|
|
1605
|
-
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${
|
|
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")
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 &&
|
|
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(
|