@statewalker/webrun-http-browser 0.5.0 → 0.6.2

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/dist/relay-sw.js CHANGED
@@ -1533,6 +1533,51 @@
1533
1533
  })(request);
1534
1534
  }
1535
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
1536
1581
  //#region src/relay/split-service-url.ts
1537
1582
  /**
1538
1583
  * Splits a URL of the form `<base>/<separator><key>/<path>` into parts.
@@ -1540,36 +1585,165 @@
1540
1585
  */
1541
1586
  function splitServiceUrl(url, separator = "~") {
1542
1587
  const str = `${url}`;
1543
- const idx = str.indexOf(separator);
1544
- let baseUrl = "";
1545
- let key = "";
1546
- let path = "";
1547
- if (idx >= 0) {
1548
- baseUrl = str.substring(0, idx + separator.length);
1549
- str.substring(idx + separator.length).replace(/^([^/]+)/, (match, $1) => {
1550
- baseUrl += match;
1551
- if (baseUrl.length < str.length) baseUrl += "/";
1552
- key = $1;
1553
- path = str.substring(baseUrl.length);
1554
- return "";
1555
- });
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;
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;
1556
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;
1557
1631
  return {
1558
1632
  url: str,
1559
1633
  key,
1560
- baseUrl,
1561
- path
1634
+ baseUrl: `${prefix === "" ? rooted ? "/" : "" : `${prefix}/`}${separator}${key}${slash < 0 ? "" : "/"}`,
1635
+ path: slash < 0 ? "" : rest.slice(slash + 1)
1562
1636
  };
1563
1637
  }
1564
1638
  //#endregion
1565
1639
  //#region src/relay/index-sw.ts
1566
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
+ /**
1567
1734
  * Boots the relay ServiceWorker: routes fetches shaped `<origin>/~<key>/…` to
1568
1735
  * the client that registered `key`, and exposes REGISTER/UNREGISTER/CONNECT
1569
1736
  * channel calls used by the page-side relay client.
1570
1737
  */
1571
- function startRelayServiceWorker(self) {
1738
+ function startRelayServiceWorker(self, options = {}) {
1572
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";
1573
1747
  if (typeof self.skipWaiting === "function") self.addEventListener("install", (e) => {
1574
1748
  e.waitUntil(self.skipWaiting());
1575
1749
  });
@@ -1577,15 +1751,35 @@
1577
1751
  e.waitUntil(self.clients.claim());
1578
1752
  });
1579
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);
1580
1760
  register(handleClaimRequests(self));
1581
1761
  register(handleChannelCalls(self, "REGISTER", async (event, data) => {
1582
1762
  const source = event.source;
1583
1763
  if (!source) return false;
1584
- const { key } = data;
1585
- 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;
1586
1779
  }));
1587
1780
  register(handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
1588
1781
  const { key } = data;
1782
+ removeRegisteredMount(mounts, key, hostKeys);
1589
1783
  return await clientsRegistry.removeClient(key);
1590
1784
  }));
1591
1785
  register(handleChannelCalls(self, "CONNECT", async (_event, data, port) => {
@@ -1594,30 +1788,43 @@
1594
1788
  if (!client) throw new Error(`Target client was not found. Target key: "${key}".`);
1595
1789
  return await callChannel(client, "CONNECT", data, port);
1596
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
+ }
1597
1815
  const fetchListener = (event) => {
1598
1816
  const request = event.request;
1599
- const params = splitServiceUrl(request.url);
1600
- const { key } = params;
1601
- 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
+ }
1602
1824
  event.respondWith((async () => {
1603
- try {
1604
- const channel = new MessageChannel();
1605
- const client = await clientsRegistry.getClient(key);
1606
- if (!client) throw HttpError.errorResourceGone(params);
1607
- if (!await callChannel(client, "CONNECT", {
1608
- type: "http",
1609
- key
1610
- }, channel.port2)) throw HttpError.errorForbidden(params);
1611
- return await sendHttpRequest(channel.port1, request);
1612
- } catch (error) {
1613
- const httpError = HttpError.fromError(error);
1614
- const options = httpError.getResponseOptions(params);
1615
- return new Response(JSON.stringify(options), {
1616
- status: httpError.status ?? 500,
1617
- statusText: httpError.statusText ?? "Internal Error",
1618
- headers: { "Content-Type": "application/json" }
1619
- });
1620
- }
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);
1621
1828
  })());
1622
1829
  };
1623
1830
  self.addEventListener("fetch", fetchListener);
@@ -1629,7 +1836,11 @@
1629
1836
  async function loadClientsIndex() {
1630
1837
  if (!_index) {
1631
1838
  const entries = await get(key) ?? [];
1632
- _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
+ }
1633
1844
  }
1634
1845
  return _index;
1635
1846
  }
@@ -1638,11 +1849,14 @@
1638
1849
  await set(key, Object.entries(index));
1639
1850
  return index;
1640
1851
  }
1641
- async function addClient(clientKey, client) {
1852
+ async function addClient(clientKey, client, path) {
1642
1853
  const index = await loadClientsIndex();
1643
- const clientId = client.id;
1644
- if (index[clientKey] === clientId) return false;
1645
- 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
+ };
1646
1860
  await storeClientsIndex();
1647
1861
  return true;
1648
1862
  }
@@ -1653,11 +1867,21 @@
1653
1867
  await storeClientsIndex();
1654
1868
  return true;
1655
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
+ }
1656
1880
  async function getClient(clientKey) {
1657
1881
  const index = await loadClientsIndex();
1658
- const clientId = index[clientKey];
1659
- if (!clientId) return void 0;
1660
- 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);
1661
1885
  if (!client) {
1662
1886
  delete index[clientKey];
1663
1887
  await storeClientsIndex();
@@ -1666,12 +1890,14 @@
1666
1890
  }
1667
1891
  return {
1668
1892
  getClient,
1893
+ getMount,
1669
1894
  addClient,
1670
- removeClient
1895
+ removeClient,
1896
+ restoreMounts
1671
1897
  };
1672
1898
  }
1673
1899
  //#endregion
1674
1900
  //#region src/relay-sw.ts
1675
- startRelayServiceWorker(self);
1901
+ startRelayServiceWorker(self, self.RELAY_OPTIONS ?? {});
1676
1902
  //#endregion
1677
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"}