@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.
@@ -3,6 +3,19 @@ export interface SwPortHandlerOptions {
3
3
  scope?: string;
4
4
  serviceWorkerUrl?: string;
5
5
  bindPort: (port: MessagePort) => void | Promise<void>;
6
+ /**
7
+ * Upper bound, in ms, for `start()`'s wait for the worker to activate, take
8
+ * control of the page and answer the handshake. `start()` rejects with a
9
+ * `ServiceWorkerControlError` past it instead of waiting forever.
10
+ * Default `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
11
+ */
12
+ timeout?: number;
13
+ /**
14
+ * If the page is still uncontrolled once the worker is active — after a
15
+ * hard reload, say — and the worker does not take it over when asked,
16
+ * reload the page once instead of rejecting. Default `false`.
17
+ */
18
+ reloadIfUncontrolled?: boolean;
6
19
  }
7
20
  interface ChannelInfo {
8
21
  key: string;
@@ -28,6 +41,15 @@ export declare class SwPortHandler {
28
41
  protected _setCommunicationPort(port: MessagePort): Promise<void>;
29
42
  protected _updateCommunicationChannel(): Promise<void>;
30
43
  protected _getRegistrationInfo(): ChannelInfo;
44
+ /**
45
+ * Registers the worker and waits until it is activated and controls this
46
+ * page, then opens the port to it. The page must be controlled, because
47
+ * only a controlled page's `fetch()` reaches the worker. When the page
48
+ * loaded uncontrolled (a hard reload does that), the worker is asked to
49
+ * claim it. Rejects with a `ServiceWorkerControlError` when that fails or
50
+ * `timeout` passes (`reason` says which step); a later `start()` tries
51
+ * again.
52
+ */
31
53
  start(): Promise<void>;
32
54
  stop(): Promise<void>;
33
55
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sw-dispatcher.d.ts","sourceRoot":"","sources":["../../src/sw/sw-dispatcher.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvD;AAED,UAAU,WAAW;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB;AAED;;;GAGG;AACH,qBAAa,aAAa;IACxB,QAAQ,CAAC,OAAO,EAAE,oBAAoB,CAAC;IACvC,OAAO,CAAC,iBAAiB,CAAC,CAAS;IACnC,OAAO,CAAC,cAAc,CAAC,CAAgB;IACvC,OAAO,CAAC,oBAAoB,CAAC,CAAgB;IAC7C,OAAO,CAAC,qBAAqB,CAAC,CAAa;IAE3C,YAAY,OAAO,EAAE,oBAAoB,EAIxC;IAED,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,OAAO,IAAI,GAAG,CAEjB;IAED,IAAI,gBAAgB,IAAI,MAAM,CAa7B;IAED,UAAgB,wBAAwB,IAAI,OAAO,CAAC,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,CAG9E;IAED,UAAgB,qBAAqB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;IAED,UAAgB,2BAA2B,IAAI,OAAO,CAAC,IAAI,CAAC,CAU3D;IAED,SAAS,CAAC,oBAAoB,IAAI,WAAW,CAE5C;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CA8B3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAU1B;CACF;AAiCD,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,wBAAwB,CAAC;IAC/B,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACpC;AAED;;;GAGG;AACH,qBAAa,gBAAgB;IAC3B,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IACxC,QAAQ,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAC3C,QAAQ,CAAC,aAAa,2BAAkC;IACxD;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,cAAqB;IACzC,OAAO,CAAC,QAAQ,CAAC,CAAa;IAC9B,OAAO,CAAC,kBAAkB,CAAC,CAAgB;IAE3C,YAAY,EAAE,IAAI,EAAE,GAAc,EAAE,EAAE,uBAAuB,EAG5D;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;OAIG;IACG,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAgBnE;IAED,yDAAyD;IACzD,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEjC;IAED,KAAK,IAAI,IAAI,CA2BZ;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAK1B;IAED,OAAO,CAAC,gBAAgB;YAOV,SAAS;YAgBT,kBAAkB;YAWlB,cAAc;YAOd,gBAAgB;CAY/B"}
1
+ {"version":3,"file":"sw-dispatcher.d.ts","sourceRoot":"","sources":["../../src/sw/sw-dispatcher.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED,UAAU,WAAW;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB;AAED;;;GAGG;AACH,qBAAa,aAAa;IACxB,QAAQ,CAAC,OAAO,EAAE,oBAAoB,CAAC;IACvC,OAAO,CAAC,iBAAiB,CAAC,CAAS;IACnC,OAAO,CAAC,cAAc,CAAC,CAAgB;IACvC,OAAO,CAAC,oBAAoB,CAAC,CAAgB;IAC7C,OAAO,CAAC,qBAAqB,CAAC,CAAa;IAE3C,YAAY,OAAO,EAAE,oBAAoB,EAIxC;IAED,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,OAAO,IAAI,GAAG,CAEjB;IAED,IAAI,gBAAgB,IAAI,MAAM,CAa7B;IAED,UAAgB,wBAAwB,IAAI,OAAO,CAAC,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,CAG9E;IAED,UAAgB,qBAAqB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;IAED,UAAgB,2BAA2B,IAAI,OAAO,CAAC,IAAI,CAAC,CAU3D;IAED,SAAS,CAAC,oBAAoB,IAAI,WAAW,CAE5C;IAED;;;;;;;;OAQG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAsD3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAU1B;CACF;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,wBAAwB,CAAC;IAC/B,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACpC;AAED;;;GAGG;AACH,qBAAa,gBAAgB;IAC3B,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IACxC,QAAQ,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAC3C,QAAQ,CAAC,aAAa,2BAAkC;IACxD;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,cAAqB;IACzC,OAAO,CAAC,QAAQ,CAAC,CAAa;IAC9B,OAAO,CAAC,kBAAkB,CAAC,CAAgB;IAE3C,YAAY,EAAE,IAAI,EAAE,GAAc,EAAE,EAAE,uBAAuB,EAG5D;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;OAIG;IACG,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAgBnE;IAED,yDAAyD;IACzD,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEjC;IAED,KAAK,IAAI,IAAI,CAgCZ;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAK1B;IAED,OAAO,CAAC,gBAAgB;YAOV,SAAS;YAgBT,kBAAkB;YAWlB,cAAc;YAOd,gBAAgB;CAY/B"}
package/dist/sw-worker.js CHANGED
@@ -1341,7 +1341,7 @@
1341
1341
  })(request);
1342
1342
  }
1343
1343
  //#endregion
1344
- //#region ../../../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1344
+ //#region ../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1345
1345
  function promisifyRequest(request) {
1346
1346
  return new Promise((resolve, reject) => {
1347
1347
  request.oncomplete = request.onsuccess = () => resolve(request.result);
@@ -1424,6 +1424,22 @@
1424
1424
  return () => target.removeEventListener("message", listener);
1425
1425
  }
1426
1426
  //#endregion
1427
+ //#region src/core/service-worker-control.ts
1428
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
1429
+ const CLAIM_CALL = "CLAIM";
1430
+ /**
1431
+ * ServiceWorker side: answers the page's `CLAIM` request with
1432
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
1433
+ * Returns a function that stops answering.
1434
+ */
1435
+ function handleClaimRequests(self) {
1436
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
1437
+ const claimed = self.clients.claim().then(() => true);
1438
+ event.waitUntil?.(claimed);
1439
+ return claimed;
1440
+ });
1441
+ }
1442
+ //#endregion
1427
1443
  //#region src/sw/sw-dispatcher.ts
1428
1444
  /**
1429
1445
  * ServiceWorker-side counterpart: maintains an index of connected clients keyed by
@@ -1472,7 +1488,8 @@
1472
1488
  return this.claimedKeys.has(key);
1473
1489
  }
1474
1490
  start() {
1475
- this._cleanup = handleChannelCalls(this.self, "UPDATE_COMMUNICATION_PORT", async (event, channelInfo, port) => {
1491
+ const stopClaims = handleClaimRequests(this.self);
1492
+ const stopPortUpdates = handleChannelCalls(this.self, "UPDATE_COMMUNICATION_PORT", async (event, channelInfo, port) => {
1476
1493
  const clientId = event.source?.id;
1477
1494
  this.log("[UPDATE_COMMUNICATION_PORT]", clientId, channelInfo);
1478
1495
  await this._updateChannelInfo({
@@ -1482,6 +1499,10 @@
1482
1499
  });
1483
1500
  return { ...channelInfo };
1484
1501
  });
1502
+ this._cleanup = () => {
1503
+ stopClaims();
1504
+ stopPortUpdates();
1505
+ };
1485
1506
  this.self.addEventListener("install", (event) => {
1486
1507
  this.log("Skip waiting on install.", event);
1487
1508
  this.self.skipWaiting();
package/dist/sw.js CHANGED
@@ -1440,7 +1440,7 @@ async function sendHttpRequest(communicationPort, request) {
1440
1440
  })(request);
1441
1441
  }
1442
1442
  //#endregion
1443
- //#region ../../../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1443
+ //#region ../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1444
1444
  function promisifyRequest(request) {
1445
1445
  return new Promise((resolve, reject) => {
1446
1446
  request.oncomplete = request.onsuccess = () => resolve(request.result);
@@ -1523,6 +1523,29 @@ function handleChannelCalls(target, callType, handler) {
1523
1523
  return () => target.removeEventListener("message", listener);
1524
1524
  }
1525
1525
  //#endregion
1526
+ //#region src/core/deadline.ts
1527
+ /**
1528
+ * Settles like `promise` if it settles before `deadline` (a `Date.now()`
1529
+ * value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
1530
+ * other value resolves. Internal; not re-exported.
1531
+ */
1532
+ function withDeadline(deadline, promise, onTimeout) {
1533
+ return new Promise((resolve, reject) => {
1534
+ const timer = setTimeout(() => {
1535
+ const outcome = onTimeout();
1536
+ if (outcome instanceof Error) reject(outcome);
1537
+ else resolve(outcome);
1538
+ }, Math.max(0, deadline - Date.now()));
1539
+ promise.then((value) => {
1540
+ clearTimeout(timer);
1541
+ resolve(value);
1542
+ }, (error) => {
1543
+ clearTimeout(timer);
1544
+ reject(error);
1545
+ });
1546
+ });
1547
+ }
1548
+ //#endregion
1526
1549
  //#region src/core/registry.ts
1527
1550
  function newRegistry(onError = console.error) {
1528
1551
  let counter = 0;
@@ -1557,6 +1580,169 @@ function newRegistry(onError = console.error) {
1557
1580
  });
1558
1581
  }
1559
1582
  //#endregion
1583
+ //#region src/core/service-worker-control.ts
1584
+ /**
1585
+ * How long, by default, the page waits for its ServiceWorker to activate and
1586
+ * to take control before giving up. Generous: a first install downloads and
1587
+ * evaluates the worker script, which on a slow link takes seconds.
1588
+ */
1589
+ const DEFAULT_SERVICE_WORKER_TIMEOUT = 3e4;
1590
+ /**
1591
+ * How long the page waits for `controllerchange` once the worker has
1592
+ * answered the `CLAIM` request. `clients.claim()` resolves only after the
1593
+ * browser has queued that event, so this is a grace period for delivery, not
1594
+ * a second budget.
1595
+ */
1596
+ const CLAIM_GRACE_MS = 1e3;
1597
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
1598
+ const CLAIM_CALL = "CLAIM";
1599
+ /**
1600
+ * Why a page could not get a working ServiceWorker. `reason` says which wait
1601
+ * failed; check it (or `name`) rather than `instanceof`, because each of this
1602
+ * package's bundles carries its own copy of this class.
1603
+ */
1604
+ var ServiceWorkerControlError = class extends Error {
1605
+ reason;
1606
+ constructor(reason, message) {
1607
+ super(message);
1608
+ this.name = "ServiceWorkerControlError";
1609
+ this.reason = reason;
1610
+ }
1611
+ };
1612
+ /**
1613
+ * Resolves with the registration's worker once it is `activated`. Rejects
1614
+ * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
1615
+ * takes longer than `timeout` — an install that throws or never finishes
1616
+ * would otherwise leave the caller waiting forever.
1617
+ */
1618
+ async function awaitActiveServiceWorker(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT } = {}) {
1619
+ return await withDeadline(Date.now() + timeout, waitForActivated(registration), () => {
1620
+ const worker = registration.installing ?? registration.waiting ?? registration.active;
1621
+ return new ServiceWorkerControlError("activation-timeout", `ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ${timeout} ms` + (worker ? `; it is "${worker.state}"` : "") + ". Check the worker script for errors during install (DevTools → Application → Service Workers), or raise the `timeout` option.");
1622
+ });
1623
+ }
1624
+ /**
1625
+ * Resolves with the ServiceWorker that controls this page, once `registration`
1626
+ * has an activated worker and the page is controlled by it.
1627
+ *
1628
+ * A page can stay uncontrolled while its worker is active, and then no
1629
+ * `controllerchange` ever fires on its own:
1630
+ * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
1631
+ * worker's `clients.claim()` already ran when it activated;
1632
+ * - Firefox can leave a page loaded while the worker is running uncontrolled.
1633
+ *
1634
+ * So when the page is uncontrolled, this asks the active worker to claim it
1635
+ * again (a `CLAIM` channel call — this package's workers answer it) and waits
1636
+ * for `controllerchange`. Every wait is bounded by `timeout`. If control never
1637
+ * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
1638
+ * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
1639
+ * happened and what to do.
1640
+ */
1641
+ async function awaitServiceWorkerControl(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT, reloadIfUncontrolled = false, container = navigator.serviceWorker } = {}) {
1642
+ const deadline = Date.now() + timeout;
1643
+ const controlled = waitForController(container);
1644
+ try {
1645
+ const active = await awaitActiveServiceWorker(registration, { timeout });
1646
+ let controller = container.controller;
1647
+ if (!controller) controller = await withDeadline(deadline, (async () => {
1648
+ await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
1649
+ return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
1650
+ })(), () => null);
1651
+ if (controller) {
1652
+ forgetReload(registration);
1653
+ return controller;
1654
+ }
1655
+ if (reloadIfUncontrolled && markReload(registration)) {
1656
+ location.reload();
1657
+ return await new Promise(() => {});
1658
+ }
1659
+ forgetReload(registration);
1660
+ throw new ServiceWorkerControlError("uncontrolled", `This page is not controlled by its ServiceWorker ${scriptUrl(active)} (scope ${registration.scope}), although the worker is active, and the worker did not take control when asked (clients.claim()) within ${timeout} ms. This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses ServiceWorkers for that load, and in Firefox for some pages opened while the worker was already running. Requests from this page would not reach the worker. Reload the page normally, pass \`reloadIfUncontrolled: true\` to do that automatically, check that the page is inside the worker's scope, and that the worker answers the "${CLAIM_CALL}" request (this package's workers do).`);
1661
+ } finally {
1662
+ controlled.cancel();
1663
+ }
1664
+ }
1665
+ /**
1666
+ * ServiceWorker side: answers the page's `CLAIM` request with
1667
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
1668
+ * Returns a function that stops answering.
1669
+ */
1670
+ function handleClaimRequests(self) {
1671
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
1672
+ const claimed = self.clients.claim().then(() => true);
1673
+ event.waitUntil?.(claimed);
1674
+ return claimed;
1675
+ });
1676
+ }
1677
+ function waitForActivated(registration) {
1678
+ return new Promise((resolve) => {
1679
+ const watched = /* @__PURE__ */ new Set();
1680
+ const check = () => {
1681
+ const active = registration.active;
1682
+ if (active?.state === "activated") {
1683
+ registration.removeEventListener("updatefound", watch);
1684
+ for (const worker of watched) worker.removeEventListener("statechange", check);
1685
+ resolve(active);
1686
+ return;
1687
+ }
1688
+ watch();
1689
+ };
1690
+ function watch() {
1691
+ for (const worker of [
1692
+ registration.installing,
1693
+ registration.waiting,
1694
+ registration.active
1695
+ ]) {
1696
+ if (!worker || watched.has(worker)) continue;
1697
+ watched.add(worker);
1698
+ worker.addEventListener("statechange", check);
1699
+ }
1700
+ }
1701
+ registration.addEventListener("updatefound", watch);
1702
+ check();
1703
+ });
1704
+ }
1705
+ function waitForController(container) {
1706
+ let cancel = () => {};
1707
+ return {
1708
+ promise: new Promise((resolve) => {
1709
+ const onChange = () => {
1710
+ if (!container.controller) return;
1711
+ cancel();
1712
+ resolve(container.controller);
1713
+ };
1714
+ cancel = () => container.removeEventListener("controllerchange", onChange);
1715
+ container.addEventListener("controllerchange", onChange);
1716
+ }),
1717
+ cancel
1718
+ };
1719
+ }
1720
+ function delay(ms, value) {
1721
+ return new Promise((resolve) => setTimeout(() => resolve(value), ms));
1722
+ }
1723
+ function scriptUrl(worker) {
1724
+ return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
1725
+ }
1726
+ function reloadKey(registration) {
1727
+ return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
1728
+ }
1729
+ /** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
1730
+ function markReload(registration) {
1731
+ try {
1732
+ const key = reloadKey(registration);
1733
+ if (sessionStorage.getItem(key)) return false;
1734
+ sessionStorage.setItem(key, "1");
1735
+ return true;
1736
+ } catch {
1737
+ return false;
1738
+ }
1739
+ }
1740
+ function forgetReload(registration) {
1741
+ try {
1742
+ sessionStorage.removeItem(reloadKey(registration));
1743
+ } catch {}
1744
+ }
1745
+ //#endregion
1560
1746
  //#region src/sw/sw-dispatcher.ts
1561
1747
  /**
1562
1748
  * Page-side counterpart: registers a ServiceWorker and sets up a
@@ -1605,6 +1791,15 @@ var SwPortHandler = class {
1605
1791
  _getRegistrationInfo() {
1606
1792
  return { key: this.key };
1607
1793
  }
1794
+ /**
1795
+ * Registers the worker and waits until it is activated and controls this
1796
+ * page, then opens the port to it. The page must be controlled, because
1797
+ * only a controlled page's `fetch()` reaches the worker. When the page
1798
+ * loaded uncontrolled (a hard reload does that), the worker is asked to
1799
+ * claim it. Rejects with a `ServiceWorkerControlError` when that fails or
1800
+ * `timeout` passes (`reason` says which step); a later `start()` tries
1801
+ * again.
1802
+ */
1608
1803
  async start() {
1609
1804
  if (!this._registrationPromise) this._registrationPromise = (async () => {
1610
1805
  const [register, cleanup] = newRegistry();
@@ -1614,13 +1809,25 @@ var SwPortHandler = class {
1614
1809
  });
1615
1810
  const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl, { scope: this.scope });
1616
1811
  register(() => registration.unregister());
1617
- register(handleChannelCalls(navigator.serviceWorker, "UPDATE_COMMUNICATION_PORT", async (_event, _params, port) => {
1812
+ const stopListening = handleChannelCalls(navigator.serviceWorker, "UPDATE_COMMUNICATION_PORT", async (_event, _params, port) => {
1618
1813
  await this._setCommunicationPort(port);
1619
1814
  return this._getRegistrationInfo();
1620
- }));
1621
- this._serviceWorker = await getServiceWorkerController();
1622
- await awaitServiceWorkerActivation(this._serviceWorker);
1623
- await this._updateCommunicationChannel();
1815
+ });
1816
+ register(stopListening);
1817
+ const timeout = this.options.timeout ?? 3e4;
1818
+ const deadline = Date.now() + timeout;
1819
+ try {
1820
+ this._serviceWorker = await awaitServiceWorkerControl(registration, {
1821
+ timeout,
1822
+ reloadIfUncontrolled: this.options.reloadIfUncontrolled
1823
+ });
1824
+ await withDeadline(deadline, this._updateCommunicationChannel(), () => new ServiceWorkerControlError("unresponsive", `ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. Check that the worker script runs this package's same-origin dispatcher (it importScripts \`sw-worker.js\`, or calls \`startHttpDispatcher\`).`));
1825
+ } catch (error) {
1826
+ stopListening();
1827
+ this._cleanupRegistrations = void 0;
1828
+ this._registrationPromise = void 0;
1829
+ throw error;
1830
+ }
1624
1831
  })();
1625
1832
  return this._registrationPromise;
1626
1833
  }
@@ -1634,35 +1841,6 @@ var SwPortHandler = class {
1634
1841
  }
1635
1842
  }
1636
1843
  };
1637
- function getServiceWorkerController() {
1638
- return new Promise((resolve) => {
1639
- const container = navigator.serviceWorker;
1640
- if (container.controller) {
1641
- resolve(container.controller);
1642
- return;
1643
- }
1644
- const onChange = () => {
1645
- if (!container.controller) return;
1646
- resolve(container.controller);
1647
- container.removeEventListener("controllerchange", onChange);
1648
- };
1649
- container.addEventListener("controllerchange", onChange);
1650
- });
1651
- }
1652
- function awaitServiceWorkerActivation(worker) {
1653
- return new Promise((resolve) => {
1654
- if (worker.state === "activated") {
1655
- resolve();
1656
- return;
1657
- }
1658
- const onStateChange = () => {
1659
- if (worker.state !== "activated") return;
1660
- worker.removeEventListener("statechange", onStateChange);
1661
- resolve();
1662
- };
1663
- worker.addEventListener("statechange", onStateChange);
1664
- });
1665
- }
1666
1844
  /**
1667
1845
  * ServiceWorker-side counterpart: maintains an index of connected clients keyed by
1668
1846
  * clientId, persisted in IndexedDB so it can reclaim ports across SW restarts.
@@ -1710,7 +1888,8 @@ var SwPortDispatcher = class {
1710
1888
  return this.claimedKeys.has(key);
1711
1889
  }
1712
1890
  start() {
1713
- this._cleanup = handleChannelCalls(this.self, "UPDATE_COMMUNICATION_PORT", async (event, channelInfo, port) => {
1891
+ const stopClaims = handleClaimRequests(this.self);
1892
+ const stopPortUpdates = handleChannelCalls(this.self, "UPDATE_COMMUNICATION_PORT", async (event, channelInfo, port) => {
1714
1893
  const clientId = event.source?.id;
1715
1894
  this.log("[UPDATE_COMMUNICATION_PORT]", clientId, channelInfo);
1716
1895
  await this._updateChannelInfo({
@@ -1720,6 +1899,10 @@ var SwPortDispatcher = class {
1720
1899
  });
1721
1900
  return { ...channelInfo };
1722
1901
  });
1902
+ this._cleanup = () => {
1903
+ stopClaims();
1904
+ stopPortUpdates();
1905
+ };
1723
1906
  this.self.addEventListener("install", (event) => {
1724
1907
  this.log("Skip waiting on install.", event);
1725
1908
  this.self.skipWaiting();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-browser",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ServiceWorker-based HTTP server for browsers, with relay and same-origin dispatch modes",
@@ -29,6 +29,10 @@
29
29
  "./relay-sw": {
30
30
  "default": "./dist/relay-sw.js"
31
31
  },
32
+ "./relay-worker": {
33
+ "types": "./dist/relay-worker.d.ts",
34
+ "import": "./dist/relay-worker.js"
35
+ },
32
36
  "./sw-worker": {
33
37
  "default": "./dist/sw-worker.js"
34
38
  }
@@ -47,11 +51,14 @@
47
51
  "@statewalker/webrun-streams": "0.2.0"
48
52
  },
49
53
  "devDependencies": {
54
+ "@biomejs/biome": "^2.5.8",
50
55
  "@types/node": "^26.2.0",
51
56
  "http-server": "^14.1.1",
57
+ "playwright": "^1.62.1",
52
58
  "rimraf": "^6.1.3",
53
59
  "rolldown": "^1.2.4",
54
60
  "typescript": "^7.0.2",
61
+ "vite": "^8.2.1",
55
62
  "vitest": "^4.1.10"
56
63
  },
57
64
  "sideEffects": false,
@@ -61,6 +68,7 @@
61
68
  "scripts": {
62
69
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
63
70
  "test": "vitest run",
71
+ "test:browser": "pnpm run build && vitest run --config vitest.browser.config.ts",
64
72
  "lint": "biome check src tests",
65
73
  "serve": "http-server -p 5173 -c-1 -s .",
66
74
  "example:same-origin": "pnpm run build && http-server -p 5173 -c-1 -o /public/index.html .",
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Settles like `promise` if it settles before `deadline` (a `Date.now()`
3
+ * value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
4
+ * other value resolves. Internal; not re-exported.
5
+ */
6
+ export function withDeadline<T, F>(
7
+ deadline: number,
8
+ promise: Promise<T>,
9
+ onTimeout: () => F,
10
+ ): Promise<T | (F extends Error ? never : F)> {
11
+ return new Promise((resolve, reject) => {
12
+ const timer = setTimeout(
13
+ () => {
14
+ const outcome = onTimeout();
15
+ if (outcome instanceof Error) reject(outcome);
16
+ else resolve(outcome as F extends Error ? never : F);
17
+ },
18
+ Math.max(0, deadline - Date.now()),
19
+ );
20
+ promise.then(
21
+ (value) => {
22
+ clearTimeout(timer);
23
+ resolve(value);
24
+ },
25
+ (error) => {
26
+ clearTimeout(timer);
27
+ reject(error);
28
+ },
29
+ );
30
+ });
31
+ }
package/src/core/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from "./data-calls.js";
7
7
  export * from "./data-channels.js";
8
8
  export * from "./message-target.js";
9
9
  export * from "./registry.js";
10
+ export * from "./service-worker-control.js";