@statewalker/webrun-http-browser 0.4.2 → 0.6.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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which registered service owns a URL.
3
+ *
4
+ * A pure lookup: no ServiceWorker, no storage, no I/O, so the routing rules
5
+ * can be tested as arithmetic rather than through a browser.
6
+ *
7
+ * TWO KINDS OF MOUNT, AND WHY BOTH. A `path` is a prefix, which is all most
8
+ * hosts need and costs nothing to match. A `match` predicate is the escape
9
+ * hatch for anything richer -- a host that wants URLPattern brings it and pays
10
+ * for it; this file must stay dependency-free, because it runs in a
11
+ * ServiceWorker that has to start fast.
12
+ *
13
+ * SPECIFICITY, NOT REGISTRATION ORDER. Prefixes are tried longest-first, so a
14
+ * catch-all at "/" cannot swallow "/peers/" and a host need not register in a
15
+ * careful order. Predicates are opaque -- nothing can be said about how
16
+ * specific they are -- so they are tried after every prefix, in the order they
17
+ * were registered.
18
+ */
19
+ export interface MountSpec {
20
+ /** A path prefix, e.g. `/peers/`. `/` is the whole origin. */
21
+ path?: string;
22
+ /** Anything richer. Consulted only when no prefix matches. */
23
+ match?: (url: URL) => boolean;
24
+ }
25
+ export interface MountTable {
26
+ /** Add or replace the mount for `key`. */
27
+ set(key: string, spec: MountSpec): void;
28
+ remove(key: string): void;
29
+ /** The key that owns `url`, or `undefined` — meaning "not the relay's". */
30
+ find(url: URL): string | undefined;
31
+ /**
32
+ * Is `url` reserved by `exclude`? `find` already applies it, but the relay
33
+ * has a SECOND route — the `/~<key>/` spelling, which does not go through
34
+ * the table at all — and "an excluded path is never claimed" has to hold
35
+ * for both. The predicate lives here so there is one copy of it.
36
+ */
37
+ excludes(url: URL): boolean;
38
+ }
39
+ export interface MountTableOptions {
40
+ /**
41
+ * Paths the relay never claims, checked BEFORE the table. A root mount
42
+ * matches every path, so a host with files of its own (a relay page, a
43
+ * worker, hashed assets) is unusable without this.
44
+ */
45
+ exclude?: (url: URL) => boolean;
46
+ }
47
+ export declare function newMountTable(options?: MountTableOptions): MountTable;
48
+ //# sourceMappingURL=mount-table.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mount-table.d.ts","sourceRoot":"","sources":["../../src/relay/mount-table.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,SAAS;IACxB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8DAA8D;IAC9D,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,UAAU;IACzB,0CAA0C;IAC1C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IACxC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,2EAA2E;IAC3E,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,GAAG,SAAS,CAAC;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC;CACjC;AAyBD,wBAAgB,aAAa,CAAC,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAyCzE"}
@@ -1 +1 @@
1
- {"version":3,"file":"split-service-url.d.ts","sourceRoot":"","sources":["../../src/relay/split-service-url.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,EAAE,SAAS,SAAM,GAAG,eAAe,CAiBnF"}
1
+ {"version":3,"file":"split-service-url.d.ts","sourceRoot":"","sources":["../../src/relay/split-service-url.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,EAAE,SAAS,SAAM,GAAG,eAAe,CA6EnF"}
package/dist/relay-sw.js CHANGED
@@ -1202,7 +1202,7 @@
1202
1202
  }
1203
1203
  };
1204
1204
  //#endregion
1205
- //#region ../../../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1205
+ //#region ../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1206
1206
  function promisifyRequest(request) {
1207
1207
  return new Promise((resolve, reject) => {
1208
1208
  request.oncomplete = request.onsuccess = () => resolve(request.result);
@@ -1319,6 +1319,22 @@
1319
1319
  });
1320
1320
  }
1321
1321
  //#endregion
1322
+ //#region src/core/service-worker-control.ts
1323
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
1324
+ const CLAIM_CALL = "CLAIM";
1325
+ /**
1326
+ * ServiceWorker side: answers the page's `CLAIM` request with
1327
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
1328
+ * Returns a function that stops answering.
1329
+ */
1330
+ function handleClaimRequests(self) {
1331
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
1332
+ const claimed = self.clients.claim().then(() => true);
1333
+ event.waitUntil?.(claimed);
1334
+ return claimed;
1335
+ });
1336
+ }
1337
+ //#endregion
1322
1338
  //#region src/core/data-channels.ts
1323
1339
  const MESSAGE_TYPE_REQUEST = "REQUEST";
1324
1340
  const MESSAGE_TYPE_RESPONSE = "RESPONSE";
@@ -1517,6 +1533,51 @@
1517
1533
  })(request);
1518
1534
  }
1519
1535
  //#endregion
1536
+ //#region src/relay/mount-table.ts
1537
+ /** `/` stays `/`; `/peers` and `/peers/` both become `/peers/`. */
1538
+ function normalise(path) {
1539
+ if (path === "" || path === "/") return "/";
1540
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
1541
+ return withSlash.endsWith("/") ? withSlash : `${withSlash}/`;
1542
+ }
1543
+ /** Does `prefix` own `pathname`? `/peers/` owns `/peers/`, `/peers` and `/peers/x`. */
1544
+ function owns(prefix, pathname) {
1545
+ if (prefix === "/") return true;
1546
+ if (pathname.startsWith(prefix)) return true;
1547
+ return `${pathname}/` === prefix;
1548
+ }
1549
+ function newMountTable(options = {}) {
1550
+ const entries = /* @__PURE__ */ new Map();
1551
+ let seq = 0;
1552
+ return {
1553
+ set(key, spec) {
1554
+ entries.set(key, {
1555
+ key,
1556
+ prefix: spec.path == null ? "" : normalise(spec.path),
1557
+ match: spec.match,
1558
+ seq: seq++
1559
+ });
1560
+ },
1561
+ remove(key) {
1562
+ entries.delete(key);
1563
+ },
1564
+ excludes(url) {
1565
+ return options.exclude?.(url) === true;
1566
+ },
1567
+ find(url) {
1568
+ if (options.exclude?.(url) === true) return void 0;
1569
+ let best;
1570
+ for (const entry of entries.values()) {
1571
+ if (entry.prefix === "" || !owns(entry.prefix, url.pathname)) continue;
1572
+ if (best == null || entry.prefix.length > best.prefix.length) best = entry;
1573
+ }
1574
+ if (best != null) return best.key;
1575
+ const predicates = [...entries.values()].filter((entry) => entry.match != null).sort((a, b) => a.seq - b.seq);
1576
+ for (const entry of predicates) if (entry.match?.(url) === true) return entry.key;
1577
+ }
1578
+ };
1579
+ }
1580
+ //#endregion
1520
1581
  //#region src/relay/split-service-url.ts
1521
1582
  /**
1522
1583
  * Splits a URL of the form `<base>/<separator><key>/<path>` into parts.
@@ -1524,36 +1585,165 @@
1524
1585
  */
1525
1586
  function splitServiceUrl(url, separator = "~") {
1526
1587
  const str = `${url}`;
1527
- const idx = str.indexOf(separator);
1528
- let baseUrl = "";
1529
- let key = "";
1530
- let path = "";
1531
- if (idx >= 0) {
1532
- baseUrl = str.substring(0, idx + separator.length);
1533
- str.substring(idx + separator.length).replace(/^([^/]+)/, (match, $1) => {
1534
- baseUrl += match;
1535
- if (baseUrl.length < str.length) baseUrl += "/";
1536
- key = $1;
1537
- path = str.substring(baseUrl.length);
1538
- return "";
1539
- });
1588
+ const empty = {
1589
+ url: str,
1590
+ key: "",
1591
+ baseUrl: "",
1592
+ path: ""
1593
+ };
1594
+ const hashIdx = str.indexOf("#");
1595
+ const queryIdx = str.indexOf("?");
1596
+ let strippedEnd = str.length;
1597
+ if (hashIdx >= 0) strippedEnd = Math.min(strippedEnd, hashIdx);
1598
+ if (queryIdx >= 0) strippedEnd = Math.min(strippedEnd, queryIdx);
1599
+ const stripped = str.slice(0, strippedEnd);
1600
+ let prefixEnd = 0;
1601
+ const schemeMatch = stripped.match(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//);
1602
+ if (schemeMatch) {
1603
+ prefixEnd = schemeMatch[0].length;
1604
+ const slashIdx = stripped.indexOf("/", prefixEnd);
1605
+ if (slashIdx >= 0) prefixEnd = slashIdx;
1606
+ else return empty;
1607
+ } else if (stripped.startsWith("//")) {
1608
+ prefixEnd = 2;
1609
+ const slashIdx = stripped.indexOf("/", prefixEnd);
1610
+ if (slashIdx >= 0) prefixEnd = slashIdx;
1611
+ else return empty;
1540
1612
  }
1613
+ const prefix = stripped.slice(0, prefixEnd);
1614
+ const pathPart = stripped.slice(prefixEnd);
1615
+ let keyStart;
1616
+ let rooted = false;
1617
+ if (prefix === "") {
1618
+ if (pathPart.startsWith(`/${separator}`)) {
1619
+ rooted = true;
1620
+ keyStart = separator.length + 1;
1621
+ } else if (pathPart.startsWith(separator)) keyStart = separator.length;
1622
+ else return empty;
1623
+ } else {
1624
+ if (!pathPart.startsWith(`/${separator}`)) return empty;
1625
+ keyStart = separator.length + 1;
1626
+ }
1627
+ const rest = pathPart.slice(keyStart);
1628
+ const slash = rest.indexOf("/");
1629
+ const key = slash < 0 ? rest : rest.slice(0, slash);
1630
+ if (key === "") return empty;
1541
1631
  return {
1542
1632
  url: str,
1543
1633
  key,
1544
- baseUrl,
1545
- path
1634
+ baseUrl: `${prefix === "" ? rooted ? "/" : "" : `${prefix}/`}${separator}${key}${slash < 0 ? "" : "/"}`,
1635
+ path: slash < 0 ? "" : rest.slice(slash + 1)
1546
1636
  };
1547
1637
  }
1548
1638
  //#endregion
1549
1639
  //#region src/relay/index-sw.ts
1550
1640
  /**
1641
+ * One stored registry entry, whatever shape it is on disk.
1642
+ *
1643
+ * BEFORE MOUNTS THE VALUE WAS A BARE CLIENT ID. A browser that ran the earlier
1644
+ * worker still holds that shape, and reading it as an object would drop the id
1645
+ * and quietly unregister every service the visitor had.
1646
+ */
1647
+ function readStoredEntry(value) {
1648
+ if (typeof value === "string") return { clientId: value };
1649
+ if (typeof value !== "object" || value === null) return void 0;
1650
+ const { clientId, path } = value;
1651
+ if (typeof clientId !== "string" || clientId === "") return void 0;
1652
+ return typeof path === "string" ? {
1653
+ clientId,
1654
+ path
1655
+ } : { clientId };
1656
+ }
1657
+ /**
1658
+ * Which service, if any, should answer `url`.
1659
+ *
1660
+ * `undefined` means NOT THE RELAY'S, and the caller must not call
1661
+ * `respondWith`: the request then goes to the network, which is how a host
1662
+ * keeps serving its own files from its own origin. Answering 404 here instead
1663
+ * would make a root mount fatal.
1664
+ */
1665
+ function resolveServiceKey(url, table, selfOrigin) {
1666
+ if (url.origin !== selfOrigin) return void 0;
1667
+ if (table.excludes(url)) return void 0;
1668
+ const mounted = table.find(url);
1669
+ if (mounted != null) return mounted;
1670
+ const { key } = splitServiceUrl(url);
1671
+ return key === "" ? void 0 : key;
1672
+ }
1673
+ /**
1674
+ * Waits for the mount table to be restored from the registry before routing
1675
+ * `url` — but a restore failure must never wedge every fetch. `restored`
1676
+ * rejecting (blocked storage, quota, private-mode edge cases) would otherwise
1677
+ * propagate straight to `respondWith` on every request, including ones that
1678
+ * should reach the network, which breaks the one rule this file exists to
1679
+ * uphold. So: log the failure and route with whatever the in-memory table
1680
+ * already holds — possibly empty, never fatal.
1681
+ */
1682
+ async function resolveAfterRestore(restored, url, table, selfOrigin) {
1683
+ try {
1684
+ await restored;
1685
+ } catch (error) {
1686
+ console.error("[relay] failed to restore mounts from the registry", error);
1687
+ }
1688
+ return resolveServiceKey(url, table, selfOrigin);
1689
+ }
1690
+ /**
1691
+ * What REGISTER does to the mount table: set it when `path` is given, or
1692
+ * remove any earlier mount when it is not. A path-less re-registration
1693
+ * reverts a service to `/~<key>/` addressing, and a stale prefix left behind
1694
+ * would keep routing requests to a mount that no longer exists.
1695
+ *
1696
+ * A REGISTRATION ONLY TOUCHES WHAT A REGISTRATION MADE. `hostKeys` names the
1697
+ * mounts the host declared itself, in `options.mounts`. Those are the host's
1698
+ * build-time decision and a page may not undo it: the documented static flow
1699
+ * -- host declares `{ key: "app", path: "/" }`, page calls `initHttpService(h,
1700
+ * { key: "app", port })` with no path -- would otherwise have its very first
1701
+ * registration delete the host's own mount and leave the origin unmounted. A
1702
+ * path-ful REGISTER naming a host key is ignored for the same reason (it would
1703
+ * replace a `match` predicate with a prefix of the page's choosing).
1704
+ */
1705
+ function applyRegisteredMount(table, key, path, hostKeys = /* @__PURE__ */ new Set()) {
1706
+ if (hostKeys.has(key)) return;
1707
+ if (path != null) table.set(key, { path });
1708
+ else table.remove(key);
1709
+ }
1710
+ /**
1711
+ * What UNREGISTER does to the mount table: drop the mount a registration
1712
+ * made. A host-declared mount stays, for the reason `applyRegisteredMount`
1713
+ * gives -- a page tearing down its service must not take the host's table
1714
+ * with it.
1715
+ */
1716
+ function removeRegisteredMount(table, key, hostKeys = /* @__PURE__ */ new Set()) {
1717
+ if (hostKeys.has(key)) return;
1718
+ table.remove(key);
1719
+ }
1720
+ /**
1721
+ * May `candidateId` take the key?
1722
+ *
1723
+ * `last-wins` is what the relay has always done and stays the default. With
1724
+ * `first-wins`, a LIVE holder keeps its key: on an origin whose name is
1725
+ * guessable, a second page proves nothing by existing. A holder that reloaded
1726
+ * is no longer live, so a host's own re-registration is never blocked.
1727
+ */
1728
+ function mayRegister(args) {
1729
+ if (args.takeover === "last-wins") return true;
1730
+ if (args.current == null || !args.isCurrentLive) return true;
1731
+ return args.current.clientId === args.candidateId;
1732
+ }
1733
+ /**
1551
1734
  * Boots the relay ServiceWorker: routes fetches shaped `<origin>/~<key>/…` to
1552
1735
  * the client that registered `key`, and exposes REGISTER/UNREGISTER/CONNECT
1553
1736
  * channel calls used by the page-side relay client.
1554
1737
  */
1555
- function startRelayServiceWorker(self) {
1738
+ function startRelayServiceWorker(self, options = {}) {
1556
1739
  const [register, clear] = newRegistry();
1740
+ const mounts = newMountTable({ exclude: options.exclude });
1741
+ const hostKeys = /* @__PURE__ */ new Set();
1742
+ for (const { key, ...spec } of options.mounts ?? []) {
1743
+ mounts.set(key, spec);
1744
+ hostKeys.add(key);
1745
+ }
1746
+ const takeover = options.takeover ?? "last-wins";
1557
1747
  if (typeof self.skipWaiting === "function") self.addEventListener("install", (e) => {
1558
1748
  e.waitUntil(self.skipWaiting());
1559
1749
  });
@@ -1561,14 +1751,35 @@
1561
1751
  e.waitUntil(self.clients.claim());
1562
1752
  });
1563
1753
  const clientsRegistry = newClientsRegistry({ self });
1754
+ const restored = clientsRegistry.restoreMounts(mounts, hostKeys);
1755
+ let restoreSettled = false;
1756
+ const latch = () => {
1757
+ restoreSettled = true;
1758
+ };
1759
+ restored.then(latch, latch);
1760
+ register(handleClaimRequests(self));
1564
1761
  register(handleChannelCalls(self, "REGISTER", async (event, data) => {
1565
1762
  const source = event.source;
1566
1763
  if (!source) return false;
1567
- const { key } = data;
1568
- return await clientsRegistry.addClient(key, source);
1764
+ const { key, path } = data;
1765
+ if (options.canRegister != null && !await options.canRegister(source, key)) throw new Error(`this client may not register "${key}"`);
1766
+ if (takeover === "first-wins") {
1767
+ const current = await clientsRegistry.getMount(key);
1768
+ const isCurrentLive = current != null && await clientsRegistry.getClient(key) != null;
1769
+ if (!mayRegister({
1770
+ current,
1771
+ candidateId: source.id,
1772
+ isCurrentLive,
1773
+ takeover
1774
+ })) throw new Error(`"${key}" is already served by another client`);
1775
+ }
1776
+ const added = await clientsRegistry.addClient(key, source, path);
1777
+ applyRegisteredMount(mounts, key, path, hostKeys);
1778
+ return added;
1569
1779
  }));
1570
1780
  register(handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
1571
1781
  const { key } = data;
1782
+ removeRegisteredMount(mounts, key, hostKeys);
1572
1783
  return await clientsRegistry.removeClient(key);
1573
1784
  }));
1574
1785
  register(handleChannelCalls(self, "CONNECT", async (_event, data, port) => {
@@ -1577,30 +1788,43 @@
1577
1788
  if (!client) throw new Error(`Target client was not found. Target key: "${key}".`);
1578
1789
  return await callChannel(client, "CONNECT", data, port);
1579
1790
  }));
1791
+ /** Answers `request` as the service registered under `key`. */
1792
+ async function serve(key, request, url) {
1793
+ const params = splitServiceUrl(url);
1794
+ try {
1795
+ const channel = new MessageChannel();
1796
+ const client = await clientsRegistry.getClient(key);
1797
+ if (!client) throw HttpError.errorResourceGone(params);
1798
+ if (!await callChannel(client, "CONNECT", {
1799
+ type: "http",
1800
+ key
1801
+ }, channel.port2)) throw HttpError.errorForbidden(params);
1802
+ const response = await sendHttpRequest(channel.port1, request);
1803
+ return options.decorateResponse?.(response, request) ?? response;
1804
+ } catch (error) {
1805
+ const httpError = HttpError.fromError(error);
1806
+ const errorOptions = httpError.getResponseOptions(params);
1807
+ const errorResponse = new Response(JSON.stringify(errorOptions), {
1808
+ status: httpError.status ?? 500,
1809
+ statusText: httpError.statusText ?? "Internal Error",
1810
+ headers: { "Content-Type": "application/json" }
1811
+ });
1812
+ return options.decorateResponse?.(errorResponse, request) ?? errorResponse;
1813
+ }
1814
+ }
1580
1815
  const fetchListener = (event) => {
1581
1816
  const request = event.request;
1582
- const params = splitServiceUrl(request.url);
1583
- const { key } = params;
1584
- if (!key) return;
1817
+ const url = new URL(request.url);
1818
+ if (restoreSettled) {
1819
+ const key = resolveServiceKey(url, mounts, self.location.origin);
1820
+ if (key == null) return;
1821
+ event.respondWith(serve(key, request, url));
1822
+ return;
1823
+ }
1585
1824
  event.respondWith((async () => {
1586
- try {
1587
- const channel = new MessageChannel();
1588
- const client = await clientsRegistry.getClient(key);
1589
- if (!client) throw HttpError.errorResourceGone(params);
1590
- if (!await callChannel(client, "CONNECT", {
1591
- type: "http",
1592
- key
1593
- }, channel.port2)) throw HttpError.errorForbidden(params);
1594
- return await sendHttpRequest(channel.port1, request);
1595
- } catch (error) {
1596
- const httpError = HttpError.fromError(error);
1597
- const options = httpError.getResponseOptions(params);
1598
- return new Response(JSON.stringify(options), {
1599
- status: httpError.status ?? 500,
1600
- statusText: httpError.statusText ?? "Internal Error",
1601
- headers: { "Content-Type": "application/json" }
1602
- });
1603
- }
1825
+ const key = await resolveAfterRestore(restored, url, mounts, self.location.origin);
1826
+ if (key == null) return await fetch(request);
1827
+ return await serve(key, request, url);
1604
1828
  })());
1605
1829
  };
1606
1830
  self.addEventListener("fetch", fetchListener);
@@ -1612,7 +1836,11 @@
1612
1836
  async function loadClientsIndex() {
1613
1837
  if (!_index) {
1614
1838
  const entries = await get(key) ?? [];
1615
- _index = Object.fromEntries(entries);
1839
+ _index = {};
1840
+ for (const [clientKey, value] of entries) {
1841
+ const entry = readStoredEntry(value);
1842
+ if (entry) _index[clientKey] = entry;
1843
+ }
1616
1844
  }
1617
1845
  return _index;
1618
1846
  }
@@ -1621,11 +1849,14 @@
1621
1849
  await set(key, Object.entries(index));
1622
1850
  return index;
1623
1851
  }
1624
- async function addClient(clientKey, client) {
1852
+ async function addClient(clientKey, client, path) {
1625
1853
  const index = await loadClientsIndex();
1626
- const clientId = client.id;
1627
- if (index[clientKey] === clientId) return false;
1628
- index[clientKey] = clientId;
1854
+ const current = index[clientKey];
1855
+ if (current?.clientId === client.id && current.path === path) return false;
1856
+ index[clientKey] = path == null ? { clientId: client.id } : {
1857
+ clientId: client.id,
1858
+ path
1859
+ };
1629
1860
  await storeClientsIndex();
1630
1861
  return true;
1631
1862
  }
@@ -1636,11 +1867,21 @@
1636
1867
  await storeClientsIndex();
1637
1868
  return true;
1638
1869
  }
1870
+ async function getMount(clientKey) {
1871
+ return (await loadClientsIndex())[clientKey];
1872
+ }
1873
+ async function restoreMounts(table, hostKeys = /* @__PURE__ */ new Set()) {
1874
+ const index = await loadClientsIndex();
1875
+ for (const [clientKey, entry] of Object.entries(index)) {
1876
+ if (hostKeys.has(clientKey)) continue;
1877
+ if (entry.path != null) table.set(clientKey, { path: entry.path });
1878
+ }
1879
+ }
1639
1880
  async function getClient(clientKey) {
1640
1881
  const index = await loadClientsIndex();
1641
- const clientId = index[clientKey];
1642
- if (!clientId) return void 0;
1643
- const client = await self.clients.get(clientId);
1882
+ const entry = index[clientKey];
1883
+ if (!entry) return void 0;
1884
+ const client = await self.clients.get(entry.clientId);
1644
1885
  if (!client) {
1645
1886
  delete index[clientKey];
1646
1887
  await storeClientsIndex();
@@ -1649,12 +1890,14 @@
1649
1890
  }
1650
1891
  return {
1651
1892
  getClient,
1893
+ getMount,
1652
1894
  addClient,
1653
- removeClient
1895
+ removeClient,
1896
+ restoreMounts
1654
1897
  };
1655
1898
  }
1656
1899
  //#endregion
1657
1900
  //#region src/relay-sw.ts
1658
- startRelayServiceWorker(self);
1901
+ startRelayServiceWorker(self, self.RELAY_OPTIONS ?? {});
1659
1902
  //#endregion
1660
1903
  })();
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The relay ServiceWorker runtime, for a host that builds its own worker.
3
+ *
4
+ * TWO WAYS TO SHIP THE RELAY WORKER, and this is the typed one. A host that
5
+ * takes the prebuilt bundle loads `@statewalker/webrun-http-browser/relay-sw`
6
+ * through classic `importScripts` and passes its options in
7
+ * `self.RELAY_OPTIONS`, which cannot be typed from the outside because that
8
+ * bundle ships no declarations. A host that bundles its own worker imports
9
+ * this module instead and calls `startRelayServiceWorker(self, { … })`
10
+ * directly, with `RelayServiceWorkerOptions` to type the options — including
11
+ * `self.RELAY_OPTIONS`, for a host writing the tiny loader script by hand.
12
+ *
13
+ * Kept as its own entry rather than re-exported from the package root: this
14
+ * code only runs inside a ServiceWorker, and the root entry is what pages
15
+ * import.
16
+ */
17
+ export type { RelayServiceWorkerOptions } from "./relay/index-sw.js";
18
+ export { startRelayServiceWorker } from "./relay/index-sw.js";
19
+ export type { MountSpec } from "./relay/mount-table.js";
20
+ //# sourceMappingURL=relay-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-worker.d.ts","sourceRoot":"","sources":["../src/relay-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,YAAY,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AACrE,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,YAAY,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC"}