dsh-claude-design 2.7.2 → 2.8.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/NOTICE.md +2 -1
- package/lib/client.js +1 -1
- package/lib/index.js +1 -1
- package/package.json +1 -1
- package/skills/claude-design/SKILL.md +3 -3
- package/skills/claude-design/scripts/artifact.mjs +198 -105
- package/skills/claude-design/scripts/server.mjs +995 -421
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// claude-design: local editor server (prebuilt; do not edit).
|
|
3
3
|
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import fs13 from "node:fs";
|
|
5
|
+
import path13 from "node:path";
|
|
6
6
|
|
|
7
|
-
import { createHash as
|
|
8
|
-
import
|
|
7
|
+
import { createHash as createHash5, randomBytes as randomBytes2, randomUUID } from "node:crypto";
|
|
8
|
+
import fs12 from "node:fs";
|
|
9
9
|
import fsp4 from "node:fs/promises";
|
|
10
|
-
import
|
|
11
|
-
import path11 from "node:path";
|
|
10
|
+
import path12 from "node:path";
|
|
12
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
12
|
|
|
14
13
|
import { spawn } from "node:child_process";
|
|
@@ -768,6 +767,7 @@ data: ${JSON.stringify(snap)}
|
|
|
768
767
|
return { handle, presence, deliver, sendRequest, requestStates, close };
|
|
769
768
|
}
|
|
770
769
|
|
|
770
|
+
import { createHash } from "node:crypto";
|
|
771
771
|
import fs3 from "node:fs";
|
|
772
772
|
import net from "node:net";
|
|
773
773
|
import os2 from "node:os";
|
|
@@ -800,17 +800,15 @@ async function runningServer(root, kind, probe) {
|
|
|
800
800
|
return null;
|
|
801
801
|
}
|
|
802
802
|
}
|
|
803
|
-
function
|
|
804
|
-
|
|
805
|
-
fs3.writeFileSync(file, `${JSON.stringify(state, null, 2)}
|
|
803
|
+
function writeState(state) {
|
|
804
|
+
fs3.writeFileSync(path2.join(state.root, STATE_FILE), `${JSON.stringify(state, null, 2)}
|
|
806
805
|
`);
|
|
807
806
|
writeGitignore(state.root, state.kind);
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
};
|
|
813
|
-
for (const signal of signals) process.on(signal, shutdown);
|
|
807
|
+
}
|
|
808
|
+
function removeState(root) {
|
|
809
|
+
const state = readState(root);
|
|
810
|
+
if (state && state.pid !== process.pid && alive(state.pid)) return;
|
|
811
|
+
fs3.rmSync(path2.join(root, STATE_FILE), { force: true });
|
|
814
812
|
}
|
|
815
813
|
var GITIGNORE_FILE = ".gitignore";
|
|
816
814
|
var gitignoreLines = (kind) => [STATE_FILE, LOG_FILE, ".sharing.json", ...kind === "docs" ? [] : ["history/"], ".*.tmp", ".DS_Store"];
|
|
@@ -909,6 +907,7 @@ function serverOrigin(host, port) {
|
|
|
909
907
|
const shown2 = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
|
|
910
908
|
return `http://${shown2}:${port}`;
|
|
911
909
|
}
|
|
910
|
+
var pagePathOf = (kind, root) => `/${kind}/${createHash("sha256").update(path2.resolve(root)).digest("base64url").slice(0, 22)}/`;
|
|
912
911
|
function sendJson2(response, status, body, headers = {}) {
|
|
913
912
|
const text = JSON.stringify(body);
|
|
914
913
|
response.writeHead(status, {
|
|
@@ -1496,8 +1495,764 @@ ${sections || '<p class="muted">This copy has no tokens, components or guide.</p
|
|
|
1496
1495
|
`;
|
|
1497
1496
|
}
|
|
1498
1497
|
|
|
1499
|
-
import
|
|
1500
|
-
import
|
|
1498
|
+
import { EventEmitter } from "node:events";
|
|
1499
|
+
import fs6 from "node:fs";
|
|
1500
|
+
import http from "node:http";
|
|
1501
|
+
import os3 from "node:os";
|
|
1502
|
+
import path6 from "node:path";
|
|
1503
|
+
|
|
1504
|
+
import crypto from "node:crypto";
|
|
1505
|
+
import fs5 from "node:fs";
|
|
1506
|
+
import path5 from "node:path";
|
|
1507
|
+
var GUEST_ROLES = ["editor", "commenter", "viewer"];
|
|
1508
|
+
var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
|
|
1509
|
+
var SHARING_FILE = ".sharing.json";
|
|
1510
|
+
var MAX_GUESTS = 200;
|
|
1511
|
+
var MAX_NAME = 40;
|
|
1512
|
+
var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
|
|
1513
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1514
|
+
function fresh() {
|
|
1515
|
+
return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
|
|
1516
|
+
}
|
|
1517
|
+
function cleanName(raw) {
|
|
1518
|
+
const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f@<>]/g, "").replace(/\s+/g, " ").trim() : "";
|
|
1519
|
+
return [...name].slice(0, MAX_NAME).join("");
|
|
1520
|
+
}
|
|
1521
|
+
function guestUser(guest) {
|
|
1522
|
+
return { id: `guest-${guest.id}`, name: guest.name, email: `${guest.name}@${guest.id}.guest` };
|
|
1523
|
+
}
|
|
1524
|
+
function isLoopback(address) {
|
|
1525
|
+
if (!address) return false;
|
|
1526
|
+
const a = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
1527
|
+
return a === "::1" || a.startsWith("127.");
|
|
1528
|
+
}
|
|
1529
|
+
var Sharing = class _Sharing {
|
|
1530
|
+
file;
|
|
1531
|
+
state;
|
|
1532
|
+
constructor(root) {
|
|
1533
|
+
this.file = path5.join(root, SHARING_FILE);
|
|
1534
|
+
this.state = _Sharing.read(this.file);
|
|
1535
|
+
}
|
|
1536
|
+
static read(file) {
|
|
1537
|
+
let raw;
|
|
1538
|
+
try {
|
|
1539
|
+
raw = JSON.parse(fs5.readFileSync(file, "utf8"));
|
|
1540
|
+
} catch {
|
|
1541
|
+
return fresh();
|
|
1542
|
+
}
|
|
1543
|
+
const state = fresh();
|
|
1544
|
+
if (!isRecord2(raw)) return state;
|
|
1545
|
+
state.lan = raw.lan === true;
|
|
1546
|
+
if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
|
|
1547
|
+
if (isRecord2(raw.links)) {
|
|
1548
|
+
for (const role of GUEST_ROLES) {
|
|
1549
|
+
const link = raw.links[role];
|
|
1550
|
+
if (isRecord2(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
if (Array.isArray(raw.guests)) {
|
|
1554
|
+
for (const g of raw.guests) {
|
|
1555
|
+
if (!isRecord2(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
1556
|
+
state.guests.push({
|
|
1557
|
+
id: g.id,
|
|
1558
|
+
secret: g.secret,
|
|
1559
|
+
name: cleanName(g.name) || "Guest",
|
|
1560
|
+
role: g.role,
|
|
1561
|
+
link: g.link,
|
|
1562
|
+
joined: typeof g.joined === "string" ? g.joined : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1563
|
+
...typeof g.seen === "string" ? { seen: g.seen } : {}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return state;
|
|
1568
|
+
}
|
|
1569
|
+
save() {
|
|
1570
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
1571
|
+
fs5.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
|
|
1572
|
+
`, { mode: 384 });
|
|
1573
|
+
fs5.renameSync(tmp, this.file);
|
|
1574
|
+
}
|
|
1575
|
+
/** The role whose link has this token, while that link is on. */
|
|
1576
|
+
linkRole(tokenGiven) {
|
|
1577
|
+
for (const role of GUEST_ROLES) {
|
|
1578
|
+
const link = this.state.links[role];
|
|
1579
|
+
if (link.on && tokenGiven.length === link.token.length && crypto.timingSafeEqual(Buffer.from(tokenGiven), Buffer.from(link.token))) return role;
|
|
1580
|
+
}
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
/** The guest a cookie names, if they still have access (their link on and not reset). */
|
|
1584
|
+
guest(secret) {
|
|
1585
|
+
if (!secret) return null;
|
|
1586
|
+
const guest = this.state.guests.find((g) => g.secret === secret);
|
|
1587
|
+
return guest && this.valid(guest) ? guest : null;
|
|
1588
|
+
}
|
|
1589
|
+
valid(guest) {
|
|
1590
|
+
const link = this.state.links[guest.role];
|
|
1591
|
+
return link.on && link.token === guest.link && this.state.guests.includes(guest);
|
|
1592
|
+
}
|
|
1593
|
+
join(role, name) {
|
|
1594
|
+
const guest = { id: token(4), secret: token(24), name: cleanName(name) || "Guest", role, link: this.state.links[role].token, joined: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1595
|
+
this.state.guests.push(guest);
|
|
1596
|
+
if (this.state.guests.length > MAX_GUESTS) this.state.guests.splice(0, this.state.guests.length - MAX_GUESTS);
|
|
1597
|
+
this.save();
|
|
1598
|
+
return guest;
|
|
1599
|
+
}
|
|
1600
|
+
/** Marks a guest seen now (saved at most once a minute per guest). */
|
|
1601
|
+
seen(guest) {
|
|
1602
|
+
const now = /* @__PURE__ */ new Date();
|
|
1603
|
+
if (guest.seen && now.getTime() - Date.parse(guest.seen) < 6e4) return;
|
|
1604
|
+
guest.seen = now.toISOString();
|
|
1605
|
+
this.save();
|
|
1606
|
+
}
|
|
1607
|
+
setLan(on, port) {
|
|
1608
|
+
this.state.lan = on;
|
|
1609
|
+
if (on && port) this.state.port = port;
|
|
1610
|
+
this.save();
|
|
1611
|
+
}
|
|
1612
|
+
setLink(role, on) {
|
|
1613
|
+
this.state.links[role].on = on;
|
|
1614
|
+
this.save();
|
|
1615
|
+
}
|
|
1616
|
+
/** A new token for the link: the old link stops working and the guests who came through it are dropped. */
|
|
1617
|
+
resetLink(role) {
|
|
1618
|
+
const old = this.state.links[role].token;
|
|
1619
|
+
this.state.links[role] = { token: token(), on: this.state.links[role].on };
|
|
1620
|
+
this.state.guests = this.state.guests.filter((g) => g.link !== old);
|
|
1621
|
+
this.save();
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Gives a guest another role. They now hang on that role's link (turning it off or resetting it drops them), so a role
|
|
1625
|
+
* whose link is off would shut them out: false then, as for a guest who isn't there.
|
|
1626
|
+
*/
|
|
1627
|
+
setRole(id, role) {
|
|
1628
|
+
const guest = this.state.guests.find((g) => g.id === id);
|
|
1629
|
+
if (!guest || !this.state.links[role].on) return false;
|
|
1630
|
+
guest.role = role;
|
|
1631
|
+
guest.link = this.state.links[role].token;
|
|
1632
|
+
this.save();
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
removeGuest(id) {
|
|
1636
|
+
const before = this.state.guests.length;
|
|
1637
|
+
this.state.guests = this.state.guests.filter((g) => g.id !== id);
|
|
1638
|
+
if (this.state.guests.length === before) return false;
|
|
1639
|
+
this.save();
|
|
1640
|
+
return true;
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1645
|
+
var GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
1646
|
+
var MAX_MESSAGE = 64 * 1024 * 1024;
|
|
1647
|
+
var WebSocketConnection = class {
|
|
1648
|
+
socket;
|
|
1649
|
+
buffer = Buffer.alloc(0);
|
|
1650
|
+
parts = [];
|
|
1651
|
+
partOpcode = 0;
|
|
1652
|
+
closed = false;
|
|
1653
|
+
handlers = {};
|
|
1654
|
+
constructor(socket, head) {
|
|
1655
|
+
this.socket = socket;
|
|
1656
|
+
socket.on("data", (chunk) => this.receive(chunk));
|
|
1657
|
+
socket.on("close", () => this.finish());
|
|
1658
|
+
socket.on("error", () => this.finish());
|
|
1659
|
+
if (head.length) this.receive(head);
|
|
1660
|
+
}
|
|
1661
|
+
get open() {
|
|
1662
|
+
return !this.closed;
|
|
1663
|
+
}
|
|
1664
|
+
sendText(text) {
|
|
1665
|
+
this.send(1, Buffer.from(text, "utf8"));
|
|
1666
|
+
}
|
|
1667
|
+
sendBinary(data) {
|
|
1668
|
+
this.send(2, data);
|
|
1669
|
+
}
|
|
1670
|
+
close(code = 1e3, reason = "") {
|
|
1671
|
+
if (this.closed) return;
|
|
1672
|
+
const body = Buffer.alloc(2 + Buffer.byteLength(reason));
|
|
1673
|
+
body.writeUInt16BE(code, 0);
|
|
1674
|
+
body.write(reason, 2);
|
|
1675
|
+
this.send(8, body);
|
|
1676
|
+
this.socket.end();
|
|
1677
|
+
this.finish();
|
|
1678
|
+
}
|
|
1679
|
+
finish() {
|
|
1680
|
+
if (this.closed) return;
|
|
1681
|
+
this.closed = true;
|
|
1682
|
+
this.socket.destroy();
|
|
1683
|
+
this.handlers.close?.();
|
|
1684
|
+
}
|
|
1685
|
+
send(opcode, payload) {
|
|
1686
|
+
if (this.closed) return;
|
|
1687
|
+
const length = payload.length;
|
|
1688
|
+
let header;
|
|
1689
|
+
if (length < 126) {
|
|
1690
|
+
header = Buffer.from([128 | opcode, length]);
|
|
1691
|
+
} else if (length < 65536) {
|
|
1692
|
+
header = Buffer.alloc(4);
|
|
1693
|
+
header[0] = 128 | opcode;
|
|
1694
|
+
header[1] = 126;
|
|
1695
|
+
header.writeUInt16BE(length, 2);
|
|
1696
|
+
} else {
|
|
1697
|
+
header = Buffer.alloc(10);
|
|
1698
|
+
header[0] = 128 | opcode;
|
|
1699
|
+
header[1] = 127;
|
|
1700
|
+
header.writeBigUInt64BE(BigInt(length), 2);
|
|
1701
|
+
}
|
|
1702
|
+
this.socket.write(Buffer.concat([header, payload]));
|
|
1703
|
+
}
|
|
1704
|
+
receive(chunk) {
|
|
1705
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
|
|
1706
|
+
for (; ; ) {
|
|
1707
|
+
if (this.buffer.length < 2) return;
|
|
1708
|
+
const first = this.buffer[0];
|
|
1709
|
+
const second = this.buffer[1];
|
|
1710
|
+
const fin = (first & 128) !== 0;
|
|
1711
|
+
const opcode = first & 15;
|
|
1712
|
+
const masked = (second & 128) !== 0;
|
|
1713
|
+
let length = second & 127;
|
|
1714
|
+
let offset = 2;
|
|
1715
|
+
if (length === 126) {
|
|
1716
|
+
if (this.buffer.length < 4) return;
|
|
1717
|
+
length = this.buffer.readUInt16BE(2);
|
|
1718
|
+
offset = 4;
|
|
1719
|
+
} else if (length === 127) {
|
|
1720
|
+
if (this.buffer.length < 10) return;
|
|
1721
|
+
const big = this.buffer.readBigUInt64BE(2);
|
|
1722
|
+
if (big > BigInt(MAX_MESSAGE)) return this.close(1009, "message too big");
|
|
1723
|
+
length = Number(big);
|
|
1724
|
+
offset = 10;
|
|
1725
|
+
}
|
|
1726
|
+
const maskLength = masked ? 4 : 0;
|
|
1727
|
+
if (this.buffer.length < offset + maskLength + length) return;
|
|
1728
|
+
const mask = masked ? this.buffer.subarray(offset, offset + 4) : null;
|
|
1729
|
+
const payload = Buffer.from(this.buffer.subarray(offset + maskLength, offset + maskLength + length));
|
|
1730
|
+
if (mask) for (let index = 0; index < payload.length; index++) payload[index] = payload[index] ^ mask[index & 3];
|
|
1731
|
+
this.buffer = this.buffer.subarray(offset + maskLength + length);
|
|
1732
|
+
this.frame(fin, opcode, payload);
|
|
1733
|
+
if (this.closed) return;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
frame(fin, opcode, payload) {
|
|
1737
|
+
switch (opcode) {
|
|
1738
|
+
case 8:
|
|
1739
|
+
this.close(payload.length >= 2 ? payload.readUInt16BE(0) : 1e3);
|
|
1740
|
+
return;
|
|
1741
|
+
case 9:
|
|
1742
|
+
this.send(10, payload);
|
|
1743
|
+
return;
|
|
1744
|
+
case 10:
|
|
1745
|
+
return;
|
|
1746
|
+
case 0:
|
|
1747
|
+
case 1:
|
|
1748
|
+
case 2: {
|
|
1749
|
+
if (opcode !== 0) {
|
|
1750
|
+
this.parts = [];
|
|
1751
|
+
this.partOpcode = opcode;
|
|
1752
|
+
}
|
|
1753
|
+
this.parts.push(payload);
|
|
1754
|
+
if (this.parts.reduce((total, part) => total + part.length, 0) > MAX_MESSAGE) return this.close(1009, "message too big");
|
|
1755
|
+
if (!fin) return;
|
|
1756
|
+
const message = Buffer.concat(this.parts);
|
|
1757
|
+
this.parts = [];
|
|
1758
|
+
if (this.partOpcode === 1) this.handlers.text?.(message.toString("utf8"));
|
|
1759
|
+
else this.handlers.binary?.(message);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
default:
|
|
1763
|
+
this.close(1002, "unknown opcode");
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
function acceptWebSocket(request, socket, head, protocol) {
|
|
1768
|
+
const key = request.headers["sec-websocket-key"];
|
|
1769
|
+
if (typeof key !== "string" || String(request.headers.upgrade).toLowerCase() !== "websocket") {
|
|
1770
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1771
|
+
return null;
|
|
1772
|
+
}
|
|
1773
|
+
const accept = createHash2("sha1").update(key + GUID).digest("base64");
|
|
1774
|
+
socket.write(["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, ...protocol ? [`Sec-WebSocket-Protocol: ${protocol}`] : [], "", ""].join("\r\n"));
|
|
1775
|
+
return new WebSocketConnection(socket, head);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
var HUB_ROUTE = "/__hub";
|
|
1779
|
+
var STREAM_PROTOCOL = "baoyu-sse";
|
|
1780
|
+
var STREAM_REFUSED = 4e3;
|
|
1781
|
+
function buildOf(script2) {
|
|
1782
|
+
const real = fs6.realpathSync(script2);
|
|
1783
|
+
const { mtimeMs, size } = fs6.statSync(real);
|
|
1784
|
+
return `${real}:${mtimeMs}:${size}`;
|
|
1785
|
+
}
|
|
1786
|
+
var SiteClash = class extends Error {
|
|
1787
|
+
};
|
|
1788
|
+
var StreamResponse = class extends EventEmitter {
|
|
1789
|
+
status = 200;
|
|
1790
|
+
headersSent = false;
|
|
1791
|
+
writableEnded = false;
|
|
1792
|
+
destroyed = false;
|
|
1793
|
+
ws;
|
|
1794
|
+
constructor(ws) {
|
|
1795
|
+
super();
|
|
1796
|
+
this.ws = ws;
|
|
1797
|
+
}
|
|
1798
|
+
writeHead(status) {
|
|
1799
|
+
this.status = status;
|
|
1800
|
+
this.headersSent = true;
|
|
1801
|
+
return this;
|
|
1802
|
+
}
|
|
1803
|
+
setHeader() {
|
|
1804
|
+
return this;
|
|
1805
|
+
}
|
|
1806
|
+
flushHeaders() {
|
|
1807
|
+
}
|
|
1808
|
+
write(chunk) {
|
|
1809
|
+
if (this.status === 200 && !this.writableEnded) this.ws.sendText(String(chunk));
|
|
1810
|
+
return true;
|
|
1811
|
+
}
|
|
1812
|
+
end(chunk) {
|
|
1813
|
+
if (this.writableEnded) return this;
|
|
1814
|
+
if (chunk !== void 0) this.write(chunk);
|
|
1815
|
+
this.writableEnded = true;
|
|
1816
|
+
this.ws.close(this.status === 200 ? 1e3 : STREAM_REFUSED + Math.min(this.status, 999));
|
|
1817
|
+
return this;
|
|
1818
|
+
}
|
|
1819
|
+
destroy() {
|
|
1820
|
+
this.destroyed = true;
|
|
1821
|
+
return this.end();
|
|
1822
|
+
}
|
|
1823
|
+
closed() {
|
|
1824
|
+
this.writableEnded = true;
|
|
1825
|
+
this.emit("close");
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
var Hub = class {
|
|
1829
|
+
port = 0;
|
|
1830
|
+
kind;
|
|
1831
|
+
host;
|
|
1832
|
+
build;
|
|
1833
|
+
factory;
|
|
1834
|
+
/** Sites' logs go to its stderr too: it runs in someone's terminal, not in the background. */
|
|
1835
|
+
echo;
|
|
1836
|
+
server;
|
|
1837
|
+
/** page → site. */
|
|
1838
|
+
sites = /* @__PURE__ */ new Map();
|
|
1839
|
+
adding = /* @__PURE__ */ new Map();
|
|
1840
|
+
startedAt = /* @__PURE__ */ new Map();
|
|
1841
|
+
hosts = /* @__PURE__ */ new Set();
|
|
1842
|
+
/** One listener per LAN address while any site is open to the network. */
|
|
1843
|
+
lanServers = /* @__PURE__ */ new Map();
|
|
1844
|
+
network;
|
|
1845
|
+
constructor(options) {
|
|
1846
|
+
this.echo = options.echo;
|
|
1847
|
+
this.kind = options.kind;
|
|
1848
|
+
this.build = options.build;
|
|
1849
|
+
this.host = options.host;
|
|
1850
|
+
this.factory = options.site;
|
|
1851
|
+
this.server = this.createServer();
|
|
1852
|
+
const hub = this;
|
|
1853
|
+
this.network = {
|
|
1854
|
+
get port() {
|
|
1855
|
+
return hub.port;
|
|
1856
|
+
},
|
|
1857
|
+
addresses: () => [...this.lanServers.keys()],
|
|
1858
|
+
open: () => this.openLan(),
|
|
1859
|
+
release: () => this.releaseLan()
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
createServer() {
|
|
1863
|
+
const server2 = http.createServer((request, response) => {
|
|
1864
|
+
this.onRequest(request, response).catch((error) => {
|
|
1865
|
+
process.stderr.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] request failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
1866
|
+
`);
|
|
1867
|
+
if (!response.headersSent) sendJson2(response, 500, { code: "internal", message: "internal error" });
|
|
1868
|
+
else response.destroy();
|
|
1869
|
+
});
|
|
1870
|
+
});
|
|
1871
|
+
server2.on("upgrade", (request, socket, head) => this.onUpgrade(request, socket, head));
|
|
1872
|
+
return server2;
|
|
1873
|
+
}
|
|
1874
|
+
/** Listens at the first free port from `port`; `first` is tried before them. */
|
|
1875
|
+
async listen(port, first) {
|
|
1876
|
+
this.port = await listenFree(this.server, port, this.host, first);
|
|
1877
|
+
const named = this.host === "0.0.0.0" || this.host === "::" ? [] : [this.host.includes(":") ? `[${this.host}]:${this.port}` : `${this.host}:${this.port}`];
|
|
1878
|
+
this.hosts = /* @__PURE__ */ new Set([`127.0.0.1:${this.port}`, `localhost:${this.port}`, `[::1]:${this.port}`, ...named]);
|
|
1879
|
+
return this.port;
|
|
1880
|
+
}
|
|
1881
|
+
/** Ends every site and the listeners; what the folders' state files say goes with them. */
|
|
1882
|
+
close() {
|
|
1883
|
+
for (const site of [...this.sites.values()]) this.drop(site);
|
|
1884
|
+
this.closeLan();
|
|
1885
|
+
this.server.close();
|
|
1886
|
+
this.server.closeAllConnections?.();
|
|
1887
|
+
}
|
|
1888
|
+
/** On a signal (or a stray error, logged): every site's record goes and the process exits. */
|
|
1889
|
+
ownProcess() {
|
|
1890
|
+
const shutdown = () => {
|
|
1891
|
+
this.close();
|
|
1892
|
+
process.exit(0);
|
|
1893
|
+
};
|
|
1894
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, shutdown);
|
|
1895
|
+
const stray = (error) => process.stderr.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] uncaught: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
1896
|
+
`);
|
|
1897
|
+
process.on("uncaughtException", stray);
|
|
1898
|
+
process.on("unhandledRejection", stray);
|
|
1899
|
+
}
|
|
1900
|
+
// ---- the sites -------------------------------------------------------------------------------------------------
|
|
1901
|
+
urlOf(site) {
|
|
1902
|
+
return `${serverOrigin(this.host, this.port)}${site.page}`;
|
|
1903
|
+
}
|
|
1904
|
+
stateOf(site) {
|
|
1905
|
+
const lan = site.share.lanUrls();
|
|
1906
|
+
return { pid: process.pid, url: this.urlOf(site), port: this.port, root: site.root, kind: this.kind, startedAt: this.startedAt.get(site.page) ?? (/* @__PURE__ */ new Date()).toISOString(), ...lan.length ? { lan } : {} };
|
|
1907
|
+
}
|
|
1908
|
+
/** Mounts a folder (already mounted: the same site), writing its <root>/.artifact-server.json. */
|
|
1909
|
+
add(root, options) {
|
|
1910
|
+
const mounted = [...this.sites.values()].find((site) => site.root === root);
|
|
1911
|
+
if (mounted) return (options.lan && !mounted.share.shared ? mounted.share.setLan(true) : Promise.resolve()).then(() => this.stateOf(mounted));
|
|
1912
|
+
const adding = this.adding.get(root);
|
|
1913
|
+
if (adding) return adding;
|
|
1914
|
+
const work = this.mount(root, options).finally(() => this.adding.delete(root));
|
|
1915
|
+
this.adding.set(root, work);
|
|
1916
|
+
return work;
|
|
1917
|
+
}
|
|
1918
|
+
async mount(root, { agentHook, lan = false }) {
|
|
1919
|
+
const file = path6.join(root, LOG_FILE);
|
|
1920
|
+
const log = (line) => {
|
|
1921
|
+
const text = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}
|
|
1922
|
+
`;
|
|
1923
|
+
try {
|
|
1924
|
+
fs6.appendFileSync(file, text);
|
|
1925
|
+
} catch {
|
|
1926
|
+
}
|
|
1927
|
+
if (this.echo) process.stderr.write(text);
|
|
1928
|
+
};
|
|
1929
|
+
const site = await this.factory(root, { log, network: this.network, ...agentHook ? { agentHook } : {} });
|
|
1930
|
+
const other = this.sites.get(site.page);
|
|
1931
|
+
if (other) {
|
|
1932
|
+
site.close();
|
|
1933
|
+
throw new SiteClash(`${other.root} is already open at ${site.page}.`);
|
|
1934
|
+
}
|
|
1935
|
+
this.sites.set(site.page, site);
|
|
1936
|
+
this.startedAt.set(site.page, (/* @__PURE__ */ new Date()).toISOString());
|
|
1937
|
+
try {
|
|
1938
|
+
await site.share.start(lan);
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
this.drop(site);
|
|
1941
|
+
throw error;
|
|
1942
|
+
}
|
|
1943
|
+
const state = this.stateOf(site);
|
|
1944
|
+
const { lan: _lan, ...record } = state;
|
|
1945
|
+
writeState(record);
|
|
1946
|
+
log(`serving ${root} at ${state.url}${agentHook ? `; each comment sent to the agent runs: ${agentHook}` : ""}`);
|
|
1947
|
+
return state;
|
|
1948
|
+
}
|
|
1949
|
+
drop(site) {
|
|
1950
|
+
this.sites.delete(site.page);
|
|
1951
|
+
this.startedAt.delete(site.page);
|
|
1952
|
+
removeState(site.root);
|
|
1953
|
+
site.close();
|
|
1954
|
+
}
|
|
1955
|
+
/** Ends one site; true when one was mounted there. The process ends with its last site. */
|
|
1956
|
+
remove(root) {
|
|
1957
|
+
const site = [...this.sites.values()].find((candidate) => candidate.root === root);
|
|
1958
|
+
if (site) this.drop(site);
|
|
1959
|
+
return site !== void 0;
|
|
1960
|
+
}
|
|
1961
|
+
// ---- the network side ------------------------------------------------------------------------------------------
|
|
1962
|
+
async openLan() {
|
|
1963
|
+
for (const address of lanAddresses()) {
|
|
1964
|
+
if (this.lanServers.has(address)) continue;
|
|
1965
|
+
const server2 = this.createServer();
|
|
1966
|
+
try {
|
|
1967
|
+
await listen(server2, this.port, address);
|
|
1968
|
+
this.lanServers.set(address, server2);
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
server2.close();
|
|
1971
|
+
process.stderr.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] not listening on ${address}:${this.port}: ${errorText(error)}
|
|
1972
|
+
`);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
return [...this.lanServers.keys()];
|
|
1976
|
+
}
|
|
1977
|
+
/** A site stopped sharing: the listeners stay while another is shared. */
|
|
1978
|
+
releaseLan() {
|
|
1979
|
+
if (![...this.sites.values()].some((site) => site.share.shared)) this.closeLan();
|
|
1980
|
+
}
|
|
1981
|
+
closeLan() {
|
|
1982
|
+
for (const server2 of this.lanServers.values()) {
|
|
1983
|
+
server2.close();
|
|
1984
|
+
server2.closeAllConnections?.();
|
|
1985
|
+
}
|
|
1986
|
+
this.lanServers.clear();
|
|
1987
|
+
}
|
|
1988
|
+
/** A Host header this hub answers: its own names, and while something is shared, its LAN addresses and machine name. */
|
|
1989
|
+
hostAllowed(request) {
|
|
1990
|
+
const host = request.headers.host ?? "";
|
|
1991
|
+
if (this.hosts.has(host)) return true;
|
|
1992
|
+
if (!this.lanServers.size) return false;
|
|
1993
|
+
const suffix = `:${this.port}`;
|
|
1994
|
+
if (!host.endsWith(suffix)) return false;
|
|
1995
|
+
const name = host.slice(0, -suffix.length).toLowerCase();
|
|
1996
|
+
const machine = os3.hostname().toLowerCase();
|
|
1997
|
+
return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
1998
|
+
}
|
|
1999
|
+
// ---- requests --------------------------------------------------------------------------------------------------
|
|
2000
|
+
/** The site whose address `pathname` is under, with the path below it. */
|
|
2001
|
+
under(pathname) {
|
|
2002
|
+
const [, first, second] = pathname.split("/");
|
|
2003
|
+
const site = first && second ? this.sites.get(`/${first}/${second}/`) : void 0;
|
|
2004
|
+
if (site) return { site, rest: pathname.slice(site.page.length - 1) };
|
|
2005
|
+
for (const candidate of this.sites.values()) if (candidate.claims?.(pathname)) return { site: candidate, rest: pathname };
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
2008
|
+
/** The site a page at this request's Referer belongs to. */
|
|
2009
|
+
referred(request) {
|
|
2010
|
+
const referer = request.headers.referer;
|
|
2011
|
+
if (typeof referer !== "string") return null;
|
|
2012
|
+
try {
|
|
2013
|
+
const at = new URL(referer);
|
|
2014
|
+
return at.host === request.headers.host ? this.under(at.pathname)?.site ?? null : null;
|
|
2015
|
+
} catch {
|
|
2016
|
+
return null;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
/** The sites this request's person may see. */
|
|
2020
|
+
seenBy(request) {
|
|
2021
|
+
return [...this.sites.values()].filter((site) => site.share.viewerOf(request));
|
|
2022
|
+
}
|
|
2023
|
+
async onRequest(request, response) {
|
|
2024
|
+
if (!this.hostAllowed(request)) {
|
|
2025
|
+
request.resume();
|
|
2026
|
+
response.writeHead(421, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Misdirected request");
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const url = new URL(request.url ?? "/", "http://local");
|
|
2030
|
+
const pathname = url.pathname;
|
|
2031
|
+
if (pathname === HUB_ROUTE) return this.control(request, response);
|
|
2032
|
+
if (pathname === "/favicon.ico") {
|
|
2033
|
+
response.writeHead(204).end();
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
if (pathname === "/" || pathname === "/index.html") return this.landing(request, response, url.search);
|
|
2037
|
+
const at = this.under(pathname);
|
|
2038
|
+
if (at) {
|
|
2039
|
+
if (at.rest === "") {
|
|
2040
|
+
request.resume();
|
|
2041
|
+
response.writeHead(308, { Location: `${pathname}/${url.search}`, "Cache-Control": "no-store" }).end();
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
request.url = `${at.rest}${url.search}`;
|
|
2045
|
+
return at.site.handle(request, response);
|
|
2046
|
+
}
|
|
2047
|
+
const site = await this.answering(request, pathname);
|
|
2048
|
+
if (site) return site.handle(request, response);
|
|
2049
|
+
request.resume();
|
|
2050
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Not found");
|
|
2051
|
+
}
|
|
2052
|
+
/** The site that answers a path outside every site's address. */
|
|
2053
|
+
async answering(request, pathname) {
|
|
2054
|
+
const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(pathname);
|
|
2055
|
+
if (link) return [...this.sites.values()].find((site) => site.share.sharing.linkRole(link[1])) ?? null;
|
|
2056
|
+
const blob = /^\/_blob\/([0-9a-f]{32})$/.exec(pathname);
|
|
2057
|
+
if (blob) {
|
|
2058
|
+
const referred = this.referred(request);
|
|
2059
|
+
const seen = this.seenBy(request);
|
|
2060
|
+
for (const site of referred && seen.includes(referred) ? [referred, ...seen] : seen) if (await site.hasBlob?.(blob[1])) return site;
|
|
2061
|
+
return null;
|
|
2062
|
+
}
|
|
2063
|
+
for (const site of this.sites.values()) if (site.common?.(pathname)) return site;
|
|
2064
|
+
return this.referred(request);
|
|
2065
|
+
}
|
|
2066
|
+
/** `/`: where this person goes. */
|
|
2067
|
+
landing(request, response, search) {
|
|
2068
|
+
const seen = this.seenBy(request);
|
|
2069
|
+
const only = seen[0];
|
|
2070
|
+
if (seen.length === 1 && only) {
|
|
2071
|
+
request.resume();
|
|
2072
|
+
response.writeHead(302, { Location: `${only.page}${search}`, "Cache-Control": "no-store" }).end();
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const any = only ?? [...this.sites.values()][0];
|
|
2076
|
+
if (!any) {
|
|
2077
|
+
request.resume();
|
|
2078
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Nothing is open here.");
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
if (!only) return any.share.denied(request, response);
|
|
2082
|
+
any.share.sendList(response, seen.map((site) => ({ page: site.page, title: site.share.title() })));
|
|
2083
|
+
}
|
|
2084
|
+
/** The CLIs' route: over loopback, from a process (a browser's POST carries an Origin). */
|
|
2085
|
+
async control(request, response) {
|
|
2086
|
+
const info = { hub: 1, kind: this.kind, build: this.build, host: this.host, pid: process.pid };
|
|
2087
|
+
if (!isLoopback(request.socket.remoteAddress) || request.headers.origin !== void 0) {
|
|
2088
|
+
request.resume();
|
|
2089
|
+
return sendJson2(response, 403, { code: "not_granted", message: "Only a local process can do this." });
|
|
2090
|
+
}
|
|
2091
|
+
if (request.method === "GET") return sendJson2(response, 200, info);
|
|
2092
|
+
if (request.method !== "POST") {
|
|
2093
|
+
request.resume();
|
|
2094
|
+
return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST." });
|
|
2095
|
+
}
|
|
2096
|
+
let body;
|
|
2097
|
+
try {
|
|
2098
|
+
body = JSON.parse((await readBody(request, 64 << 10)).toString("utf8"));
|
|
2099
|
+
} catch {
|
|
2100
|
+
return sendJson2(response, 400, { code: "invalid_argument", message: "The call is not JSON." });
|
|
2101
|
+
}
|
|
2102
|
+
if (typeof body.root !== "string" || !path6.isAbsolute(body.root)) return sendJson2(response, 400, { code: "invalid_argument", message: "root: an absolute path." });
|
|
2103
|
+
if (body.action === "mount") {
|
|
2104
|
+
try {
|
|
2105
|
+
const state = await this.add(body.root, { ...typeof body.agentHook === "string" && body.agentHook ? { agentHook: body.agentHook } : {}, lan: body.lan === true });
|
|
2106
|
+
return sendJson2(response, 200, { ...info, state });
|
|
2107
|
+
} catch (error) {
|
|
2108
|
+
sendJson2(response, error instanceof SiteClash ? 409 : 400, { ...info, code: error.code ?? "SERVER_FAILED", message: errorText(error) });
|
|
2109
|
+
return this.endIfEmpty();
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
if (body.action === "unmount") {
|
|
2113
|
+
const removed = this.remove(body.root);
|
|
2114
|
+
const remaining = this.sites.size;
|
|
2115
|
+
sendJson2(response, 200, { ...info, removed, remaining });
|
|
2116
|
+
return this.endIfEmpty();
|
|
2117
|
+
}
|
|
2118
|
+
return sendJson2(response, 400, { code: "invalid_argument", message: "action: mount or unmount." });
|
|
2119
|
+
}
|
|
2120
|
+
/** The process ends with its last site. */
|
|
2121
|
+
endIfEmpty() {
|
|
2122
|
+
if (this.sites.size > 0 || this.adding.size > 0) return;
|
|
2123
|
+
this.close();
|
|
2124
|
+
setImmediate(() => process.exit(0));
|
|
2125
|
+
}
|
|
2126
|
+
onUpgrade(request, socket, head) {
|
|
2127
|
+
socket.on("error", () => {
|
|
2128
|
+
});
|
|
2129
|
+
const url = new URL(request.url ?? "/", "http://local");
|
|
2130
|
+
const at = this.hostAllowed(request) && this.originOk(request) ? this.under(url.pathname) : null;
|
|
2131
|
+
if (!at) {
|
|
2132
|
+
socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
request.url = `${at.rest}${url.search}`;
|
|
2136
|
+
const asked = String(request.headers["sec-websocket-protocol"] ?? "").split(",").map((p) => p.trim());
|
|
2137
|
+
if (asked.includes(STREAM_PROTOCOL)) return this.stream(at.site, request, socket, head, url);
|
|
2138
|
+
if (at.site.upgrade) return at.site.upgrade(request, socket, head);
|
|
2139
|
+
socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
2140
|
+
}
|
|
2141
|
+
originOk(request) {
|
|
2142
|
+
const origin = request.headers.origin;
|
|
2143
|
+
if (origin === void 0) return true;
|
|
2144
|
+
try {
|
|
2145
|
+
return new URL(origin).host === request.headers.host;
|
|
2146
|
+
} catch {
|
|
2147
|
+
return false;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
/** An event stream over a WebSocket: the site's handler answers it as a GET. */
|
|
2151
|
+
stream(site, request, socket, head, url) {
|
|
2152
|
+
const ws = acceptWebSocket(request, socket, head, STREAM_PROTOCOL);
|
|
2153
|
+
if (!ws) return;
|
|
2154
|
+
const last = url.searchParams.get("__lastEventId");
|
|
2155
|
+
if (last !== null) {
|
|
2156
|
+
request.headers["last-event-id"] = last;
|
|
2157
|
+
url.searchParams.delete("__lastEventId");
|
|
2158
|
+
request.url = `${request.url?.split("?")[0]}${url.search}`;
|
|
2159
|
+
}
|
|
2160
|
+
request.method = "GET";
|
|
2161
|
+
const response = new StreamResponse(ws);
|
|
2162
|
+
ws.handlers = {
|
|
2163
|
+
close: () => {
|
|
2164
|
+
response.closed();
|
|
2165
|
+
request.emit("close");
|
|
2166
|
+
}
|
|
2167
|
+
};
|
|
2168
|
+
Promise.resolve(site.handle(request, response)).catch(() => ws.close(STREAM_REFUSED + 500));
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
async function hubAt(origin) {
|
|
2172
|
+
try {
|
|
2173
|
+
const response = await fetch(`${origin}${HUB_ROUTE}`, { signal: AbortSignal.timeout(1e3) });
|
|
2174
|
+
if (!response.ok) return null;
|
|
2175
|
+
const info = await response.json();
|
|
2176
|
+
return info.hub === 1 ? info : null;
|
|
2177
|
+
} catch {
|
|
2178
|
+
return null;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
async function joinHub(call, below = Infinity) {
|
|
2182
|
+
if (call.port === 0) return null;
|
|
2183
|
+
const ports = call.portGiven ? [call.port] : Array.from({ length: PORT_TRIES }, (_, i) => call.port + i).filter((port) => port <= 65535 && port < below);
|
|
2184
|
+
const build = buildOf(call.script);
|
|
2185
|
+
const origins = ports.map((port) => serverOrigin(call.host, port));
|
|
2186
|
+
const infos = await Promise.all(origins.map(hubAt));
|
|
2187
|
+
for (const [i, info] of infos.entries()) {
|
|
2188
|
+
if (!info || info.kind !== call.kind || info.build !== build || info.host !== call.host) continue;
|
|
2189
|
+
const response = await fetch(`${origins[i]}${HUB_ROUTE}`, {
|
|
2190
|
+
method: "POST",
|
|
2191
|
+
headers: { "Content-Type": "application/json" },
|
|
2192
|
+
body: JSON.stringify({ action: "mount", root: call.root, ...call.agentHook ? { agentHook: call.agentHook } : {}, lan: call.lan === true }),
|
|
2193
|
+
signal: AbortSignal.timeout(2e4)
|
|
2194
|
+
}).catch(() => null);
|
|
2195
|
+
if (!response) continue;
|
|
2196
|
+
const body = await response.json().catch(() => ({}));
|
|
2197
|
+
if (response.ok && body.state) return body.state;
|
|
2198
|
+
if (response.status === 409) continue;
|
|
2199
|
+
throw Object.assign(new Error(body.message ?? `the server answered ${response.status}`), { code: body.code ?? "SERVER_FAILED" });
|
|
2200
|
+
}
|
|
2201
|
+
return null;
|
|
2202
|
+
}
|
|
2203
|
+
async function leaveHub(state) {
|
|
2204
|
+
let remaining = 0;
|
|
2205
|
+
const response = await fetch(`${new URL(state.url).origin}${HUB_ROUTE}`, {
|
|
2206
|
+
method: "POST",
|
|
2207
|
+
headers: { "Content-Type": "application/json" },
|
|
2208
|
+
body: JSON.stringify({ action: "unmount", root: state.root }),
|
|
2209
|
+
signal: AbortSignal.timeout(5e3)
|
|
2210
|
+
}).catch(() => null);
|
|
2211
|
+
const body = response ? await response.json().catch(() => null) : null;
|
|
2212
|
+
if (body?.hub === 1) remaining = Number(body.remaining ?? 0);
|
|
2213
|
+
else if (alive(state.pid)) process.kill(state.pid, "SIGTERM");
|
|
2214
|
+
const deadline = Date.now() + 5e3;
|
|
2215
|
+
while (remaining === 0 && alive(state.pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2216
|
+
if (remaining === 0 && alive(state.pid)) return false;
|
|
2217
|
+
fs6.rmSync(path6.join(state.root, STATE_FILE), { force: true });
|
|
2218
|
+
return true;
|
|
2219
|
+
}
|
|
2220
|
+
function holdSite(state) {
|
|
2221
|
+
const leave = () => {
|
|
2222
|
+
void leaveHub(state).finally(() => process.exit(0));
|
|
2223
|
+
};
|
|
2224
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, leave);
|
|
2225
|
+
setInterval(() => {
|
|
2226
|
+
if (!alive(state.pid) || readState(state.root)?.pid !== state.pid) process.exit(0);
|
|
2227
|
+
}, 2e3);
|
|
2228
|
+
}
|
|
2229
|
+
async function serveHub(call) {
|
|
2230
|
+
const joined = await joinHub(call);
|
|
2231
|
+
const stayed = (state) => {
|
|
2232
|
+
if (!call.background) holdSite(state);
|
|
2233
|
+
return { ...state, joined: true };
|
|
2234
|
+
};
|
|
2235
|
+
if (joined) return stayed(joined);
|
|
2236
|
+
const hub = new Hub({ kind: call.kind, build: buildOf(call.script), host: call.host, site: call.site, echo: !call.background });
|
|
2237
|
+
const { state: sharing } = new Sharing(call.root);
|
|
2238
|
+
const linksPort = !call.portGiven && (call.lan || sharing.lan) ? sharing.port : void 0;
|
|
2239
|
+
const port = await hub.listen(call.port, linksPort);
|
|
2240
|
+
const lower = call.portGiven ? null : await joinHub(call, port);
|
|
2241
|
+
if (lower) {
|
|
2242
|
+
hub.close();
|
|
2243
|
+
return stayed(lower);
|
|
2244
|
+
}
|
|
2245
|
+
hub.ownProcess();
|
|
2246
|
+
try {
|
|
2247
|
+
return { ...await hub.add(call.root, { ...call.agentHook ? { agentHook: call.agentHook } : {}, lan: call.lan === true }), joined: false };
|
|
2248
|
+
} catch (error) {
|
|
2249
|
+
hub.close();
|
|
2250
|
+
throw error;
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
import fs7 from "node:fs";
|
|
2255
|
+
import path7 from "node:path";
|
|
1501
2256
|
var FOLDER2 = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
1502
2257
|
var MAX_FILES = 2e3;
|
|
1503
2258
|
var MAX_FILE_BYTES2 = 64 * 1024 * 1024;
|
|
@@ -1505,7 +2260,7 @@ var DESIGN_SYSTEM_FILE = "design-system.json";
|
|
|
1505
2260
|
function readRecords(project, indexFile) {
|
|
1506
2261
|
let index;
|
|
1507
2262
|
try {
|
|
1508
|
-
index = JSON.parse(
|
|
2263
|
+
index = JSON.parse(fs7.readFileSync(path7.join(project, indexFile), "utf8"));
|
|
1509
2264
|
} catch {
|
|
1510
2265
|
return [];
|
|
1511
2266
|
}
|
|
@@ -1524,11 +2279,11 @@ function readRecords(project, indexFile) {
|
|
|
1524
2279
|
return out;
|
|
1525
2280
|
}
|
|
1526
2281
|
function plain(p, want) {
|
|
1527
|
-
const stat =
|
|
2282
|
+
const stat = fs7.lstatSync(p, { throwIfNoEntry: false });
|
|
1528
2283
|
return Boolean(stat && !stat.isSymbolicLink() && (want === "file" ? stat.isFile() : stat.isDirectory()));
|
|
1529
2284
|
}
|
|
1530
2285
|
function walk(dir, prefix = "", out = []) {
|
|
1531
|
-
for (const entry of
|
|
2286
|
+
for (const entry of fs7.readdirSync(path7.join(dir, prefix), { withFileTypes: true })) {
|
|
1532
2287
|
if (out.length >= MAX_FILES) break;
|
|
1533
2288
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1534
2289
|
if (entry.isDirectory()) walk(dir, rel, out);
|
|
@@ -1536,22 +2291,22 @@ function walk(dir, prefix = "", out = []) {
|
|
|
1536
2291
|
}
|
|
1537
2292
|
return out;
|
|
1538
2293
|
}
|
|
1539
|
-
var projectOf = (candidate) =>
|
|
2294
|
+
var projectOf = (candidate) => path7.basename(candidate) === "project" ? candidate : path7.join(candidate, "project");
|
|
1540
2295
|
function sourceProject(artifact, root, from) {
|
|
1541
2296
|
const given = from ?? artifact;
|
|
1542
2297
|
if (!given) return { reason: "the record names no source folder", kind: "none" };
|
|
1543
2298
|
if (!from && /^[a-z][a-z0-9+.-]*:\/\//i.test(given)) return { reason: `its source is ${given}, which is not a folder on this machine`, kind: "remote" };
|
|
1544
|
-
const candidates =
|
|
2299
|
+
const candidates = path7.isAbsolute(given) ? [given] : [path7.resolve(given), path7.resolve(root, given), path7.resolve(root, "..", given)];
|
|
1545
2300
|
for (const candidate of candidates) {
|
|
1546
2301
|
const project = projectOf(candidate);
|
|
1547
2302
|
if (plain(project, "dir")) return { project };
|
|
1548
2303
|
}
|
|
1549
|
-
if (!from && !
|
|
1550
|
-
let dir =
|
|
1551
|
-
for (let up =
|
|
2304
|
+
if (!from && !path7.isAbsolute(given)) {
|
|
2305
|
+
let dir = path7.resolve(root, "..");
|
|
2306
|
+
for (let up = path7.dirname(dir); up !== dir; up = path7.dirname(dir)) {
|
|
1552
2307
|
dir = up;
|
|
1553
|
-
const project = projectOf(
|
|
1554
|
-
if (plain(
|
|
2308
|
+
const project = projectOf(path7.resolve(dir, given));
|
|
2309
|
+
if (plain(path7.join(project, DESIGN_SYSTEM_FILE), "file")) return { project };
|
|
1555
2310
|
}
|
|
1556
2311
|
}
|
|
1557
2312
|
return { reason: `its source folder ${given} was not found${from ? "" : ` (tried from ${process.cwd()} and the folders above the artifact); pass --from <folder>`}`, kind: "not-found" };
|
|
@@ -1560,21 +2315,21 @@ function compareSystem(project, root, record, from) {
|
|
|
1560
2315
|
const diff = { namespace: record.namespace, title: record.title, changed: [], removed: [] };
|
|
1561
2316
|
const found = sourceProject(record.artifact, root, from);
|
|
1562
2317
|
if (!found.project) return { ...diff, skipped: found.reason, skippedKind: found.kind };
|
|
1563
|
-
diff.source =
|
|
1564
|
-
const copy =
|
|
2318
|
+
diff.source = path7.dirname(found.project);
|
|
2319
|
+
const copy = path7.join(project, "ds", record.namespace);
|
|
1565
2320
|
if (!plain(copy, "dir")) return { ...diff, missing: true };
|
|
1566
2321
|
for (const rel of walk(copy)) {
|
|
1567
|
-
const mine =
|
|
1568
|
-
const theirs =
|
|
2322
|
+
const mine = path7.join(copy, rel);
|
|
2323
|
+
const theirs = path7.join(found.project, rel);
|
|
1569
2324
|
if (!plain(mine, "file")) continue;
|
|
1570
2325
|
if (!plain(theirs, "file")) {
|
|
1571
2326
|
diff.removed.push(rel);
|
|
1572
2327
|
continue;
|
|
1573
2328
|
}
|
|
1574
|
-
const a =
|
|
1575
|
-
const b =
|
|
2329
|
+
const a = fs7.statSync(mine);
|
|
2330
|
+
const b = fs7.statSync(theirs);
|
|
1576
2331
|
if (a.size > MAX_FILE_BYTES2 || b.size > MAX_FILE_BYTES2) continue;
|
|
1577
|
-
if (a.size !== b.size || !
|
|
2332
|
+
if (a.size !== b.size || !fs7.readFileSync(mine).equals(fs7.readFileSync(theirs))) diff.changed.push(rel);
|
|
1578
2333
|
}
|
|
1579
2334
|
return diff;
|
|
1580
2335
|
}
|
|
@@ -1600,8 +2355,8 @@ function compareSystems(project, root, indexFile, namespace, from) {
|
|
|
1600
2355
|
return pick(readRecords(project, indexFile), namespace).map((r) => compareSystem(project, root, r, from));
|
|
1601
2356
|
}
|
|
1602
2357
|
function stamp(project, indexFile, namespaces, now) {
|
|
1603
|
-
const file =
|
|
1604
|
-
const text =
|
|
2358
|
+
const file = path7.join(project, indexFile);
|
|
2359
|
+
const text = fs7.readFileSync(file, "utf8");
|
|
1605
2360
|
const index = JSON.parse(text);
|
|
1606
2361
|
const list = index.designSystems;
|
|
1607
2362
|
const records = Array.isArray(list) ? list : list && typeof list === "object" ? Object.values(list) : [];
|
|
@@ -1612,9 +2367,9 @@ function stamp(project, indexFile, namespaces, now) {
|
|
|
1612
2367
|
const indent = /^\{\n/.test(text) ? /\n( +|\t)"/.exec(text)?.[1] ?? 2 : void 0;
|
|
1613
2368
|
const out = JSON.stringify(index, null, indent);
|
|
1614
2369
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1615
|
-
|
|
2370
|
+
fs7.writeFileSync(tmp, text.endsWith("\n") ? `${out}
|
|
1616
2371
|
` : out);
|
|
1617
|
-
|
|
2372
|
+
fs7.renameSync(tmp, file);
|
|
1618
2373
|
}
|
|
1619
2374
|
function syncSystems(project, root, indexFile, opts) {
|
|
1620
2375
|
const diffs = compareSystems(project, root, indexFile, opts.namespace, opts.from);
|
|
@@ -1625,10 +2380,10 @@ function syncSystems(project, root, indexFile, opts) {
|
|
|
1625
2380
|
const result = { ...diff, copied: [] };
|
|
1626
2381
|
if (!opts.check && diff.source && !diff.missing) {
|
|
1627
2382
|
for (const rel of diff.changed) {
|
|
1628
|
-
const target =
|
|
2383
|
+
const target = path7.join(project, "ds", diff.namespace, rel);
|
|
1629
2384
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
1630
|
-
|
|
1631
|
-
|
|
2385
|
+
fs7.copyFileSync(path7.join(diff.source, "project", rel), tmp);
|
|
2386
|
+
fs7.renameSync(tmp, target);
|
|
1632
2387
|
result.copied.push(rel);
|
|
1633
2388
|
}
|
|
1634
2389
|
if (diff.changed.length > 0) synced.add(diff.namespace);
|
|
@@ -1642,9 +2397,9 @@ function syncSystems(project, root, indexFile, opts) {
|
|
|
1642
2397
|
return results;
|
|
1643
2398
|
}
|
|
1644
2399
|
|
|
1645
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1646
|
-
import
|
|
1647
|
-
import
|
|
2400
|
+
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
2401
|
+
import fs8 from "node:fs";
|
|
2402
|
+
import path8 from "node:path";
|
|
1648
2403
|
var HISTORY_DIR = "history";
|
|
1649
2404
|
var INDEX = "index.json";
|
|
1650
2405
|
var OBJECTS = "objects";
|
|
@@ -1655,34 +2410,34 @@ var MAX_NOTICES = 50;
|
|
|
1655
2410
|
var NOTICE_DAYS = 7;
|
|
1656
2411
|
var AGENT = { by: "agent", user: { id: "agent", name: "Agent" } };
|
|
1657
2412
|
var SYSTEM = { by: "system", user: { id: "system", name: "" } };
|
|
1658
|
-
var sha256 = (bytes) =>
|
|
2413
|
+
var sha256 = (bytes) => createHash3("sha256").update(bytes).digest("hex");
|
|
1659
2414
|
var SHA = /^[0-9a-f]{64}$/;
|
|
1660
|
-
var
|
|
2415
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1661
2416
|
function writeAtomic2(file, data) {
|
|
1662
|
-
|
|
1663
|
-
const tmp =
|
|
2417
|
+
fs8.mkdirSync(path8.dirname(file), { recursive: true });
|
|
2418
|
+
const tmp = path8.join(path8.dirname(file), `.${path8.basename(file)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
1664
2419
|
try {
|
|
1665
|
-
|
|
1666
|
-
|
|
2420
|
+
fs8.writeFileSync(tmp, data);
|
|
2421
|
+
fs8.renameSync(tmp, file);
|
|
1667
2422
|
} catch (error) {
|
|
1668
|
-
|
|
2423
|
+
fs8.rmSync(tmp, { force: true });
|
|
1669
2424
|
throw error;
|
|
1670
2425
|
}
|
|
1671
2426
|
}
|
|
1672
2427
|
var emptyIndex = () => ({ format: 1, next: 1, base: {}, versions: [], notices: [] });
|
|
1673
2428
|
function readUser(value) {
|
|
1674
|
-
return
|
|
2429
|
+
return isRecord3(value) && typeof value.id === "string" && typeof value.name === "string" ? { id: value.id, name: value.name } : null;
|
|
1675
2430
|
}
|
|
1676
2431
|
var isAuthor = (value) => value === "human" || value === "agent" || value === "system";
|
|
1677
2432
|
function parseIndex(raw) {
|
|
1678
2433
|
const index = emptyIndex();
|
|
1679
|
-
if (!
|
|
1680
|
-
if (
|
|
2434
|
+
if (!isRecord3(raw)) return index;
|
|
2435
|
+
if (isRecord3(raw.base)) {
|
|
1681
2436
|
for (const [p, s] of Object.entries(raw.base)) if (typeof s === "string" && SHA.test(s)) index.base[p] = s;
|
|
1682
2437
|
}
|
|
1683
2438
|
if (Array.isArray(raw.versions)) {
|
|
1684
2439
|
for (const v of raw.versions) {
|
|
1685
|
-
if (!
|
|
2440
|
+
if (!isRecord3(v) || typeof v.n !== "number" || !isAuthor(v.by) || typeof v.at !== "string" || !isRecord3(v.changes)) continue;
|
|
1686
2441
|
const user = readUser(v.user);
|
|
1687
2442
|
if (!user) continue;
|
|
1688
2443
|
const changes = {};
|
|
@@ -1694,8 +2449,8 @@ function parseIndex(raw) {
|
|
|
1694
2449
|
if (Array.isArray(v.overwrote)) {
|
|
1695
2450
|
const list = [];
|
|
1696
2451
|
for (const o of v.overwrote) {
|
|
1697
|
-
const who =
|
|
1698
|
-
if (!
|
|
2452
|
+
const who = isRecord3(o) ? readUser(o.user) : null;
|
|
2453
|
+
if (!isRecord3(o) || !who || typeof o.path !== "string" || o.lost !== null && typeof o.lost !== "string" || typeof o.n !== "number" || !isAuthor(o.by)) continue;
|
|
1699
2454
|
list.push({
|
|
1700
2455
|
path: o.path,
|
|
1701
2456
|
lost: o.lost,
|
|
@@ -1717,10 +2472,10 @@ function parseIndex(raw) {
|
|
|
1717
2472
|
index.next = Math.max(typeof raw.next === "number" ? raw.next : 1, (last?.n ?? 0) + 1);
|
|
1718
2473
|
if (Array.isArray(raw.notices)) {
|
|
1719
2474
|
for (const n of raw.notices) {
|
|
1720
|
-
if (!
|
|
2475
|
+
if (!isRecord3(n) || typeof n.id !== "string" || typeof n.to !== "string" || typeof n.path !== "string" || typeof n.n !== "number") continue;
|
|
1721
2476
|
if (n.kind !== "overwritten" && n.kind !== "overwrote") continue;
|
|
1722
2477
|
const by = readUser(n.by);
|
|
1723
|
-
const lost =
|
|
2478
|
+
const lost = isRecord3(n.lost) ? n.lost : null;
|
|
1724
2479
|
const lostUser = lost ? readUser(lost.user) : null;
|
|
1725
2480
|
if (!by || !lost || !lostUser || lost.sha !== null && typeof lost.sha !== "string" || typeof lost.n !== "number" || !isAuthor(lost.by)) continue;
|
|
1726
2481
|
index.notices.push({
|
|
@@ -1746,10 +2501,10 @@ var History = class {
|
|
|
1746
2501
|
/** path → sha: what the files hold after the newest version. */
|
|
1747
2502
|
headState;
|
|
1748
2503
|
constructor(root) {
|
|
1749
|
-
this.dir =
|
|
2504
|
+
this.dir = path8.join(root, HISTORY_DIR);
|
|
1750
2505
|
let raw = null;
|
|
1751
2506
|
try {
|
|
1752
|
-
raw = JSON.parse(
|
|
2507
|
+
raw = JSON.parse(fs8.readFileSync(path8.join(this.dir, INDEX), "utf8"));
|
|
1753
2508
|
} catch {
|
|
1754
2509
|
}
|
|
1755
2510
|
this.index = parseIndex(raw);
|
|
@@ -1793,7 +2548,7 @@ var History = class {
|
|
|
1793
2548
|
read(sha) {
|
|
1794
2549
|
if (!SHA.test(sha)) return null;
|
|
1795
2550
|
try {
|
|
1796
|
-
return
|
|
2551
|
+
return fs8.readFileSync(path8.join(this.dir, OBJECTS, sha));
|
|
1797
2552
|
} catch {
|
|
1798
2553
|
return null;
|
|
1799
2554
|
}
|
|
@@ -1807,7 +2562,7 @@ var History = class {
|
|
|
1807
2562
|
size(sha) {
|
|
1808
2563
|
if (!SHA.test(sha)) return null;
|
|
1809
2564
|
try {
|
|
1810
|
-
return
|
|
2565
|
+
return fs8.statSync(path8.join(this.dir, OBJECTS, sha)).size;
|
|
1811
2566
|
} catch {
|
|
1812
2567
|
return null;
|
|
1813
2568
|
}
|
|
@@ -1941,8 +2696,8 @@ var History = class {
|
|
|
1941
2696
|
return out;
|
|
1942
2697
|
}
|
|
1943
2698
|
putObject(sha, bytes) {
|
|
1944
|
-
const file =
|
|
1945
|
-
if (
|
|
2699
|
+
const file = path8.join(this.dir, OBJECTS, sha);
|
|
2700
|
+
if (fs8.existsSync(file)) return;
|
|
1946
2701
|
writeAtomic2(file, bytes);
|
|
1947
2702
|
}
|
|
1948
2703
|
/** Past MAX_VERSIONS the oldest fold into the base; old notices go; contents no one holds are removed. */
|
|
@@ -1979,14 +2734,14 @@ var History = class {
|
|
|
1979
2734
|
}
|
|
1980
2735
|
let names;
|
|
1981
2736
|
try {
|
|
1982
|
-
names =
|
|
2737
|
+
names = fs8.readdirSync(path8.join(this.dir, OBJECTS));
|
|
1983
2738
|
} catch {
|
|
1984
2739
|
return;
|
|
1985
2740
|
}
|
|
1986
|
-
for (const name of names) if (SHA.test(name) && !keep.has(name))
|
|
2741
|
+
for (const name of names) if (SHA.test(name) && !keep.has(name)) fs8.rmSync(path8.join(this.dir, OBJECTS, name), { force: true });
|
|
1987
2742
|
}
|
|
1988
2743
|
save() {
|
|
1989
|
-
writeAtomic2(
|
|
2744
|
+
writeAtomic2(path8.join(this.dir, INDEX), `${JSON.stringify(this.index)}
|
|
1990
2745
|
`);
|
|
1991
2746
|
}
|
|
1992
2747
|
};
|
|
@@ -2055,157 +2810,16 @@ function diffContents(before, after) {
|
|
|
2055
2810
|
return { added, removed, lines, ...truncated ? { truncated: true } : {} };
|
|
2056
2811
|
}
|
|
2057
2812
|
|
|
2058
|
-
import { createHash as
|
|
2059
|
-
import
|
|
2060
|
-
import
|
|
2061
|
-
import
|
|
2813
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
2814
|
+
import fs11 from "node:fs";
|
|
2815
|
+
import os5 from "node:os";
|
|
2816
|
+
import path11 from "node:path";
|
|
2062
2817
|
|
|
2063
|
-
import
|
|
2818
|
+
import fs9 from "node:fs";
|
|
2064
2819
|
import fsp2 from "node:fs/promises";
|
|
2065
|
-
import
|
|
2820
|
+
import path9 from "node:path";
|
|
2066
2821
|
import { promisify } from "node:util";
|
|
2067
2822
|
import zlib from "node:zlib";
|
|
2068
|
-
|
|
2069
|
-
import crypto from "node:crypto";
|
|
2070
|
-
import fs7 from "node:fs";
|
|
2071
|
-
import path7 from "node:path";
|
|
2072
|
-
var GUEST_ROLES = ["editor", "commenter", "viewer"];
|
|
2073
|
-
var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
|
|
2074
|
-
var SHARING_FILE = ".sharing.json";
|
|
2075
|
-
var MAX_GUESTS = 200;
|
|
2076
|
-
var MAX_NAME = 40;
|
|
2077
|
-
var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
|
|
2078
|
-
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2079
|
-
function fresh() {
|
|
2080
|
-
return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
|
|
2081
|
-
}
|
|
2082
|
-
function cleanName(raw) {
|
|
2083
|
-
const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f@<>]/g, "").replace(/\s+/g, " ").trim() : "";
|
|
2084
|
-
return [...name].slice(0, MAX_NAME).join("");
|
|
2085
|
-
}
|
|
2086
|
-
function guestUser(guest) {
|
|
2087
|
-
return { id: `guest-${guest.id}`, name: guest.name, email: `${guest.name}@${guest.id}.guest` };
|
|
2088
|
-
}
|
|
2089
|
-
function isLoopback(address) {
|
|
2090
|
-
if (!address) return false;
|
|
2091
|
-
const a = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
2092
|
-
return a === "::1" || a.startsWith("127.");
|
|
2093
|
-
}
|
|
2094
|
-
var Sharing = class _Sharing {
|
|
2095
|
-
file;
|
|
2096
|
-
state;
|
|
2097
|
-
constructor(root) {
|
|
2098
|
-
this.file = path7.join(root, SHARING_FILE);
|
|
2099
|
-
this.state = _Sharing.read(this.file);
|
|
2100
|
-
}
|
|
2101
|
-
static read(file) {
|
|
2102
|
-
let raw;
|
|
2103
|
-
try {
|
|
2104
|
-
raw = JSON.parse(fs7.readFileSync(file, "utf8"));
|
|
2105
|
-
} catch {
|
|
2106
|
-
return fresh();
|
|
2107
|
-
}
|
|
2108
|
-
const state = fresh();
|
|
2109
|
-
if (!isRecord3(raw)) return state;
|
|
2110
|
-
state.lan = raw.lan === true;
|
|
2111
|
-
if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
|
|
2112
|
-
if (isRecord3(raw.links)) {
|
|
2113
|
-
for (const role of GUEST_ROLES) {
|
|
2114
|
-
const link = raw.links[role];
|
|
2115
|
-
if (isRecord3(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
if (Array.isArray(raw.guests)) {
|
|
2119
|
-
for (const g of raw.guests) {
|
|
2120
|
-
if (!isRecord3(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
2121
|
-
state.guests.push({
|
|
2122
|
-
id: g.id,
|
|
2123
|
-
secret: g.secret,
|
|
2124
|
-
name: cleanName(g.name) || "Guest",
|
|
2125
|
-
role: g.role,
|
|
2126
|
-
link: g.link,
|
|
2127
|
-
joined: typeof g.joined === "string" ? g.joined : (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
2128
|
-
...typeof g.seen === "string" ? { seen: g.seen } : {}
|
|
2129
|
-
});
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
return state;
|
|
2133
|
-
}
|
|
2134
|
-
save() {
|
|
2135
|
-
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
2136
|
-
fs7.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
|
|
2137
|
-
`, { mode: 384 });
|
|
2138
|
-
fs7.renameSync(tmp, this.file);
|
|
2139
|
-
}
|
|
2140
|
-
/** The role whose link has this token, while that link is on. */
|
|
2141
|
-
linkRole(tokenGiven) {
|
|
2142
|
-
for (const role of GUEST_ROLES) {
|
|
2143
|
-
const link = this.state.links[role];
|
|
2144
|
-
if (link.on && tokenGiven.length === link.token.length && crypto.timingSafeEqual(Buffer.from(tokenGiven), Buffer.from(link.token))) return role;
|
|
2145
|
-
}
|
|
2146
|
-
return null;
|
|
2147
|
-
}
|
|
2148
|
-
/** The guest a cookie names, if they still have access (their link on and not reset). */
|
|
2149
|
-
guest(secret) {
|
|
2150
|
-
if (!secret) return null;
|
|
2151
|
-
const guest = this.state.guests.find((g) => g.secret === secret);
|
|
2152
|
-
return guest && this.valid(guest) ? guest : null;
|
|
2153
|
-
}
|
|
2154
|
-
valid(guest) {
|
|
2155
|
-
const link = this.state.links[guest.role];
|
|
2156
|
-
return link.on && link.token === guest.link && this.state.guests.includes(guest);
|
|
2157
|
-
}
|
|
2158
|
-
join(role, name) {
|
|
2159
|
-
const guest = { id: token(4), secret: token(24), name: cleanName(name) || "Guest", role, link: this.state.links[role].token, joined: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2160
|
-
this.state.guests.push(guest);
|
|
2161
|
-
if (this.state.guests.length > MAX_GUESTS) this.state.guests.splice(0, this.state.guests.length - MAX_GUESTS);
|
|
2162
|
-
this.save();
|
|
2163
|
-
return guest;
|
|
2164
|
-
}
|
|
2165
|
-
/** Marks a guest seen now (saved at most once a minute per guest). */
|
|
2166
|
-
seen(guest) {
|
|
2167
|
-
const now = /* @__PURE__ */ new Date();
|
|
2168
|
-
if (guest.seen && now.getTime() - Date.parse(guest.seen) < 6e4) return;
|
|
2169
|
-
guest.seen = now.toISOString();
|
|
2170
|
-
this.save();
|
|
2171
|
-
}
|
|
2172
|
-
setLan(on, port) {
|
|
2173
|
-
this.state.lan = on;
|
|
2174
|
-
if (on && port) this.state.port = port;
|
|
2175
|
-
this.save();
|
|
2176
|
-
}
|
|
2177
|
-
setLink(role, on) {
|
|
2178
|
-
this.state.links[role].on = on;
|
|
2179
|
-
this.save();
|
|
2180
|
-
}
|
|
2181
|
-
/** A new token for the link: the old link stops working and the guests who came through it are dropped. */
|
|
2182
|
-
resetLink(role) {
|
|
2183
|
-
const old = this.state.links[role].token;
|
|
2184
|
-
this.state.links[role] = { token: token(), on: this.state.links[role].on };
|
|
2185
|
-
this.state.guests = this.state.guests.filter((g) => g.link !== old);
|
|
2186
|
-
this.save();
|
|
2187
|
-
}
|
|
2188
|
-
/**
|
|
2189
|
-
* Gives a guest another role. They now hang on that role's link (turning it off or resetting it drops them), so a role
|
|
2190
|
-
* whose link is off would shut them out: false then, as for a guest who isn't there.
|
|
2191
|
-
*/
|
|
2192
|
-
setRole(id, role) {
|
|
2193
|
-
const guest = this.state.guests.find((g) => g.id === id);
|
|
2194
|
-
if (!guest || !this.state.links[role].on) return false;
|
|
2195
|
-
guest.role = role;
|
|
2196
|
-
guest.link = this.state.links[role].token;
|
|
2197
|
-
this.save();
|
|
2198
|
-
return true;
|
|
2199
|
-
}
|
|
2200
|
-
removeGuest(id) {
|
|
2201
|
-
const before = this.state.guests.length;
|
|
2202
|
-
this.state.guests = this.state.guests.filter((g) => g.id !== id);
|
|
2203
|
-
if (this.state.guests.length === before) return false;
|
|
2204
|
-
this.save();
|
|
2205
|
-
return true;
|
|
2206
|
-
}
|
|
2207
|
-
};
|
|
2208
|
-
|
|
2209
2823
|
var LOCAL_FILES = /* @__PURE__ */ new Set([STATE_FILE, LOG_FILE, SHARING_FILE]);
|
|
2210
2824
|
var LOCAL_DIRS = /* @__PURE__ */ new Set([EXPORTS_DIR]);
|
|
2211
2825
|
var JUNK_FILES = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
@@ -2217,8 +2831,8 @@ var ArchiveTooLarge = class extends Error {
|
|
|
2217
2831
|
code = "TOO_LARGE";
|
|
2218
2832
|
};
|
|
2219
2833
|
async function archiveEntries(root, skip = []) {
|
|
2220
|
-
const folder =
|
|
2221
|
-
const skipped = new Set(skip.map((file) =>
|
|
2834
|
+
const folder = path9.basename(path9.resolve(root)) || "artifact";
|
|
2835
|
+
const skipped = new Set(skip.map((file) => path9.resolve(file)));
|
|
2222
2836
|
const entries = [];
|
|
2223
2837
|
const walk2 = async (dir, rel) => {
|
|
2224
2838
|
let names;
|
|
@@ -2231,7 +2845,7 @@ async function archiveEntries(root, skip = []) {
|
|
|
2231
2845
|
for (const dirent of names.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
2232
2846
|
const name = dirent.name;
|
|
2233
2847
|
if (JUNK_FILES.has(name) || isTemp(name) || !rel && (LOCAL_FILES.has(name) || LOCAL_DIRS.has(name) && dirent.isDirectory())) continue;
|
|
2234
|
-
const file =
|
|
2848
|
+
const file = path9.join(dir, name);
|
|
2235
2849
|
if (skipped.has(file)) continue;
|
|
2236
2850
|
const inner = rel ? `${rel}/${name}` : name;
|
|
2237
2851
|
if (dirent.isDirectory()) await walk2(file, inner);
|
|
@@ -2241,7 +2855,7 @@ async function archiveEntries(root, skip = []) {
|
|
|
2241
2855
|
}
|
|
2242
2856
|
}
|
|
2243
2857
|
};
|
|
2244
|
-
await walk2(
|
|
2858
|
+
await walk2(path9.resolve(root), "");
|
|
2245
2859
|
if (entries.length > MAX_ENTRIES) throw new ArchiveTooLarge(`The folder holds ${entries.length} files, more than a zip holds (${MAX_ENTRIES}); copy the folder itself instead.`);
|
|
2246
2860
|
const bound = entries.reduce((sum, entry) => sum + entry.size + 2 * (46 + Buffer.byteLength(entry.name)), 22);
|
|
2247
2861
|
if (bound > MAX_BYTES) throw new ArchiveTooLarge(`The folder holds ${(bound / 2 ** 30).toFixed(1)} GiB, more than a zip holds (4 GiB); copy the folder itself instead.`);
|
|
@@ -2290,7 +2904,7 @@ async function writeZip(entries, write) {
|
|
|
2290
2904
|
const crc = crc32(data);
|
|
2291
2905
|
let method = 0;
|
|
2292
2906
|
let body = data;
|
|
2293
|
-
if (data.length > 0 && !STORED.has(
|
|
2907
|
+
if (data.length > 0 && !STORED.has(path9.extname(entry.name).toLowerCase())) {
|
|
2294
2908
|
const deflated = await deflateRaw(data);
|
|
2295
2909
|
if (deflated.length < data.length) {
|
|
2296
2910
|
method = 8;
|
|
@@ -2344,12 +2958,12 @@ async function writeZip(entries, write) {
|
|
|
2344
2958
|
}
|
|
2345
2959
|
function archiveFileName(root, now = /* @__PURE__ */ new Date()) {
|
|
2346
2960
|
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
2347
|
-
return `${
|
|
2961
|
+
return `${path9.basename(path9.resolve(root)) || "artifact"}-${day}.zip`;
|
|
2348
2962
|
}
|
|
2349
2963
|
async function writeArchive(root, out) {
|
|
2350
|
-
const file =
|
|
2964
|
+
const file = path9.resolve(out);
|
|
2351
2965
|
const entries = await archiveEntries(root, [file]);
|
|
2352
|
-
const tmp =
|
|
2966
|
+
const tmp = path9.join(path9.dirname(file), `.${path9.basename(file)}.${process.pid}.tmp`);
|
|
2353
2967
|
const handle = await fsp2.open(tmp, "w");
|
|
2354
2968
|
let bytes;
|
|
2355
2969
|
try {
|
|
@@ -2367,10 +2981,10 @@ async function writeArchive(root, out) {
|
|
|
2367
2981
|
return { file, files: entries.length, bytes };
|
|
2368
2982
|
}
|
|
2369
2983
|
async function archiveCommand(root, out) {
|
|
2370
|
-
const where = out ?
|
|
2371
|
-
const target =
|
|
2984
|
+
const where = out ? path9.resolve(out) : path9.dirname(path9.resolve(root));
|
|
2985
|
+
const target = fs9.statSync(where, { throwIfNoEntry: false })?.isDirectory() ? path9.join(where, archiveFileName(root)) : where;
|
|
2372
2986
|
const { file, files, bytes } = await writeArchive(root, target);
|
|
2373
|
-
return { file, folder:
|
|
2987
|
+
return { file, folder: path9.basename(path9.resolve(root)), files, bytes, leftOut: [...LOCAL_FILES] };
|
|
2374
2988
|
}
|
|
2375
2989
|
function attachment(name) {
|
|
2376
2990
|
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
@@ -2418,31 +3032,31 @@ async function sendArchive(request, response, root) {
|
|
|
2418
3032
|
}
|
|
2419
3033
|
}
|
|
2420
3034
|
|
|
2421
|
-
import
|
|
3035
|
+
import fs10 from "node:fs";
|
|
2422
3036
|
import fsp3 from "node:fs/promises";
|
|
2423
|
-
import
|
|
2424
|
-
import
|
|
3037
|
+
import os4 from "node:os";
|
|
3038
|
+
import path10 from "node:path";
|
|
2425
3039
|
var MAX_BYTES2 = 4294967295;
|
|
2426
3040
|
function shown(file) {
|
|
2427
|
-
const home =
|
|
2428
|
-
return home && (file === home || file.startsWith(home +
|
|
3041
|
+
const home = os4.homedir();
|
|
3042
|
+
return home && (file === home || file.startsWith(home + path10.sep)) ? `~${file.slice(home.length)}` : file;
|
|
2429
3043
|
}
|
|
2430
3044
|
function saved(file, bytes) {
|
|
2431
|
-
return { path: file, name:
|
|
3045
|
+
return { path: file, name: path10.basename(file), folder: path10.dirname(file), shown: shown(file), bytes };
|
|
2432
3046
|
}
|
|
2433
3047
|
function safeFileName(raw) {
|
|
2434
3048
|
let name = (raw.replace(/\\/g, "/").split("/").pop() ?? "").replace(/[\u0000-\u001f\u007f<>:"|?*]/g, "_").replace(/^[.\s]+|[.\s]+$/g, "");
|
|
2435
3049
|
if (name.length > 200) {
|
|
2436
|
-
const ext =
|
|
3050
|
+
const ext = path10.extname(name).slice(0, 16);
|
|
2437
3051
|
name = name.slice(0, 200 - ext.length) + ext;
|
|
2438
3052
|
}
|
|
2439
3053
|
return name || "download";
|
|
2440
3054
|
}
|
|
2441
3055
|
async function openFree(dir, name) {
|
|
2442
|
-
const ext =
|
|
3056
|
+
const ext = path10.extname(name);
|
|
2443
3057
|
const stem = name.slice(0, name.length - ext.length);
|
|
2444
3058
|
for (let n = 0; ; n++) {
|
|
2445
|
-
const file =
|
|
3059
|
+
const file = path10.join(dir, n === 0 ? name : `${stem} (${n})${ext}`);
|
|
2446
3060
|
try {
|
|
2447
3061
|
return { file, handle: await fsp3.open(file, "wx") };
|
|
2448
3062
|
} catch (error) {
|
|
@@ -2461,7 +3075,7 @@ async function saveDownload(request, response, root) {
|
|
|
2461
3075
|
let handle = null;
|
|
2462
3076
|
let bytes = 0;
|
|
2463
3077
|
try {
|
|
2464
|
-
const dir =
|
|
3078
|
+
const dir = path10.join(root, EXPORTS_DIR);
|
|
2465
3079
|
await fsp3.mkdir(dir, { recursive: true });
|
|
2466
3080
|
({ file, handle } = await openFree(dir, name));
|
|
2467
3081
|
for await (const chunk of request) {
|
|
@@ -2486,7 +3100,7 @@ async function saveArchive(request, response, root) {
|
|
|
2486
3100
|
request.resume();
|
|
2487
3101
|
try {
|
|
2488
3102
|
const { file } = await archiveCommand(root, void 0);
|
|
2489
|
-
sendJson2(response, 200, saved(file,
|
|
3103
|
+
sendJson2(response, 200, saved(file, fs10.statSync(file).size));
|
|
2490
3104
|
} catch (error) {
|
|
2491
3105
|
const tooLarge = error instanceof ArchiveTooLarge;
|
|
2492
3106
|
sendJson2(response, tooLarge ? 413 : 500, { code: tooLarge ? "too_large" : "upstream_error", message: error instanceof Error ? error.message : String(error) });
|
|
@@ -2515,108 +3129,73 @@ function commentsViewer(viewer) {
|
|
|
2515
3129
|
var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2516
3130
|
var esc2 = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2517
3131
|
var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
2518
|
-
function ownHosts(host, port) {
|
|
2519
|
-
const named = host === "0.0.0.0" || host === "::" ? [] : [host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`];
|
|
2520
|
-
return [.../* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, ...named])];
|
|
2521
|
-
}
|
|
2522
3132
|
function localOwner() {
|
|
2523
3133
|
let name = "You";
|
|
2524
3134
|
try {
|
|
2525
|
-
name =
|
|
3135
|
+
name = os5.userInfo().username.trim() || name;
|
|
2526
3136
|
} catch {
|
|
2527
3137
|
}
|
|
2528
3138
|
return { id: "local", name, email: `${name}@localhost` };
|
|
2529
3139
|
}
|
|
2530
|
-
var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${
|
|
3140
|
+
var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash4("sha256").update(path11.resolve(root)).digest("hex").slice(0, 12)}`;
|
|
2531
3141
|
var ShareHost = class {
|
|
2532
3142
|
sharing;
|
|
2533
3143
|
options;
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
lanServers = /* @__PURE__ */ new Map();
|
|
3144
|
+
network;
|
|
3145
|
+
page;
|
|
3146
|
+
/** Open to the local network now (the hub's listeners are up, for this artifact too). */
|
|
3147
|
+
on = false;
|
|
2539
3148
|
live = /* @__PURE__ */ new Set();
|
|
2540
3149
|
constructor(options) {
|
|
2541
3150
|
this.options = options;
|
|
3151
|
+
this.network = options.network;
|
|
3152
|
+
this.page = options.page;
|
|
2542
3153
|
this.sharing = new Sharing(options.root);
|
|
2543
3154
|
}
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
async listen(server2, { port, host, lan = false, portGiven = false }) {
|
|
3155
|
+
get port() {
|
|
3156
|
+
return this.network.port;
|
|
3157
|
+
}
|
|
3158
|
+
/** The hub took the artifact: open to the network now with `lan`, or if it was left shared. */
|
|
3159
|
+
async start(lan = false) {
|
|
2550
3160
|
const { state } = this.sharing;
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
else if (!linksPort && port && taken !== port) this.options.log(`port ${port} is in use; took ${taken}`);
|
|
2555
|
-
if (lan && !this.sharing.state.lan) this.sharing.setLan(true, taken);
|
|
2556
|
-
await this.listening(taken, ownHosts(host, taken));
|
|
2557
|
-
return taken;
|
|
3161
|
+
if (!lan && !state.lan) return;
|
|
3162
|
+
if (state.port && state.port !== this.port) this.options.log(`port ${state.port} (the shared links') is not this server's; it is on ${this.port}: give the new links out again`);
|
|
3163
|
+
await this.setLan(true);
|
|
2558
3164
|
}
|
|
2559
3165
|
/** The artifact at each LAN address it is shared on ([] while it is not). */
|
|
2560
3166
|
lanUrls() {
|
|
2561
|
-
return
|
|
2562
|
-
}
|
|
2563
|
-
/** The main listener is up on `port` (answering `hosts`): open to the network now if the artifact was left shared. */
|
|
2564
|
-
async listening(port, hosts) {
|
|
2565
|
-
this.port = port;
|
|
2566
|
-
this.hosts = new Set(hosts);
|
|
2567
|
-
if (this.sharing.state.lan) await this.setLan(true);
|
|
3167
|
+
return this.on ? this.network.addresses().map((a) => `http://${a}:${this.port}${this.page}`) : [];
|
|
2568
3168
|
}
|
|
2569
3169
|
/** Open to the local network now. */
|
|
2570
3170
|
get shared() {
|
|
2571
|
-
return this.
|
|
3171
|
+
return this.on;
|
|
2572
3172
|
}
|
|
2573
|
-
/**
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
if (this.hosts.has(host)) return true;
|
|
2577
|
-
if (!this.lanServers.size) return false;
|
|
2578
|
-
const suffix = `:${this.port}`;
|
|
2579
|
-
if (!host.endsWith(suffix)) return false;
|
|
2580
|
-
const name = host.slice(0, -suffix.length).toLowerCase();
|
|
2581
|
-
const machine = os4.hostname().toLowerCase();
|
|
2582
|
-
return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
3173
|
+
/** The artifact's name, for the join page and the list of what is open at the server's root. */
|
|
3174
|
+
title() {
|
|
3175
|
+
return this.options.title();
|
|
2583
3176
|
}
|
|
2584
3177
|
/**
|
|
2585
|
-
* Opens the artifact to the local network
|
|
2586
|
-
*
|
|
3178
|
+
* Opens the artifact to the local network or closes it, and returns the addresses it is on. Guests already in keep
|
|
3179
|
+
* their place for when it opens again.
|
|
2587
3180
|
*/
|
|
2588
3181
|
async setLan(on) {
|
|
2589
3182
|
if (!on) {
|
|
2590
|
-
this.
|
|
3183
|
+
this.on = false;
|
|
3184
|
+
this.network.release();
|
|
2591
3185
|
for (const live of [...this.live]) if (live.viewer.guest) live.close("unshared");
|
|
2592
3186
|
if (this.sharing.state.lan) this.sharing.setLan(false);
|
|
2593
3187
|
return [];
|
|
2594
3188
|
}
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
const server2 = this.options.createServer();
|
|
2598
|
-
try {
|
|
2599
|
-
await listen(server2, this.port, address);
|
|
2600
|
-
this.lanServers.set(address, server2);
|
|
2601
|
-
} catch (error) {
|
|
2602
|
-
server2.close();
|
|
2603
|
-
this.options.log(`not listening on ${address}:${this.port}: ${error.message}`);
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
3189
|
+
const addresses = await this.network.open();
|
|
3190
|
+
this.on = addresses.length > 0;
|
|
2606
3191
|
if (!this.sharing.state.lan || this.sharing.state.port !== this.port) this.sharing.setLan(true, this.port);
|
|
2607
|
-
this.options.log(`shared on the local network: ${
|
|
2608
|
-
return
|
|
2609
|
-
}
|
|
2610
|
-
closeLan() {
|
|
2611
|
-
for (const server2 of this.lanServers.values()) {
|
|
2612
|
-
server2.close();
|
|
2613
|
-
server2.closeAllConnections?.();
|
|
2614
|
-
}
|
|
2615
|
-
this.lanServers.clear();
|
|
3192
|
+
this.options.log(`shared on the local network: ${addresses.map((a) => `http://${a}:${this.port}${this.page}`).join(", ") || "(no LAN address)"}`);
|
|
3193
|
+
return addresses;
|
|
2616
3194
|
}
|
|
2617
|
-
/**
|
|
3195
|
+
/** The artifact left the hub: it stays shared in .sharing.json, for the next time it opens. */
|
|
2618
3196
|
close() {
|
|
2619
|
-
this.
|
|
3197
|
+
this.on = false;
|
|
3198
|
+
this.network.release();
|
|
2620
3199
|
}
|
|
2621
3200
|
// ---- who is asking ---------------------------------------------------------------------------------------------
|
|
2622
3201
|
cookie(request) {
|
|
@@ -2634,7 +3213,7 @@ var ShareHost = class {
|
|
|
2634
3213
|
*/
|
|
2635
3214
|
viewerOf(request) {
|
|
2636
3215
|
if (isLoopback(request.socket.remoteAddress)) return { role: "owner", user: this.options.owner };
|
|
2637
|
-
const guest = this.
|
|
3216
|
+
const guest = this.on ? this.sharing.guest(this.cookie(request)) : null;
|
|
2638
3217
|
if (!guest) return null;
|
|
2639
3218
|
this.sharing.seen(guest);
|
|
2640
3219
|
return { role: guest.role, user: guestUser(guest), guest };
|
|
@@ -2652,7 +3231,7 @@ var ShareHost = class {
|
|
|
2652
3231
|
}
|
|
2653
3232
|
/** Still allowed in: the owner, or a guest whose link is on and who was not removed. */
|
|
2654
3233
|
stillIn(viewer) {
|
|
2655
|
-
return !viewer.guest || this.
|
|
3234
|
+
return !viewer.guest || this.on && this.sharing.valid(viewer.guest);
|
|
2656
3235
|
}
|
|
2657
3236
|
/** Ends the streams of guests who lost access (a link turned off or reset, a guest removed). */
|
|
2658
3237
|
dropRevoked() {
|
|
@@ -2692,7 +3271,7 @@ var ShareHost = class {
|
|
|
2692
3271
|
}
|
|
2693
3272
|
if (method === "GET" && p === "/__share/invite") return sendJson2(response, 200, this.invites(viewer, request.headers.host ?? "")), true;
|
|
2694
3273
|
if (method === "GET" && p === "/__share/ui.js") {
|
|
2695
|
-
|
|
3274
|
+
fs11.stat(this.options.uiFile, (error, stat) => {
|
|
2696
3275
|
if (error || !stat.isFile()) return sendJson2(response, 404, { code: "not_found", message: "The Share panel is missing." });
|
|
2697
3276
|
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" });
|
|
2698
3277
|
});
|
|
@@ -2711,8 +3290,8 @@ var ShareHost = class {
|
|
|
2711
3290
|
* ("?…") goes to the boot script, for `globals`.
|
|
2712
3291
|
*/
|
|
2713
3292
|
headTags(_viewer, query = "") {
|
|
2714
|
-
let tags = `<script src="
|
|
2715
|
-
if (
|
|
3293
|
+
let tags = `<script src="${this.page}__share/boot.js${query.replace(/[<>"']/g, "").replace(/&/g, "&")}"></script>`;
|
|
3294
|
+
if (fs11.existsSync(this.options.uiFile)) tags += `<script type="module" src="${this.page}__share/ui.js"></script>`;
|
|
2716
3295
|
return tags;
|
|
2717
3296
|
}
|
|
2718
3297
|
/** `html` with the head tags first in its <head>; null when it has none. */
|
|
@@ -2722,10 +3301,10 @@ var ShareHost = class {
|
|
|
2722
3301
|
const end = html.indexOf(">", at) + 1;
|
|
2723
3302
|
return html.slice(0, end) + this.headTags(viewer, query) + html.slice(end);
|
|
2724
3303
|
}
|
|
2725
|
-
/** What the page runs before anything else (see SECURE_CONTEXT_SHIM and GUEST_WATCH). */
|
|
3304
|
+
/** What the page runs before anything else (see ROOT_SHIM, SECURE_CONTEXT_SHIM and GUEST_WATCH). */
|
|
2726
3305
|
bootScript(viewer, query = new URLSearchParams()) {
|
|
2727
3306
|
const share = { ...this.me(viewer), noun: this.options.noun, title: this.options.title(), ...this.options.reactions ? { reactions: true } : {} };
|
|
2728
|
-
let js =
|
|
3307
|
+
let js = `(function(BASE){${ROOT_SHIM}})(${script(this.page)});${SECURE_CONTEXT_SHIM}window.__baoyuShare=${script(share)};`;
|
|
2729
3308
|
for (const [name, value] of Object.entries(this.options.globals?.(viewer, query) ?? {})) js += `window[${script(name)}]=${script(value)};`;
|
|
2730
3309
|
if (viewer.guest) js += GUEST_WATCH;
|
|
2731
3310
|
return js;
|
|
@@ -2738,6 +3317,11 @@ var ShareHost = class {
|
|
|
2738
3317
|
const t = this.words(request);
|
|
2739
3318
|
this.sendPage(response, 403, t.noAccessTitle, `<h1>${esc2(t.noAccessTitle)}</h1><p>${esc2(t.noAccess)}</p>${this.ownerHint(request, t)}`);
|
|
2740
3319
|
}
|
|
3320
|
+
/** `/` on the server for someone who may see more than one artifact: a page of links. */
|
|
3321
|
+
sendList(response, sites) {
|
|
3322
|
+
const noun = this.options.noun.en;
|
|
3323
|
+
this.sendPage(response, 200, `Open ${noun}s`, `<h1>Open ${esc2(noun)}s</h1><ul>${sites.map((site) => `<li><a href="${esc2(site.page)}">${esc2(site.title)}</a></li>`).join("")}</ul>`);
|
|
3324
|
+
}
|
|
2741
3325
|
/** A small page of our own (the join form, the refusal), in the reader's language. */
|
|
2742
3326
|
sendPage(response, status, title, body, headers = {}) {
|
|
2743
3327
|
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc2(title)}</title><style>${PAGE_CSS}</style></head><body><main>${body}</main></body></html>`;
|
|
@@ -2752,8 +3336,8 @@ var ShareHost = class {
|
|
|
2752
3336
|
/** On the computer that shares the artifact, over its LAN address: the way back to the owner's page. */
|
|
2753
3337
|
ownerHint(request, t) {
|
|
2754
3338
|
const from = (request.socket.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
2755
|
-
if (!this.
|
|
2756
|
-
const url = `http://127.0.0.1:${this.port}
|
|
3339
|
+
if (!this.network.addresses().includes(from)) return "";
|
|
3340
|
+
const url = `http://127.0.0.1:${this.port}${this.page}`;
|
|
2757
3341
|
return `<p class="note">${esc2(t.ownerHere)} <a href="${esc2(url)}">${esc2(url)}</a></p>`;
|
|
2758
3342
|
}
|
|
2759
3343
|
/** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
|
|
@@ -2763,16 +3347,16 @@ var ShareHost = class {
|
|
|
2763
3347
|
const local = isLoopback(request.socket.remoteAddress);
|
|
2764
3348
|
const form = method === "POST" ? await readBody(request, 8 << 10).then((body) => body.toString("utf8"), () => "") : "";
|
|
2765
3349
|
const title = this.options.title();
|
|
2766
|
-
if (!role || !local && !this.
|
|
3350
|
+
if (!role || !local && !this.on) return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc2(t.linkGoneTitle)}</h1><p>${esc2(t.linkGone)}</p>`);
|
|
2767
3351
|
if (local) {
|
|
2768
|
-
const lan =
|
|
2769
|
-
const there = lan ? `http://${lan}:${this.port}
|
|
3352
|
+
const lan = this.on ? this.network.addresses()[0] : void 0;
|
|
3353
|
+
const there = lan ? `http://${lan}:${this.port}${this.page}s/${token2}` : "";
|
|
2770
3354
|
const tryIt = there ? `<p>${esc2(t.tryRole(t.roles[role]))} <a href="${esc2(there)}">${esc2(there)}</a></p>` : "";
|
|
2771
|
-
return this.sendPage(response, 200, title, `<h1>${esc2(title)}</h1><p>${esc2(t.youOwn)} <a href="
|
|
3355
|
+
return this.sendPage(response, 200, title, `<h1>${esc2(title)}</h1><p>${esc2(t.youOwn)} <a href="${esc2(this.page)}">${esc2(t.openAsOwner)}</a></p>${tryIt}`);
|
|
2772
3356
|
}
|
|
2773
3357
|
const current = this.sharing.guest(this.cookie(request));
|
|
2774
3358
|
if (method === "GET" && current && current.link === this.sharing.state.links[role].token) {
|
|
2775
|
-
response.writeHead(303, { Location:
|
|
3359
|
+
response.writeHead(303, { Location: this.page, "Cache-Control": "no-store" }).end();
|
|
2776
3360
|
return;
|
|
2777
3361
|
}
|
|
2778
3362
|
if (method === "GET") {
|
|
@@ -2787,21 +3371,21 @@ var ShareHost = class {
|
|
|
2787
3371
|
const guest = this.sharing.join(role, name);
|
|
2788
3372
|
this.options.log(`share: ${guest.name} joined as ${role} (${request.socket.remoteAddress})`);
|
|
2789
3373
|
const cookie = `${this.options.cookieName}=${guest.secret}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
|
|
2790
|
-
response.writeHead(303, { Location:
|
|
3374
|
+
response.writeHead(303, { Location: this.page, "Set-Cookie": cookie, "Cache-Control": "no-store" }).end();
|
|
2791
3375
|
}
|
|
2792
3376
|
/**
|
|
2793
3377
|
* 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
|
|
2794
|
-
* the address they came by (`host`, a Host header
|
|
3378
|
+
* the address they came by (`host`, a Host header the hub let in). A guest could forward the link they joined
|
|
2795
3379
|
* with anyway; a lower one gives no more than that. Who joins through one shows in the owner's panel like anyone else.
|
|
2796
3380
|
*/
|
|
2797
3381
|
invites(viewer, host) {
|
|
2798
3382
|
const { links } = this.sharing.state;
|
|
2799
3383
|
const from = viewer.role === "owner" ? 0 : GUEST_ROLES.indexOf(viewer.role);
|
|
2800
|
-
return { links: GUEST_ROLES.slice(from).filter((role) => links[role].on).map((role) => ({ role, url: `http://${host}
|
|
3384
|
+
return { links: GUEST_ROLES.slice(from).filter((role) => links[role].on).map((role) => ({ role, url: `http://${host}${this.page}s/${links[role].token}` })) };
|
|
2801
3385
|
}
|
|
2802
3386
|
/** Links, addresses and people, for the share panel and `artifact.mjs share`. */
|
|
2803
3387
|
shareView() {
|
|
2804
|
-
const addresses =
|
|
3388
|
+
const addresses = (this.on ? this.network.addresses() : []).sort((a, b) => Number(isTailnet(a)) - Number(isTailnet(b)));
|
|
2805
3389
|
const local = addresses.filter((a) => !isTailnet(a));
|
|
2806
3390
|
const base = local.length ? `http://${local[0]}:${this.port}` : addresses.length ? `http://${addresses[0]}:${this.port}` : `http://127.0.0.1:${this.port}`;
|
|
2807
3391
|
const tailnet = addresses.find(isTailnet);
|
|
@@ -2812,12 +3396,13 @@ var ShareHost = class {
|
|
|
2812
3396
|
const network = lan.length ? `http://${lan[0]}:${this.port}` : base;
|
|
2813
3397
|
return {
|
|
2814
3398
|
title: this.options.title(),
|
|
2815
|
-
lan: this.
|
|
3399
|
+
lan: this.on,
|
|
2816
3400
|
port: this.port,
|
|
3401
|
+
page: this.page,
|
|
2817
3402
|
addresses,
|
|
2818
|
-
local: `http://127.0.0.1:${this.port}
|
|
2819
|
-
links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `${base}
|
|
2820
|
-
...tailnet ? { tailnet: { address: tailnet, links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `http://${tailnet}:${this.port}
|
|
3403
|
+
local: `http://127.0.0.1:${this.port}${this.page}`,
|
|
3404
|
+
links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `${base}${this.page}s/${links[role].token}`, networkUrl: `${network}${this.page}s/${links[role].token}` }])),
|
|
3405
|
+
...tailnet ? { tailnet: { address: tailnet, links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `http://${tailnet}:${this.port}${this.page}s/${links[role].token}` }])) } } : {},
|
|
2821
3406
|
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) }))
|
|
2822
3407
|
};
|
|
2823
3408
|
}
|
|
@@ -2881,6 +3466,31 @@ var ShareHost = class {
|
|
|
2881
3466
|
return (zh ? ZH : EN)(this.options.noun);
|
|
2882
3467
|
}
|
|
2883
3468
|
};
|
|
3469
|
+
var ROOT_SHIM = `
|
|
3470
|
+
var P=location.protocol==='https:'?'wss:':'ws:';
|
|
3471
|
+
function under(u){try{var a=new URL(String(u),location.href);if(a.host===location.host&&/^(https?|wss?):$/.test(a.protocol)&&a.pathname.indexOf(BASE)!==0)a.pathname=BASE+a.pathname.slice(1);return a.href}catch(e){return u}}
|
|
3472
|
+
var F=window.fetch;
|
|
3473
|
+
window.fetch=function(i,o){if(typeof Request==='function'&&i instanceof Request){var r=under(i.url);return F.call(this,r===i.url?i:new Request(r,i),o)}return F.call(this,under(i&&i.href||i),o)};
|
|
3474
|
+
var X=XMLHttpRequest.prototype.open;
|
|
3475
|
+
XMLHttpRequest.prototype.open=function(m,u){var a=[].slice.call(arguments);a[1]=under(u);return X.apply(this,a)};
|
|
3476
|
+
var W=window.WebSocket;
|
|
3477
|
+
function WS(u,p){return p===undefined?new W(under(u)):new W(under(u),p)}
|
|
3478
|
+
WS.prototype=W.prototype;WS.CONNECTING=0;WS.OPEN=1;WS.CLOSING=2;WS.CLOSED=3;
|
|
3479
|
+
window.WebSocket=WS;
|
|
3480
|
+
if(navigator.sendBeacon){var B=navigator.sendBeacon;navigator.sendBeacon=function(u,d){return B.call(navigator,under(u),d)}}
|
|
3481
|
+
class S extends EventTarget{
|
|
3482
|
+
constructor(u){super();this.url=under(u);this.withCredentials=false;this.readyState=0;this.onopen=this.onmessage=this.onerror=null;this.l='';this.c()}
|
|
3483
|
+
c(){var t=this,a=new URL(t.url);a.protocol=P;if(t.l)a.searchParams.set('__lastEventId',t.l);var w=t.w=new W(a.href,'baoyu-sse'),b='';
|
|
3484
|
+
w.onopen=function(){t.readyState=1;t.f(new Event('open'))};
|
|
3485
|
+
w.onmessage=function(e){b+=e.data;var i;while((i=b.indexOf('\\n\\n'))>=0){t.b(b.slice(0,i));b=b.slice(i+2)}};
|
|
3486
|
+
w.onclose=function(e){if(t.readyState===2)return;if(e.code>=4000){t.readyState=2;t.f(new Event('error'));return}t.readyState=0;t.f(new Event('error'));t.r=setTimeout(function(){t.c()},1000)}}
|
|
3487
|
+
b(s){var n='message',d=[],h=false;s.split('\\n').forEach(function(x){if(!x||x[0]===':')return;var i=x.indexOf(':'),k=i<0?x:x.slice(0,i),v=i<0?'':x.slice(i+1).replace(/^ /,'');if(k==='event')n=v;else if(k==='data'){d.push(v);h=true}else if(k==='id')this.l=v}.bind(this));if(h)this.f(new MessageEvent(n,{data:d.join('\\n'),origin:location.origin,lastEventId:this.l}))}
|
|
3488
|
+
f(e){this.dispatchEvent(e);var h=this['on'+e.type];if(typeof h==='function')h.call(this,e)}
|
|
3489
|
+
close(){this.readyState=2;clearTimeout(this.r);if(this.w)this.w.close()}
|
|
3490
|
+
}
|
|
3491
|
+
S.CONNECTING=0;S.OPEN=1;S.CLOSED=2;
|
|
3492
|
+
window.EventSource=S;
|
|
3493
|
+
`;
|
|
2884
3494
|
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};";
|
|
2885
3495
|
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);";
|
|
2886
3496
|
var PAGE_CSS = `
|
|
@@ -3355,7 +3965,7 @@ var OVERWRITE_WINDOW_MS = 6e4;
|
|
|
3355
3965
|
var filesOf = (listing, paths) => Object.fromEntries(paths.map((p) => [p, listing.files[p]?.sha256 ?? null]));
|
|
3356
3966
|
var authorOf = (who) => ({ id: who.user.id, name: who.user.name, ...who.by === "agent" ? { agent: true } : {} });
|
|
3357
3967
|
var CLIENT_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
3358
|
-
var sha2562 = (bytes) =>
|
|
3968
|
+
var sha2562 = (bytes) => createHash5("sha256").update(bytes).digest("hex");
|
|
3359
3969
|
function contentTypeOf(file) {
|
|
3360
3970
|
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
3361
3971
|
return MIME[ext] ?? "application/octet-stream";
|
|
@@ -3404,7 +4014,7 @@ async function collectFiles(root, limit) {
|
|
|
3404
4014
|
const seen = /* @__PURE__ */ new Set();
|
|
3405
4015
|
let truncated = false;
|
|
3406
4016
|
const visit = async (relDir, descend) => {
|
|
3407
|
-
const dir = relDir === "" ? root :
|
|
4017
|
+
const dir = relDir === "" ? root : path12.join(root, ...relDir.split("/"));
|
|
3408
4018
|
let entries;
|
|
3409
4019
|
try {
|
|
3410
4020
|
entries = await fsp4.readdir(dir, { withFileTypes: true });
|
|
@@ -3435,8 +4045,8 @@ async function collectFiles(root, limit) {
|
|
|
3435
4045
|
return { rels, truncated };
|
|
3436
4046
|
}
|
|
3437
4047
|
async function writeFileAtomic(file, bytes) {
|
|
3438
|
-
await fsp4.mkdir(
|
|
3439
|
-
const tmp =
|
|
4048
|
+
await fsp4.mkdir(path12.dirname(file), { recursive: true });
|
|
4049
|
+
const tmp = path12.join(path12.dirname(file), `.${path12.basename(file)}.${randomUUID()}.tmp`);
|
|
3440
4050
|
try {
|
|
3441
4051
|
await fsp4.writeFile(tmp, bytes);
|
|
3442
4052
|
await fsp4.rename(tmp, file);
|
|
@@ -3492,11 +4102,11 @@ var Store = class {
|
|
|
3492
4102
|
async diskPath(rel) {
|
|
3493
4103
|
const realRoot = await fsp4.realpath(this.root).catch(() => null);
|
|
3494
4104
|
if (realRoot === null) return null;
|
|
3495
|
-
const file =
|
|
3496
|
-
for (let dir =
|
|
4105
|
+
const file = path12.join(realRoot, ...rel.split("/"));
|
|
4106
|
+
for (let dir = path12.dirname(file); ; dir = path12.dirname(dir)) {
|
|
3497
4107
|
const real = await fsp4.realpath(dir).catch(() => null);
|
|
3498
4108
|
if (real === null) {
|
|
3499
|
-
if (
|
|
4109
|
+
if (path12.dirname(dir) === dir) return null;
|
|
3500
4110
|
continue;
|
|
3501
4111
|
}
|
|
3502
4112
|
if (real !== dir) return null;
|
|
@@ -3726,7 +4336,7 @@ var Store = class {
|
|
|
3726
4336
|
}
|
|
3727
4337
|
async findBlobFile(id) {
|
|
3728
4338
|
try {
|
|
3729
|
-
const names = await fsp4.readdir(
|
|
4339
|
+
const names = await fsp4.readdir(path12.join(this.root, LAYOUT.blobDir));
|
|
3730
4340
|
const name = names.find((n) => n.startsWith(`${id}.`) && !n.endsWith(".tmp"));
|
|
3731
4341
|
if (name === void 0) return null;
|
|
3732
4342
|
const file = await this.diskPath(`${LAYOUT.blobDir}/${name}`);
|
|
@@ -3781,13 +4391,13 @@ function watchArtifact(store, onChange, onError) {
|
|
|
3781
4391
|
const directories = /* @__PURE__ */ new Map();
|
|
3782
4392
|
const visit = (rel, descend) => {
|
|
3783
4393
|
if (directories.size >= maxDirectories) return;
|
|
3784
|
-
const dir = rel === "" ? store.root :
|
|
3785
|
-
if (directories.has(dir) || !
|
|
3786
|
-
const info =
|
|
4394
|
+
const dir = rel === "" ? store.root : path12.join(store.root, ...rel.split("/"));
|
|
4395
|
+
if (directories.has(dir) || !fs12.existsSync(dir)) return;
|
|
4396
|
+
const info = fs12.lstatSync(dir);
|
|
3787
4397
|
if (!info.isDirectory()) return;
|
|
3788
4398
|
directories.set(dir, `${info.dev}:${info.ino}`);
|
|
3789
4399
|
if (!descend) return;
|
|
3790
|
-
for (const entry of
|
|
4400
|
+
for (const entry of fs12.readdirSync(dir, { withFileTypes: true })) {
|
|
3791
4401
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
3792
4402
|
const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
3793
4403
|
if (LAYOUT.descendInto(child)) visit(child, true);
|
|
@@ -3804,19 +4414,19 @@ function watchArtifact(store, onChange, onError) {
|
|
|
3804
4414
|
if (watchers.has(dir)) continue;
|
|
3805
4415
|
let watcher;
|
|
3806
4416
|
try {
|
|
3807
|
-
watcher =
|
|
4417
|
+
watcher = fs12.watch(dir, (event, name) => {
|
|
3808
4418
|
if (stopped) return;
|
|
3809
4419
|
if (name) {
|
|
3810
|
-
const changed =
|
|
4420
|
+
const changed = path12.join(dir, String(name));
|
|
3811
4421
|
if (event === "rename" && watchers.has(changed)) {
|
|
3812
4422
|
for (const [watchedDir, item] of watchers) {
|
|
3813
|
-
if (watchedDir === changed || watchedDir.startsWith(changed +
|
|
4423
|
+
if (watchedDir === changed || watchedDir.startsWith(changed + path12.sep)) {
|
|
3814
4424
|
item.watcher.close();
|
|
3815
4425
|
watchers.delete(watchedDir);
|
|
3816
4426
|
}
|
|
3817
4427
|
}
|
|
3818
4428
|
}
|
|
3819
|
-
const rel =
|
|
4429
|
+
const rel = path12.relative(store.root, changed).split(path12.sep).join("/");
|
|
3820
4430
|
if (isIgnoredPath(rel) || !LAYOUT.watches(rel)) return;
|
|
3821
4431
|
}
|
|
3822
4432
|
schedule();
|
|
@@ -3852,7 +4462,7 @@ function watchArtifact(store, onChange, onError) {
|
|
|
3852
4462
|
const { rels } = await collectFiles(store.root, LAYOUT.maxFiles);
|
|
3853
4463
|
const parts = [];
|
|
3854
4464
|
for (const rel of rels) {
|
|
3855
|
-
const info = await fsp4.lstat(
|
|
4465
|
+
const info = await fsp4.lstat(path12.join(store.root, ...rel.split("/"))).catch(() => null);
|
|
3856
4466
|
parts.push(info ? `${rel}\0${info.size}\0${info.mtimeMs}\0${info.ino}` : `${rel}\0-`);
|
|
3857
4467
|
}
|
|
3858
4468
|
return parts.join("\n");
|
|
@@ -3881,23 +4491,23 @@ function watchArtifact(store, onChange, onError) {
|
|
|
3881
4491
|
};
|
|
3882
4492
|
}
|
|
3883
4493
|
var publishError = (response, status, code, message) => sendJson2(response, status, { code, message });
|
|
3884
|
-
async function
|
|
3885
|
-
|
|
3886
|
-
const
|
|
3887
|
-
const stamp2 = (line) => log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}`);
|
|
4494
|
+
async function mountSite(root, { log: stamp2, network, agentHook }) {
|
|
4495
|
+
const project = path12.join(root, "project");
|
|
4496
|
+
const editorPath = pagePathOf(KIND, root);
|
|
3888
4497
|
const projectDir = await fsp4.realpath(project);
|
|
3889
4498
|
const history = new History(root);
|
|
3890
4499
|
const store = new Store(projectDir, history, stamp2);
|
|
3891
4500
|
const owner = localOwner();
|
|
3892
4501
|
const share = new ShareHost({
|
|
3893
4502
|
root,
|
|
4503
|
+
page: editorPath,
|
|
4504
|
+
network,
|
|
3894
4505
|
cookieName: guestCookieName(KIND, root),
|
|
3895
4506
|
owner,
|
|
3896
4507
|
noun: NOUNS[KIND] ?? { en: NOUN, zh: NOUN, zhThis: NOUN },
|
|
3897
4508
|
title: () => artifactTitle(root, projectDir),
|
|
3898
4509
|
log: stamp2,
|
|
3899
|
-
|
|
3900
|
-
uiFile: path11.join(EDITOR_DIR, "share-ui.js"),
|
|
4510
|
+
uiFile: path12.join(EDITOR_DIR, "share-ui.js"),
|
|
3901
4511
|
// The History panel's address and what this person may do there; on a version's page (?at=n), which version.
|
|
3902
4512
|
globals: (viewer, query) => {
|
|
3903
4513
|
const at = Number(query.get("at") ?? "");
|
|
@@ -3916,8 +4526,6 @@ async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1
|
|
|
3916
4526
|
return viewer ? commentsViewer(viewer) : null;
|
|
3917
4527
|
}
|
|
3918
4528
|
});
|
|
3919
|
-
const grant = randomBytes2(24).toString("base64url");
|
|
3920
|
-
const editorPath = `/${KIND}/${grant}/`;
|
|
3921
4529
|
const boot = randomBytes2(6).toString("hex");
|
|
3922
4530
|
const clients = /* @__PURE__ */ new Map();
|
|
3923
4531
|
const presence = new PresenceHub();
|
|
@@ -4048,7 +4656,7 @@ data: ${JSON.stringify(event)}
|
|
|
4048
4656
|
const parts = rest === "" ? ["index.html"] : rest.split("/");
|
|
4049
4657
|
const invalid = parts.some((p) => p === "" || p === "." || p === ".." || p.startsWith(".") || p.includes("\\")) || parts[0] === "node";
|
|
4050
4658
|
const realDist = invalid ? null : await fsp4.realpath(EDITOR_DIR).catch(() => null);
|
|
4051
|
-
const file = realDist === null ? null :
|
|
4659
|
+
const file = realDist === null ? null : path12.join(realDist, ...parts);
|
|
4052
4660
|
const real = file === null ? null : await fsp4.realpath(file).catch(() => null);
|
|
4053
4661
|
const info = real === null ? null : await fsp4.lstat(real).catch(() => null);
|
|
4054
4662
|
if (file === null || real !== file || !info?.isFile()) {
|
|
@@ -4059,7 +4667,7 @@ data: ${JSON.stringify(event)}
|
|
|
4059
4667
|
response.writeHead(404, { "Cache-Control": "no-store" }).end();
|
|
4060
4668
|
return;
|
|
4061
4669
|
}
|
|
4062
|
-
const ext =
|
|
4670
|
+
const ext = path12.extname(file).toLowerCase();
|
|
4063
4671
|
const type = STATIC_TYPES[ext] ?? "application/octet-stream";
|
|
4064
4672
|
if (ext === ".html") {
|
|
4065
4673
|
const key = parts.join("/");
|
|
@@ -4075,7 +4683,8 @@ data: ${JSON.stringify(event)}
|
|
|
4075
4683
|
"Content-Length": body.length,
|
|
4076
4684
|
"Cache-Control": "no-cache",
|
|
4077
4685
|
"X-Content-Type-Options": "nosniff",
|
|
4078
|
-
|
|
4686
|
+
// A root-relative <link> or <a href> finds its way to this artifact by the page's address (hub.ts).
|
|
4687
|
+
"Referrer-Policy": "same-origin",
|
|
4079
4688
|
"Content-Security-Policy": page.csp
|
|
4080
4689
|
});
|
|
4081
4690
|
response.end(request.method === "HEAD" ? void 0 : body);
|
|
@@ -4286,7 +4895,7 @@ data: ${JSON.stringify(event)}
|
|
|
4286
4895
|
"Content-Length": body.length,
|
|
4287
4896
|
"Cache-Control": "no-store",
|
|
4288
4897
|
"X-Content-Type-Options": "nosniff",
|
|
4289
|
-
//
|
|
4898
|
+
// A README may link anywhere, and the page (no script, its own origin) asks for nothing by its address.
|
|
4290
4899
|
"Referrer-Policy": "no-referrer",
|
|
4291
4900
|
"Content-Security-Policy": DATA_CSP
|
|
4292
4901
|
});
|
|
@@ -4327,48 +4936,18 @@ data: ${JSON.stringify(event)}
|
|
|
4327
4936
|
}
|
|
4328
4937
|
};
|
|
4329
4938
|
async function handle(request, response) {
|
|
4330
|
-
if (!share.hostAllowed(request)) {
|
|
4331
|
-
request.resume();
|
|
4332
|
-
response.writeHead(421, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Misdirected request");
|
|
4333
|
-
return;
|
|
4334
|
-
}
|
|
4335
|
-
const url2 = new URL(request.url ?? "/", "http://localhost");
|
|
4336
|
-
const pathname = url2.pathname;
|
|
4337
|
-
if (pathname === "/favicon.ico") {
|
|
4338
|
-
response.writeHead(204).end();
|
|
4339
|
-
return;
|
|
4340
|
-
}
|
|
4341
4939
|
if (await share.route(request, response)) return;
|
|
4342
4940
|
const viewer = share.viewerOf(request);
|
|
4343
4941
|
if (!viewer) return share.denied(request, response);
|
|
4344
|
-
if (pathname === "/" || pathname === "/index.html") {
|
|
4345
|
-
response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
|
|
4346
|
-
return;
|
|
4347
|
-
}
|
|
4348
|
-
const rootBlob = BLOB_PATH.exec(pathname.slice(1));
|
|
4349
|
-
if (rootBlob) {
|
|
4350
|
-
void guard(response, () => serveBlob(request, response, rootBlob[1]));
|
|
4351
|
-
return;
|
|
4352
|
-
}
|
|
4353
|
-
const match = /^\/([a-z-]+)\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
4354
|
-
if (!match || match[1] !== KIND || match[2] !== grant) {
|
|
4355
|
-
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Not found");
|
|
4356
|
-
return;
|
|
4357
|
-
}
|
|
4358
|
-
if (match[3] === void 0) {
|
|
4359
|
-
response.writeHead(308, { Location: `${editorPath}${url2.search}`, "Cache-Control": "no-store" }).end();
|
|
4360
|
-
return;
|
|
4361
|
-
}
|
|
4362
4942
|
let rest;
|
|
4363
4943
|
try {
|
|
4364
|
-
rest = decodeURIComponent(
|
|
4944
|
+
rest = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname.slice(1));
|
|
4365
4945
|
} catch {
|
|
4366
4946
|
response.writeHead(400).end();
|
|
4367
4947
|
return;
|
|
4368
4948
|
}
|
|
4369
|
-
|
|
4949
|
+
return route(request, response, rest, viewer);
|
|
4370
4950
|
}
|
|
4371
|
-
const server2 = http.createServer((request, response) => void guard(response, () => handle(request, response)));
|
|
4372
4951
|
const close = () => {
|
|
4373
4952
|
stopWatch();
|
|
4374
4953
|
comments.close();
|
|
@@ -4376,32 +4955,27 @@ data: ${JSON.stringify(event)}
|
|
|
4376
4955
|
presence.close();
|
|
4377
4956
|
for (const client of clients.keys()) client.end();
|
|
4378
4957
|
clients.clear();
|
|
4379
|
-
server2.close();
|
|
4380
|
-
server2.closeAllConnections?.();
|
|
4381
4958
|
};
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4959
|
+
return {
|
|
4960
|
+
root,
|
|
4961
|
+
page: editorPath,
|
|
4962
|
+
share,
|
|
4963
|
+
handle: (request, response) => guard(response, () => handle(request, response)),
|
|
4964
|
+
hasBlob: async (id) => await store.findBlobFile(id) !== null,
|
|
4965
|
+
close
|
|
4966
|
+
};
|
|
4390
4967
|
}
|
|
4391
4968
|
function artifactTitle(root, projectDir) {
|
|
4392
4969
|
try {
|
|
4393
|
-
const index = JSON.parse(
|
|
4970
|
+
const index = JSON.parse(fs12.readFileSync(path12.join(projectDir, INDEX_FILE), "utf8"));
|
|
4394
4971
|
for (const value of [index.title, index.name]) if (typeof value === "string" && value.trim() && value.trim() !== "project") return value.trim();
|
|
4395
4972
|
} catch {
|
|
4396
4973
|
}
|
|
4397
|
-
return
|
|
4974
|
+
return path12.basename(root);
|
|
4398
4975
|
}
|
|
4399
4976
|
var runningServer2 = (root) => runningServer(root, KIND, ROUTES.session);
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
const state = { pid: process.pid, url: running.url, port: running.port, root, kind: KIND, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4403
|
-
recordServer(state, running.close);
|
|
4404
|
-
return { ...state, ...running.lan ? { lan: running.lan } : {} };
|
|
4977
|
+
function serveForeground(options) {
|
|
4978
|
+
return serveHub({ ...options, kind: KIND, script: SCRIPT, portGiven: options.portGiven === true, site: mountSite });
|
|
4405
4979
|
}
|
|
4406
4980
|
async function main(argv) {
|
|
4407
4981
|
const positional = [];
|
|
@@ -4410,23 +4984,23 @@ async function main(argv) {
|
|
|
4410
4984
|
const arg = argv[i];
|
|
4411
4985
|
if (!arg.startsWith("--")) positional.push(arg);
|
|
4412
4986
|
else if (arg.includes("=")) flags.set(arg.slice(2, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1));
|
|
4413
|
-
else if (arg === "--lan") flags.set(
|
|
4987
|
+
else if (arg === "--lan" || arg === "--background") flags.set(arg.slice(2), "true");
|
|
4414
4988
|
else flags.set(arg.slice(2), argv[++i] ?? "");
|
|
4415
4989
|
}
|
|
4416
4990
|
if (positional.length !== 1 || flags.has("help")) {
|
|
4417
4991
|
process.stderr.write(
|
|
4418
|
-
`Usage: node ${
|
|
4992
|
+
`Usage: node ${path12.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
|
|
4419
4993
|
<root> holds project/${INDEX_FILE}.
|
|
4420
4994
|
`
|
|
4421
4995
|
);
|
|
4422
4996
|
return 2;
|
|
4423
4997
|
}
|
|
4424
|
-
let root =
|
|
4425
|
-
if (
|
|
4426
|
-
root =
|
|
4998
|
+
let root = path12.resolve(positional[0]);
|
|
4999
|
+
if (path12.basename(root) === "project" && fs12.existsSync(path12.join(root, INDEX_FILE)) && !fs12.existsSync(path12.join(root, "project"))) {
|
|
5000
|
+
root = path12.dirname(root);
|
|
4427
5001
|
}
|
|
4428
|
-
if (!
|
|
4429
|
-
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${
|
|
5002
|
+
if (!fs12.statSync(path12.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
|
|
5003
|
+
process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path12.join(root, "project", INDEX_FILE)} does not exist.` })}
|
|
4430
5004
|
`);
|
|
4431
5005
|
return 2;
|
|
4432
5006
|
}
|
|
@@ -4443,14 +5017,14 @@ async function main(argv) {
|
|
|
4443
5017
|
return 0;
|
|
4444
5018
|
}
|
|
4445
5019
|
const agentHook = flags.get("agent-hook") || void 0;
|
|
4446
|
-
const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook, lan: flags.has("lan"), portGiven: flags.has("port") });
|
|
5020
|
+
const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook, lan: flags.has("lan"), portGiven: flags.has("port"), background: flags.has("background") });
|
|
4447
5021
|
process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid, ...state.lan ? { lan: state.lan } : {} }, null, 2)}
|
|
4448
5022
|
`);
|
|
4449
|
-
return -1;
|
|
5023
|
+
return state.joined && flags.has("background") ? 0 : -1;
|
|
4450
5024
|
}
|
|
4451
5025
|
function isMain(moduleUrl) {
|
|
4452
5026
|
try {
|
|
4453
|
-
return Boolean(process.argv[1]) && pathToFileURL(
|
|
5027
|
+
return Boolean(process.argv[1]) && pathToFileURL(fs12.realpathSync(process.argv[1])).href === moduleUrl;
|
|
4454
5028
|
} catch {
|
|
4455
5029
|
return false;
|
|
4456
5030
|
}
|
|
@@ -4467,7 +5041,7 @@ function createArtifactServer(kind, moduleUrl) {
|
|
|
4467
5041
|
kindLabels = kind.labels;
|
|
4468
5042
|
kindAgentContext = kind.agentContext;
|
|
4469
5043
|
SCRIPT = fileURLToPath(moduleUrl);
|
|
4470
|
-
EDITOR_DIR =
|
|
5044
|
+
EDITOR_DIR = path12.join(path12.dirname(SCRIPT), "editor");
|
|
4471
5045
|
const runIfMain = () => {
|
|
4472
5046
|
if (!isMain(moduleUrl)) return;
|
|
4473
5047
|
main(process.argv.slice(2)).then(
|
|
@@ -4482,7 +5056,7 @@ function createArtifactServer(kind, moduleUrl) {
|
|
|
4482
5056
|
}
|
|
4483
5057
|
);
|
|
4484
5058
|
};
|
|
4485
|
-
return {
|
|
5059
|
+
return { mountSite, runningServer: runningServer2, serveForeground, readState, runIfMain };
|
|
4486
5060
|
}
|
|
4487
5061
|
|
|
4488
5062
|
var KIND2 = "design";
|
|
@@ -4517,7 +5091,7 @@ function commentLocation2(input, projectDir) {
|
|
|
4517
5091
|
});
|
|
4518
5092
|
let boards = [];
|
|
4519
5093
|
try {
|
|
4520
|
-
const listed = JSON.parse(
|
|
5094
|
+
const listed = JSON.parse(fs13.readFileSync(path13.join(projectDir, INDEX_FILE2), "utf8")).boards;
|
|
4521
5095
|
if (listed && typeof listed === "object") boards = Object.keys(listed);
|
|
4522
5096
|
} catch {
|
|
4523
5097
|
}
|
|
@@ -4528,7 +5102,7 @@ function commentLocation2(input, projectDir) {
|
|
|
4528
5102
|
function labels(projectDir) {
|
|
4529
5103
|
const titles = /* @__PURE__ */ new Map();
|
|
4530
5104
|
try {
|
|
4531
|
-
const listed = JSON.parse(
|
|
5105
|
+
const listed = JSON.parse(fs13.readFileSync(path13.join(projectDir, INDEX_FILE2), "utf8")).boards;
|
|
4532
5106
|
if (listed && typeof listed === "object") {
|
|
4533
5107
|
for (const [file, board] of Object.entries(listed)) {
|
|
4534
5108
|
const title = board?.title;
|
|
@@ -4562,15 +5136,15 @@ var server = createArtifactServer(
|
|
|
4562
5136
|
},
|
|
4563
5137
|
import.meta.url
|
|
4564
5138
|
);
|
|
4565
|
-
var {
|
|
5139
|
+
var { mountSite: mountSite2, serveForeground: serveForeground2, runningServer: runningServer3, readState: readState2 } = server;
|
|
4566
5140
|
server.runIfMain();
|
|
4567
5141
|
export {
|
|
4568
5142
|
INDEX_FILE2 as INDEX_FILE,
|
|
4569
5143
|
KIND2 as KIND,
|
|
4570
5144
|
LAYOUT2 as LAYOUT,
|
|
4571
5145
|
NOUN2 as NOUN,
|
|
5146
|
+
mountSite2 as mountSite,
|
|
4572
5147
|
readState2 as readState,
|
|
4573
5148
|
runningServer3 as runningServer,
|
|
4574
|
-
serveForeground2 as serveForeground
|
|
4575
|
-
startServer2 as startServer
|
|
5149
|
+
serveForeground2 as serveForeground
|
|
4576
5150
|
};
|