@statewalker/webrun-http-browser 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2026 statewalker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -48,7 +48,7 @@ npm install @statewalker/webrun-http-browser
48
48
  | Subpath | Purpose |
49
49
  | --- | --- |
50
50
  | `@statewalker/webrun-http-browser` | Page-side relay API: `newRemoteRelayChannel`, `initHttpService`, `callHttpService`, `splitServiceUrl`, `initServiceWorker`, `newServiceWorkerPort`, `getRelayWindowMessageHandler`; the MessagePort call primitives (`callChannel`, `handleChannelCalls`, `newInvokationChannel`, `sendStream`, `handleStreams`, `newRegistry`); plus everything re-exported from `@statewalker/webrun-http-streams` (`HttpError`, the client/server stubs), `@statewalker/webrun-streams` (stream and error helpers) and the `MessageTarget` family from `@statewalker/webrun-rpc` |
51
- | `@statewalker/webrun-http-browser/sw` | Same-origin adapter classes: `SwHttpAdapter` (page), `SwHttpDispatcher` (SW), `startHttpDispatcher` bootstrap |
51
+ | `@statewalker/webrun-http-browser/sw` | Same-origin adapter classes: `SwHttpAdapter` (page), `SwHttpDispatcher` (SW), `startHttpDispatcher` bootstrap; `start()` options `timeout` and `reloadIfUncontrolled` |
52
52
  | `@statewalker/webrun-http-browser/relay-sw` | IIFE bundle of the relay SW runtime — load via `importScripts` from a loader script in your relay origin |
53
53
  | `@statewalker/webrun-http-browser/sw-worker` | IIFE bundle of the same-origin SW runtime — ditto, for same-origin apps |
54
54
 
@@ -124,6 +124,45 @@ const { baseUrl } = await adapter.register(`${KEY}/api/`, async (request) => {
124
124
  // fetch(`${baseUrl}anything`) is intercepted by the SW.
125
125
  ```
126
126
 
127
+ `start()` resolves once the worker is activated **and controls the page** —
128
+ only a controlled page's `fetch()` reaches the worker. Two options bound it:
129
+
130
+ | Option | Default | Meaning |
131
+ | --- | --- | --- |
132
+ | `timeout` | `30_000` | Upper bound, in ms, for the wait for the worker to activate, take control and answer the adapter's handshake. Past it `start()` rejects with a `ServiceWorkerControlError` (see `reason`) instead of waiting forever. |
133
+ | `reloadIfUncontrolled` | `false` | If the page is still uncontrolled once the worker is active, reload it once instead of rejecting (see below). |
134
+
135
+ #### When the page is not controlled
136
+
137
+ A page can load **uncontrolled although its worker is active**: a hard reload
138
+ (Ctrl+Shift+R / Cmd+Shift+R) bypasses ServiceWorkers for that load, and the
139
+ worker's `clients.claim()` already ran when it activated, so nothing ever
140
+ hands the page to it. (Firefox has also been seen leaving a second page of a
141
+ running worker uncontrolled.) `start()` then asks the worker to claim the page
142
+ again — a `CLAIM` call this package's workers answer with `clients.claim()` —
143
+ and waits for `controllerchange`. That takes the page over in Chromium and
144
+ Firefox, both verified by the browser tests.
145
+
146
+ If it still is not controlled (a worker that does not answer `CLAIM`, a page
147
+ outside the worker's scope), `start()` rejects with a
148
+ `ServiceWorkerControlError` whose `reason` is `"uncontrolled"` and whose message
149
+ says what happened and what to do. With `reloadIfUncontrolled: true` it reloads
150
+ the page instead — a normal reload is a controlled navigation — at most once:
151
+ a `sessionStorage` marker makes a second uncontrolled load reject rather than
152
+ loop. After a failure, calling `start()` again retries.
153
+
154
+ ```ts
155
+ try {
156
+ await adapter.start();
157
+ } catch (error) {
158
+ if ((error as Error).name === "ServiceWorkerControlError") showReloadPrompt();
159
+ else throw error;
160
+ }
161
+ ```
162
+
163
+ Check `name` or `reason`, not `instanceof`: each bundle of this package carries
164
+ its own copy of the class.
165
+
127
166
  The SW script itself ships as a pre-built IIFE bundle. Put a tiny loader
128
167
  next to your app pages so the SW's default scope covers them:
129
168
 
@@ -250,9 +289,14 @@ imports keep working after those extractions. Its own surface is below.
250
289
 
251
290
  | Export | Kind | Purpose |
252
291
  | --- | --- | --- |
253
- | `initServiceWorker(opts)` | function | Registers a SW and resolves once it is activated **and controlling the page**. |
254
- | `InitServiceWorkerOptions` | interface | `{ swUrl, scopeUrl?, type? }`. |
255
- | `newServiceWorkerPort()` | function | A `MessagePort` that transparently bridges to the controlling SW. |
292
+ | `initServiceWorker(opts)` | function | Registers a SW and resolves with it once it is activated: the controller, or the registration's active worker when the page is not controlled (the relay only needs to message it). Rejects with a `ServiceWorkerControlError` past `timeout`. |
293
+ | `InitServiceWorkerOptions` | interface | `{ swUrl, scopeUrl?, type?, timeout? }`. |
294
+ | `newServiceWorkerPort(registration?)` | function | A `MessagePort` that transparently bridges to the controlling SW, or to `registration.active` while the page is not controlled. |
295
+ | `awaitActiveServiceWorker(registration, opts?)` | function | Resolves with the registration's worker once `activated`; rejects (`reason: "activation-timeout"`) past `timeout`. |
296
+ | `awaitServiceWorkerControl(registration, opts?)` | function | Resolves with the controller once the page is controlled, asking the worker to claim an uncontrolled page; bounded by `timeout`, optional `reloadIfUncontrolled`. What `SwHttpAdapter.start()` uses. |
297
+ | `handleClaimRequests(self)` | function | Worker side: answers the page's `CLAIM` call with `clients.claim()`. Both of this package's workers install it; use it in a worker of your own. |
298
+ | `ServiceWorkerControlError` | class | `name: "ServiceWorkerControlError"`, `reason`: `"activation-timeout"` (worker did not activate in time), `"uncontrolled"` (active, but the page is not controlled), `"unresponsive"` (controls the page, did not answer `SwHttpAdapter`'s handshake). |
299
+ | `DEFAULT_SERVICE_WORKER_TIMEOUT` / `CLAIM_CALL` | const | `30_000` ms / `"CLAIM"`. |
256
300
 
257
301
  ### Connection registry
258
302
 
@@ -310,10 +354,14 @@ src/
310
354
  │ ├── data-calls.ts │ Transport primitives over a
311
355
  │ ├── data-channels.ts │ `MessageTarget`: one-shot
312
356
  │ ├── message-target.ts │ `callChannel` / `handleChannelCalls`,
313
- │ └── registry.ts │ the request/response
357
+ │ ├── registry.ts │ the request/response
314
358
  │ │ `newInvokationChannel`, streaming
315
- │ │ `sendStream` / `handleStreams` with
359
+ │ └── service-worker-control.ts │ `sendStream` / `handleStreams` with
316
360
  │ │ backpressure, and `newRegistry`.
361
+ │ │ Bounded waits for a worker to
362
+ │ │ activate / take control, and the
363
+ │ │ `CLAIM` request that takes over an
364
+ │ │ uncontrolled page.
317
365
  │ │ Also re-exports
318
366
  │ │ `@statewalker/webrun-streams`.
319
367
  │ ┘
@@ -378,6 +426,17 @@ src/
378
426
  backpressure — each `next(value)` returns a `Promise<boolean>` that resolves
379
427
  once the consumer has dequeued — and drains in-flight producers on consumer
380
428
  exit.
429
+ - **No literal `new URL("…", import.meta.url)` in page-side code**.
430
+ Bundlers (Vite among them) turn that pattern into an emitted asset at build
431
+ time, before tree-shaking; the relay defaults resolved `"../"` that way,
432
+ which is the package itself, so every Vite consumer shipped a dead copy of
433
+ `dist/index.js`. They resolve against a `moduleUrl` variable instead, and
434
+ `tests/dist/vite-consumer.dist.ts` builds a Vite app to prove nothing is
435
+ emitted.
436
+ - **Uncontrolled pages are handled per mode**. Same-origin mode needs
437
+ control — an uncontrolled page's `fetch()` never reaches the worker — so it
438
+ asks the worker to claim the page. Relay mode needs only a worker to message,
439
+ so an uncontrolled relay page bridges to `registration.active`.
381
440
  - **SW client registry is IndexedDB-persisted**. Both `SwPortDispatcher`
382
441
  (same-origin) and `relay/index-sw.ts` keep their client-lookup tables in
383
442
  IndexedDB so a SW wake-up after idle doesn't lose its bindings.
@@ -398,6 +457,11 @@ src/
398
457
  only controls pages and fetches under `/public/`. If you need a broader
399
458
  scope, the SW script must be served with the
400
459
  `Service-Worker-Allowed` HTTP header, *or* live higher in the origin.
460
+ - **A hard reload loads the page without its worker.** Handled — see
461
+ [When the page is not controlled](#when-the-page-is-not-controlled) — as long
462
+ as the worker answers `CLAIM`. A worker script of your own that
463
+ `importScripts` this package's `sw-worker.js` or `relay-sw.js` does; one
464
+ that does not should call `handleClaimRequests(self)`.
401
465
  - **`http://localhost` or HTTPS only.** Browsers refuse to register SWs
402
466
  on other `http://` origins.
403
467
  - **Relay mode needs an iframe-capable sandbox.** Pages with strict CSP
@@ -421,7 +485,16 @@ Runtime:
421
485
 
422
486
  Dev: TypeScript, vitest, rolldown, rimraf, `http-server` (for the
423
487
  `example:*` scripts), `@types/node` (catalog versions from the monorepo
424
- root).
488
+ root), `playwright` and `vite` (for `test:browser`).
489
+
490
+ ## Tests
491
+
492
+ - `pnpm test` — unit tests under Node, against the source.
493
+ - `pnpm test:browser` — builds, then runs `tests/browser/` in real Chromium and
494
+ Firefox through Playwright against the built bundles (first visit, normal
495
+ reload, hard reload, second tab, the relay page, and the timeout and
496
+ uncontrolled errors), plus `tests/dist/`, which builds a Vite consumer of
497
+ the bundles. Needs the browsers: `npx playwright install chromium firefox`.
425
498
 
426
499
  ## License
427
500
 
@@ -0,0 +1,7 @@
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 declare function withDeadline<T, F>(deadline: number, promise: Promise<T>, onTimeout: () => F): Promise<T | (F extends Error ? never : F)>;
7
+ //# sourceMappingURL=deadline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deadline.d.ts","sourceRoot":"","sources":["../../src/core/deadline.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,CAAC,GACjB,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAqB5C"}
@@ -3,4 +3,5 @@ export * from "./data-calls.js";
3
3
  export * from "./data-channels.js";
4
4
  export * from "./message-target.js";
5
5
  export * from "./registry.js";
6
+ export * from "./service-worker-control.js";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAGA,cAAc,6BAA6B,CAAC;AAE5C,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAGA,cAAc,6BAA6B,CAAC;AAE5C,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,eAAe,CAAC;AAC9B,cAAc,6BAA6B,CAAC"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How long, by default, the page waits for its ServiceWorker to activate and
3
+ * to take control before giving up. Generous: a first install downloads and
4
+ * evaluates the worker script, which on a slow link takes seconds.
5
+ */
6
+ export declare const DEFAULT_SERVICE_WORKER_TIMEOUT = 30000;
7
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
8
+ export declare const CLAIM_CALL = "CLAIM";
9
+ /**
10
+ * - `activation-timeout`: the worker did not activate in time.
11
+ * - `uncontrolled`: the worker is active but the page is not controlled by it.
12
+ * - `unresponsive`: the page is controlled, but the worker did not answer the
13
+ * adapter's handshake in time.
14
+ */
15
+ export type ServiceWorkerControlFailure = "activation-timeout" | "uncontrolled" | "unresponsive";
16
+ /**
17
+ * Why a page could not get a working ServiceWorker. `reason` says which wait
18
+ * failed; check it (or `name`) rather than `instanceof`, because each of this
19
+ * package's bundles carries its own copy of this class.
20
+ */
21
+ export declare class ServiceWorkerControlError extends Error {
22
+ readonly reason: ServiceWorkerControlFailure;
23
+ constructor(reason: ServiceWorkerControlFailure, message: string);
24
+ }
25
+ export interface AwaitServiceWorkerOptions {
26
+ /** Upper bound for the whole wait, in ms. Default `DEFAULT_SERVICE_WORKER_TIMEOUT`. */
27
+ timeout?: number;
28
+ }
29
+ export interface AwaitServiceWorkerControlOptions extends AwaitServiceWorkerOptions {
30
+ /**
31
+ * When the page is still uncontrolled after asking the worker to claim it,
32
+ * reload the page once instead of rejecting. A normal reload is a
33
+ * navigation, and navigations are controlled. Guarded by `sessionStorage`
34
+ * so it never loops: if the reloaded page is uncontrolled too, it rejects.
35
+ * Default `false`.
36
+ */
37
+ reloadIfUncontrolled?: boolean;
38
+ /** For tests. Default `navigator.serviceWorker`. */
39
+ container?: ServiceWorkerContainer;
40
+ }
41
+ /**
42
+ * Resolves with the registration's worker once it is `activated`. Rejects
43
+ * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
44
+ * takes longer than `timeout` — an install that throws or never finishes
45
+ * would otherwise leave the caller waiting forever.
46
+ */
47
+ export declare function awaitActiveServiceWorker(registration: ServiceWorkerRegistration, { timeout }?: AwaitServiceWorkerOptions): Promise<ServiceWorker>;
48
+ /**
49
+ * Resolves with the ServiceWorker that controls this page, once `registration`
50
+ * has an activated worker and the page is controlled by it.
51
+ *
52
+ * A page can stay uncontrolled while its worker is active, and then no
53
+ * `controllerchange` ever fires on its own:
54
+ * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
55
+ * worker's `clients.claim()` already ran when it activated;
56
+ * - Firefox can leave a page loaded while the worker is running uncontrolled.
57
+ *
58
+ * So when the page is uncontrolled, this asks the active worker to claim it
59
+ * again (a `CLAIM` channel call — this package's workers answer it) and waits
60
+ * for `controllerchange`. Every wait is bounded by `timeout`. If control never
61
+ * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
62
+ * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
63
+ * happened and what to do.
64
+ */
65
+ export declare function awaitServiceWorkerControl(registration: ServiceWorkerRegistration, { timeout, reloadIfUncontrolled, container, }?: AwaitServiceWorkerControlOptions): Promise<ServiceWorker>;
66
+ /**
67
+ * ServiceWorker side: answers the page's `CLAIM` request with
68
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
69
+ * Returns a function that stops answering.
70
+ */
71
+ export declare function handleClaimRequests(self: ServiceWorkerGlobalScope): () => void;
72
+ //# sourceMappingURL=service-worker-control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-worker-control.d.ts","sourceRoot":"","sources":["../../src/core/service-worker-control.ts"],"names":[],"mappings":"AAKA;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,QAAS,CAAC;AAUrD,kFAAkF;AAClF,eAAO,MAAM,UAAU,UAAU,CAAC;AAElC;;;;;GAKG;AACH,MAAM,MAAM,2BAA2B,GAAG,oBAAoB,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;;;GAIG;AACH,qBAAa,yBAA0B,SAAQ,KAAK;IAClD,QAAQ,CAAC,MAAM,EAAE,2BAA2B,CAAC;IAC7C,YAAY,MAAM,EAAE,2BAA2B,EAAE,OAAO,EAAE,MAAM,EAI/D;CACF;AAED,MAAM,WAAW,yBAAyB;IACxC,uFAAuF;IACvF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gCAAiC,SAAQ,yBAAyB;IACjF;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,oDAAoD;IACpD,SAAS,CAAC,EAAE,sBAAsB,CAAC;CACpC;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAC5C,YAAY,EAAE,yBAAyB,EACvC,EAAE,OAAwC,EAAE,GAAE,yBAA8B,GAC3E,OAAO,CAAC,aAAa,CAAC,CAaxB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,yBAAyB,EACvC,EACE,OAAwC,EACxC,oBAA4B,EAC5B,SAAmC,GACpC,GAAE,gCAAqC,GACvC,OAAO,CAAC,aAAa,CAAC,CA6CxB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,wBAAwB,GAAG,MAAM,IAAI,CAM9E"}
package/dist/index.js CHANGED
@@ -1125,6 +1125,192 @@ function newRegistry(onError = console.error) {
1125
1125
  });
1126
1126
  }
1127
1127
  //#endregion
1128
+ //#region src/core/deadline.ts
1129
+ /**
1130
+ * Settles like `promise` if it settles before `deadline` (a `Date.now()`
1131
+ * value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
1132
+ * other value resolves. Internal; not re-exported.
1133
+ */
1134
+ function withDeadline(deadline, promise, onTimeout) {
1135
+ return new Promise((resolve, reject) => {
1136
+ const timer = setTimeout(() => {
1137
+ const outcome = onTimeout();
1138
+ if (outcome instanceof Error) reject(outcome);
1139
+ else resolve(outcome);
1140
+ }, Math.max(0, deadline - Date.now()));
1141
+ promise.then((value) => {
1142
+ clearTimeout(timer);
1143
+ resolve(value);
1144
+ }, (error) => {
1145
+ clearTimeout(timer);
1146
+ reject(error);
1147
+ });
1148
+ });
1149
+ }
1150
+ //#endregion
1151
+ //#region src/core/service-worker-control.ts
1152
+ /**
1153
+ * How long, by default, the page waits for its ServiceWorker to activate and
1154
+ * to take control before giving up. Generous: a first install downloads and
1155
+ * evaluates the worker script, which on a slow link takes seconds.
1156
+ */
1157
+ const DEFAULT_SERVICE_WORKER_TIMEOUT = 3e4;
1158
+ /**
1159
+ * How long the page waits for `controllerchange` once the worker has
1160
+ * answered the `CLAIM` request. `clients.claim()` resolves only after the
1161
+ * browser has queued that event, so this is a grace period for delivery, not
1162
+ * a second budget.
1163
+ */
1164
+ const CLAIM_GRACE_MS = 1e3;
1165
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
1166
+ const CLAIM_CALL = "CLAIM";
1167
+ /**
1168
+ * Why a page could not get a working ServiceWorker. `reason` says which wait
1169
+ * failed; check it (or `name`) rather than `instanceof`, because each of this
1170
+ * package's bundles carries its own copy of this class.
1171
+ */
1172
+ var ServiceWorkerControlError = class extends Error {
1173
+ reason;
1174
+ constructor(reason, message) {
1175
+ super(message);
1176
+ this.name = "ServiceWorkerControlError";
1177
+ this.reason = reason;
1178
+ }
1179
+ };
1180
+ /**
1181
+ * Resolves with the registration's worker once it is `activated`. Rejects
1182
+ * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
1183
+ * takes longer than `timeout` — an install that throws or never finishes
1184
+ * would otherwise leave the caller waiting forever.
1185
+ */
1186
+ async function awaitActiveServiceWorker(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT } = {}) {
1187
+ return await withDeadline(Date.now() + timeout, waitForActivated(registration), () => {
1188
+ const worker = registration.installing ?? registration.waiting ?? registration.active;
1189
+ 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.");
1190
+ });
1191
+ }
1192
+ /**
1193
+ * Resolves with the ServiceWorker that controls this page, once `registration`
1194
+ * has an activated worker and the page is controlled by it.
1195
+ *
1196
+ * A page can stay uncontrolled while its worker is active, and then no
1197
+ * `controllerchange` ever fires on its own:
1198
+ * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
1199
+ * worker's `clients.claim()` already ran when it activated;
1200
+ * - Firefox can leave a page loaded while the worker is running uncontrolled.
1201
+ *
1202
+ * So when the page is uncontrolled, this asks the active worker to claim it
1203
+ * again (a `CLAIM` channel call — this package's workers answer it) and waits
1204
+ * for `controllerchange`. Every wait is bounded by `timeout`. If control never
1205
+ * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
1206
+ * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
1207
+ * happened and what to do.
1208
+ */
1209
+ async function awaitServiceWorkerControl(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT, reloadIfUncontrolled = false, container = navigator.serviceWorker } = {}) {
1210
+ const deadline = Date.now() + timeout;
1211
+ const controlled = waitForController(container);
1212
+ try {
1213
+ const active = await awaitActiveServiceWorker(registration, { timeout });
1214
+ let controller = container.controller;
1215
+ if (!controller) controller = await withDeadline(deadline, (async () => {
1216
+ await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
1217
+ return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
1218
+ })(), () => null);
1219
+ if (controller) {
1220
+ forgetReload(registration);
1221
+ return controller;
1222
+ }
1223
+ if (reloadIfUncontrolled && markReload(registration)) {
1224
+ location.reload();
1225
+ return await new Promise(() => {});
1226
+ }
1227
+ forgetReload(registration);
1228
+ 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).`);
1229
+ } finally {
1230
+ controlled.cancel();
1231
+ }
1232
+ }
1233
+ /**
1234
+ * ServiceWorker side: answers the page's `CLAIM` request with
1235
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
1236
+ * Returns a function that stops answering.
1237
+ */
1238
+ function handleClaimRequests(self) {
1239
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
1240
+ const claimed = self.clients.claim().then(() => true);
1241
+ event.waitUntil?.(claimed);
1242
+ return claimed;
1243
+ });
1244
+ }
1245
+ function waitForActivated(registration) {
1246
+ return new Promise((resolve) => {
1247
+ const watched = /* @__PURE__ */ new Set();
1248
+ const check = () => {
1249
+ const active = registration.active;
1250
+ if (active?.state === "activated") {
1251
+ registration.removeEventListener("updatefound", watch);
1252
+ for (const worker of watched) worker.removeEventListener("statechange", check);
1253
+ resolve(active);
1254
+ return;
1255
+ }
1256
+ watch();
1257
+ };
1258
+ function watch() {
1259
+ for (const worker of [
1260
+ registration.installing,
1261
+ registration.waiting,
1262
+ registration.active
1263
+ ]) {
1264
+ if (!worker || watched.has(worker)) continue;
1265
+ watched.add(worker);
1266
+ worker.addEventListener("statechange", check);
1267
+ }
1268
+ }
1269
+ registration.addEventListener("updatefound", watch);
1270
+ check();
1271
+ });
1272
+ }
1273
+ function waitForController(container) {
1274
+ let cancel = () => {};
1275
+ return {
1276
+ promise: new Promise((resolve) => {
1277
+ const onChange = () => {
1278
+ if (!container.controller) return;
1279
+ cancel();
1280
+ resolve(container.controller);
1281
+ };
1282
+ cancel = () => container.removeEventListener("controllerchange", onChange);
1283
+ container.addEventListener("controllerchange", onChange);
1284
+ }),
1285
+ cancel
1286
+ };
1287
+ }
1288
+ function delay(ms, value) {
1289
+ return new Promise((resolve) => setTimeout(() => resolve(value), ms));
1290
+ }
1291
+ function scriptUrl(worker) {
1292
+ return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
1293
+ }
1294
+ function reloadKey(registration) {
1295
+ return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
1296
+ }
1297
+ /** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
1298
+ function markReload(registration) {
1299
+ try {
1300
+ const key = reloadKey(registration);
1301
+ if (sessionStorage.getItem(key)) return false;
1302
+ sessionStorage.setItem(key, "1");
1303
+ return true;
1304
+ } catch {
1305
+ return false;
1306
+ }
1307
+ }
1308
+ function forgetReload(registration) {
1309
+ try {
1310
+ sessionStorage.removeItem(reloadKey(registration));
1311
+ } catch {}
1312
+ }
1313
+ //#endregion
1128
1314
  //#region ../webrun-http-streams/dist/index.js
1129
1315
  const CR = 13;
1130
1316
  const LF = 10;
@@ -2498,13 +2684,25 @@ function splitServiceUrl(url, separator = "~") {
2498
2684
  //#endregion
2499
2685
  //#region src/relay/index.ts
2500
2686
  /**
2687
+ * The URL of this module, kept in a variable on purpose. Bundlers (Vite
2688
+ * among them) rewrite every literal `new URL("<path>", import.meta.url)` into
2689
+ * an emitted asset at build time, before tree-shaking — so the defaults below
2690
+ * made every Vite consumer of this entry emit a dead copy of the package's
2691
+ * own `dist/index.js` (`"../"` resolves to the package, hence to its `main`).
2692
+ * Resolving against a variable is the same URL at run time and invisible to
2693
+ * that transform.
2694
+ */
2695
+ const moduleUrl = import.meta.url;
2696
+ /**
2501
2697
  * Returns a MessagePort that transparently bridges messages to/from the
2502
- * ServiceWorker controlling this page.
2698
+ * page's ServiceWorker: the one controlling the page, or — when the page is
2699
+ * not controlled (a hard reload, or a page Firefox left uncontrolled) — the
2700
+ * active worker of `registration`, which answers messages all the same.
2503
2701
  */
2504
- function newServiceWorkerPort() {
2702
+ function newServiceWorkerPort(registration) {
2505
2703
  const channel = new MessageChannel();
2506
2704
  channel.port1.onmessage = (event) => {
2507
- navigator.serviceWorker.controller?.postMessage(event.data, [...event.ports]);
2705
+ (navigator.serviceWorker.controller ?? registration?.active)?.postMessage(event.data, [...event.ports]);
2508
2706
  };
2509
2707
  navigator.serviceWorker.addEventListener("message", (event) => {
2510
2708
  channel.port1.postMessage(event.data, [...event.ports]);
@@ -2512,45 +2710,26 @@ function newServiceWorkerPort() {
2512
2710
  return channel.port2;
2513
2711
  }
2514
2712
  /**
2515
- * Registers a ServiceWorker and resolves once it's activated and controlling the page.
2713
+ * Registers a ServiceWorker and resolves with it once it is activated: the
2714
+ * worker controlling the page, or the registration's active worker when the
2715
+ * page is not controlled. Messaging works either way, which is all the relay
2716
+ * needs; nothing here waits for control, because an uncontrolled page (hard
2717
+ * reload; Firefox) may never get it. Rejects with a
2718
+ * `ServiceWorkerControlError` if activation takes longer than `timeout`.
2516
2719
  */
2517
- async function initServiceWorker({ swUrl, scopeUrl, type }) {
2518
- await navigator.serviceWorker.register(swUrl, {
2720
+ async function initServiceWorker(options) {
2721
+ return (await registerServiceWorker(options)).worker;
2722
+ }
2723
+ async function registerServiceWorker({ swUrl, scopeUrl, type, timeout = DEFAULT_SERVICE_WORKER_TIMEOUT }) {
2724
+ const registration = await navigator.serviceWorker.register(swUrl, {
2519
2725
  type,
2520
2726
  scope: scopeUrl
2521
2727
  });
2522
- const worker = await getServiceWorkerController();
2523
- await awaitServiceWorkerActivation(worker);
2524
- return worker;
2525
- }
2526
- function getServiceWorkerController() {
2527
- return new Promise((resolve) => {
2528
- const container = navigator.serviceWorker;
2529
- if (container.controller) {
2530
- resolve(container.controller);
2531
- return;
2532
- }
2533
- const onChange = () => {
2534
- if (!container.controller) return;
2535
- resolve(container.controller);
2536
- container.removeEventListener("controllerchange", onChange);
2537
- };
2538
- container.addEventListener("controllerchange", onChange);
2539
- });
2540
- }
2541
- function awaitServiceWorkerActivation(worker) {
2542
- return new Promise((resolve) => {
2543
- if (worker.state === "activated") {
2544
- resolve();
2545
- return;
2546
- }
2547
- const onStateChange = () => {
2548
- if (worker.state !== "activated") return;
2549
- worker.removeEventListener("statechange", onStateChange);
2550
- resolve();
2551
- };
2552
- worker.addEventListener("statechange", onStateChange);
2553
- });
2728
+ const active = await awaitActiveServiceWorker(registration, { timeout });
2729
+ return {
2730
+ registration,
2731
+ worker: navigator.serviceWorker.controller ?? active
2732
+ };
2554
2733
  }
2555
2734
  /**
2556
2735
  * Registers `handler` as the server for the given service `key` on the relay.
@@ -2581,9 +2760,11 @@ async function callHttpService(request, { key, port }) {
2581
2760
  /**
2582
2761
  * Returns a `window.onmessage` handler for use inside the relay iframe:
2583
2762
  * it accepts a CONNECT message, starts the relay ServiceWorker, and bridges
2584
- * the parent's MessagePort with the SW.
2763
+ * the parent's MessagePort with the SW. If the worker cannot be started, every
2764
+ * call the parent makes on that port is answered with the error, so the
2765
+ * parent's `initHttpService` / `callHttpService` reject instead of waiting.
2585
2766
  */
2586
- function getRelayWindowMessageHandler({ swUrl = `${new URL("./index-sw.js", import.meta.url)}`, scopeUrl = `${new URL("../", import.meta.url)}` } = {}) {
2767
+ function getRelayWindowMessageHandler({ swUrl = `${new URL("./index-sw.js", moduleUrl)}`, scopeUrl = `${new URL("../", moduleUrl)}`, timeout } = {}) {
2587
2768
  let externalPort;
2588
2769
  return async (ev) => {
2589
2770
  if (ev.data?.type !== "CONNECT") return;
@@ -2594,11 +2775,19 @@ function getRelayWindowMessageHandler({ swUrl = `${new URL("./index-sw.js", impo
2594
2775
  return;
2595
2776
  }
2596
2777
  externalPort = newExternalPort;
2597
- await initServiceWorker({
2598
- swUrl,
2599
- scopeUrl
2600
- });
2601
- const serviceWorkerPort = newServiceWorkerPort();
2778
+ let registration;
2779
+ try {
2780
+ ({registration} = await registerServiceWorker({
2781
+ swUrl,
2782
+ scopeUrl,
2783
+ timeout
2784
+ }));
2785
+ } catch (error) {
2786
+ const serialized = serializeError(error);
2787
+ externalPort.onmessage = (event) => event.ports[0]?.postMessage({ error: serialized });
2788
+ throw error;
2789
+ }
2790
+ const serviceWorkerPort = newServiceWorkerPort(registration);
2602
2791
  serviceWorkerPort.onmessage = (event) => {
2603
2792
  externalPort?.postMessage(event.data, [...event.ports]);
2604
2793
  };
@@ -2611,7 +2800,7 @@ function getRelayWindowMessageHandler({ swUrl = `${new URL("./index-sw.js", impo
2611
2800
  * Embeds a hidden relay iframe, establishes a MessageChannel with it, and
2612
2801
  * returns the port to be used with `initHttpService` / `callHttpService`.
2613
2802
  */
2614
- async function newRemoteRelayChannel({ baseUrl = new URL("../public-relay/", import.meta.url), url = new URL("relay.html", baseUrl), container = document.body } = {}) {
2803
+ async function newRemoteRelayChannel({ baseUrl = new URL("../public-relay/", moduleUrl), url = new URL("relay.html", baseUrl), container = document.body } = {}) {
2615
2804
  const messageChannel = new MessageChannel();
2616
2805
  const { iframe, promise } = newIFrame(url);
2617
2806
  Object.assign(iframe.style, {
@@ -2680,4 +2869,4 @@ async function registerConnectionsHandler({ key, handler, communicationPort }) {
2680
2869
  return cleanup;
2681
2870
  }
2682
2871
  //#endregion
2683
- export { DuplexSiteBuilder, HttpError, HttpParseError, PEER_ERROR_HEADER, TransportClosedError, callChannel, callHttpService, collect, collectBytes, collectString, decodeJsonl, decodeMessage, decodeText, defaultCodec, deserializeError, emulateMux, encodeJsonl, encodeMessage, encodeText, fetchOverDuplex, fromReadableStream, getRelayWindowMessageHandler, handleChannelCalls, handleHttpRequests, handleStreams, httpCodec, httpFetch, httpServe, initHttpService, initServiceWorker, initializeConnection, joinLines, jsonEnvelopeCodec, map, newAsyncGenerator, newCreditGrantor, newCreditLedger, newHttpClientStub, newHttpCodec, newHttpServerStub, newInvokationChannel, newRegistry, newRemoteRelayChannel, newServiceWorkerPort, newSniffingCodec, normalizeToUint8Array, recieveIterator, registerConnectionsHandler, sendHttpRequest, sendIterator, sendStream, serializeError, serveFetchOverDuplex, splitLines, splitServiceUrl, toChunks, toReadableStream };
2872
+ export { CLAIM_CALL, DEFAULT_SERVICE_WORKER_TIMEOUT, DuplexSiteBuilder, HttpError, HttpParseError, PEER_ERROR_HEADER, ServiceWorkerControlError, TransportClosedError, awaitActiveServiceWorker, awaitServiceWorkerControl, callChannel, callHttpService, collect, collectBytes, collectString, decodeJsonl, decodeMessage, decodeText, defaultCodec, deserializeError, emulateMux, encodeJsonl, encodeMessage, encodeText, fetchOverDuplex, fromReadableStream, getRelayWindowMessageHandler, handleChannelCalls, handleClaimRequests, handleHttpRequests, handleStreams, httpCodec, httpFetch, httpServe, initHttpService, initServiceWorker, initializeConnection, joinLines, jsonEnvelopeCodec, map, newAsyncGenerator, newCreditGrantor, newCreditLedger, newHttpClientStub, newHttpCodec, newHttpServerStub, newInvokationChannel, newRegistry, newRemoteRelayChannel, newServiceWorkerPort, newSniffingCodec, normalizeToUint8Array, recieveIterator, registerConnectionsHandler, sendHttpRequest, sendIterator, sendStream, serializeError, serveFetchOverDuplex, splitLines, splitServiceUrl, toChunks, toReadableStream };
@@ -1 +1 @@
1
- {"version":3,"file":"index-sw.d.ts","sourceRoot":"","sources":["../../src/relay/index-sw.ts"],"names":[],"mappings":"AAOA;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,wBAAwB,GAAG,MAAM,IAAI,CAwElF"}
1
+ {"version":3,"file":"index-sw.d.ts","sourceRoot":"","sources":["../../src/relay/index-sw.ts"],"names":[],"mappings":"AAQA;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,wBAAwB,GAAG,MAAM,IAAI,CA4ElF"}