@statewalker/webrun-http-browser 0.6.2 → 0.6.4

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.
Files changed (2) hide show
  1. package/README.md +399 -495
  2. package/package.json +10 -8
package/README.md CHANGED
@@ -1,211 +1,174 @@
1
1
  # @statewalker/webrun-http-browser
2
2
 
3
- ServiceWorker-based HTTP server for browsers. You write ordinary
4
- `(Request) ⇒ Response` handlers in JavaScript; a ServiceWorker intercepts
5
- same-origin `fetch()` calls and routes them to your handlers — no network
6
- round-trip, no external server, no bundler tricks required.
3
+ ## What it is
7
4
 
8
- Two modes, picked by how the SW is hosted:
5
+ A ServiceWorker-based HTTP server for the browser. You write ordinary
6
+ `(Request) => Response` handlers in page JavaScript; a ServiceWorker intercepts
7
+ `fetch()` calls and routes them to those handlers over a `MessagePort`, with no
8
+ network round-trip and no external server. It has two modes:
9
9
 
10
- - **Same-origin** (`@statewalker/webrun-http-browser/sw`) — your app
11
- registers its own SW and mounts handlers next to the page.
12
- - **Relay** (default entry) — a SW running at a shared relay origin
13
- (CDN / unpkg / your own host) serves requests for any page that embeds
14
- a hidden relay iframe. Cross-origin friendly.
10
+ - **Same-origin** (`./sw` + `./sw-worker`): your app registers its own worker
11
+ next to its pages and mounts handlers under URL prefixes.
12
+ - **Relay** (`.` + `./relay-sw` or `./relay-worker`): a worker hosted on a relay
13
+ origin serves requests for any page that embeds a hidden relay iframe. The
14
+ page never registers a worker of its own.
15
15
 
16
16
  ## Why it exists
17
17
 
18
- The browser already has everything needed to be an HTTP server: `Request`,
19
- `Response`, `ReadableStream`, `ServiceWorker`. Two things are missing from
20
- the raw platform APIs, and this package fills them:
18
+ The browser already has `Request`, `Response`, `ReadableStream` and
19
+ ServiceWorkers. What it lacks is the plumbing between them:
21
20
 
22
- 1. **Plumbing for same-origin SW dispatch.** Browsers let a SW intercept
23
- `fetch` events, but you still have to build URL routing, MessageChannel
24
- wiring between the page and the SW, and recovery after SW restarts.
25
- 2. **A way to use a SW from a page that isn't on the SW's origin.** The
26
- relay mode lets *any* page (Observable, notebooks, a `file://` demo,
27
- unpkg, a third-party host) share a SW hosted somewhere else. The page
28
- never registers a SW of its own — it just embeds a hidden iframe.
21
+ 1. **Same-origin dispatch.** A worker can intercept `fetch` events, but you
22
+ still need URL routing, a `MessageChannel` between page and worker, recovery
23
+ after the worker is stopped and restarted, and a way out when the page loads
24
+ uncontrolled.
25
+ 2. **A worker for a page that is not on the worker's origin.** Relay mode lets
26
+ any page (a notebook, a CDN-hosted page, a third-party host) use a worker
27
+ hosted elsewhere by embedding a hidden iframe.
29
28
 
30
- Combining both modes means the same handler code works in an app you
31
- control *and* in an embed you don't.
29
+ The same handler code works in both modes.
32
30
 
33
- ## Install
31
+ ## How to use
34
32
 
35
33
  ```sh
36
- npm install @statewalker/webrun-http-browser
34
+ pnpm add @statewalker/webrun-http-browser
37
35
  ```
38
36
 
39
- Browser-only — it needs `navigator.serviceWorker`, so a secure context
40
- (`https://` or `localhost`) is required. No peer dependencies.
37
+ - **Peer dependencies:** none.
38
+ - **Environment:** browser only. Pages need `navigator.serviceWorker`, which
39
+ requires a secure context (`https://` or `http://localhost`).
41
40
 
42
- ## How to use
41
+ ### Entry points
43
42
 
44
- ```sh
45
- npm install @statewalker/webrun-http-browser
46
- ```
43
+ | Subpath | Format | Runs in | What it gives |
44
+ | --- | --- | --- | --- |
45
+ | `.` | ESM, `dist/index.js` + `.d.ts` | page, relay iframe | Relay page API (`newRemoteRelayChannel`, `initHttpService`, `callHttpService`, `getRelayWindowMessageHandler`, `splitServiceUrl`), ServiceWorker lifecycle helpers (`initServiceWorker`, `newServiceWorkerPort`, `awaitActiveServiceWorker`, `awaitServiceWorkerControl`, `handleClaimRequests`, `ServiceWorkerControlError`), the `MessagePort` call primitives, and everything re-exported from `@statewalker/webrun-streams` and `@statewalker/webrun-http-streams`. |
46
+ | `./sw` | ESM, `dist/sw.js` + `.d.ts` | page and worker | Same-origin classes: `SwHttpAdapter` (page), `SwHttpDispatcher` and `startHttpDispatcher` (worker), and their bases `SwPortHandler` / `SwPortDispatcher`. |
47
+ | `./sw-worker` | IIFE, `dist/sw-worker.js`, no types | ServiceWorker | Prebuilt same-origin worker: calls `startHttpDispatcher({ self, log: console.log })`. Load it with `importScripts`. |
48
+ | `./relay-sw` | IIFE, `dist/relay-sw.js`, no types | ServiceWorker | Prebuilt relay worker: calls `startRelayServiceWorker(self, self.RELAY_OPTIONS ?? {})`. Load it with `importScripts`. |
49
+ | `./relay-worker` | ESM, `dist/relay-worker.js` + `.d.ts` | ServiceWorker | The relay worker runtime as a typed module, for a host that bundles its own worker: `startRelayServiceWorker`, `RelayServiceWorkerOptions`, `MountSpec`. Not re-exported from `.`, because it only runs inside a worker. |
50
+
51
+ The ESM subpaths also have a `source` condition pointing at `src/`.
47
52
 
48
- | Subpath | Purpose |
53
+ ### Static files in the package
54
+
55
+ The published package also ships three directories, served straight from a
56
+ static host:
57
+
58
+ | Path | Contents |
49
59
  | --- | --- |
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; `start()` options `timeout` and `reloadIfUncontrolled` |
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. No declarations: it takes its options from `self.RELAY_OPTIONS` |
53
- | `@statewalker/webrun-http-browser/relay-worker` | The same runtime as a typed ES module, for a host that bundles its own relay worker: `startRelayServiceWorker(self, options)`, plus `RelayServiceWorkerOptions` and `MountSpec` to type them (including a hand-written `self.RELAY_OPTIONS`) |
54
- | `@statewalker/webrun-http-browser/sw-worker` | IIFE bundle of the same-origin SW runtime — ditto, for same-origin apps |
60
+ | `public-relay/relay.html`, `public-relay/relay-sw.js` | A ready relay: the iframe page (calls `getRelayWindowMessageHandler`) and a worker loader that `importScripts("../dist/relay-sw.js")`. `newRemoteRelayChannel()` points here by default. |
61
+ | `public/index.html`, `public/index.js`, `public/sw-worker.js` | Minimal same-origin demo, and a loader that `importScripts("../dist/sw-worker.js")`. |
62
+ | `demo/demo-1.html`, `demo/demo-2.html` | Relay demos: a Hono app (loaded from esm.sh) as an in-tab site, and a local folder served through the File System Access API. |
55
63
 
56
64
  ## Examples
57
65
 
58
- > Every example below needs a real browser: a ServiceWorker, and for relay
59
- > mode an iframe on the relay origin. None of them run under Node, and
60
- > ServiceWorkers only register over `http://localhost` or HTTPS. The runnable
61
- > versions are in [`public/`](./public) and [`demo/`](./demo) — see
62
- > [Running the bundled examples](#running-the-bundled-examples).
66
+ Every example needs a real browser with ServiceWorker support. None runs under
67
+ Node.
63
68
 
64
- ### Relay mode — cross-origin
65
-
66
- Your page ↔ hidden relay iframe ↔ relay ServiceWorker. The relay SW claims
67
- URLs shaped `<relay-origin>/~<service-key>/…` and forwards each request to
68
- whichever page registered that `key`.
69
+ ### Relay mode: serve a handler from the page
69
70
 
70
71
  ```ts
71
72
  import {
72
- newRemoteRelayChannel,
73
- initHttpService,
74
73
  callHttpService,
74
+ initHttpService,
75
+ newRemoteRelayChannel,
75
76
  } from "@statewalker/webrun-http-browser";
76
77
 
77
- // 1. Embed the relay iframe and open a MessagePort into its SW.
78
+ // 1. Embed the hidden relay iframe and get a port into its worker.
78
79
  const connection = await newRemoteRelayChannel({
79
- url: new URL("https://my-relay.example/public-relay/relay.html"),
80
+ url: new URL("https://my-relay.example/relay.html"),
80
81
  });
81
82
 
82
- // 2. Register a handler for service "FS".
83
- const baseUrl = `${connection.baseUrl}~FS`;
84
- await initHttpService(
85
- async (request) =>
86
- new Response(`Hello ${new URL(request.url).pathname}`),
83
+ // 2. Register a handler for service "FS". Returns a cleanup that unregisters it.
84
+ const unregister = await initHttpService(
85
+ async (request) => new Response(`Hello ${new URL(request.url).pathname}`),
87
86
  { key: "FS", port: connection.port },
88
87
  );
89
88
 
90
- // 3a. Any browser tab loading the service URL now hits your handler:
91
- await fetch(`${baseUrl}/anything`);
92
-
93
- // 3b. …or call it directly through the same port, bypassing `fetch`
94
- // (useful when the caller isn't on the relay origin):
95
- const res = await callHttpService(
96
- new Request(`${baseUrl}/anything`),
97
- { key: "FS", port: connection.port },
98
- );
89
+ // 3. Call it through the same port, without going through `fetch`.
90
+ const res = await callHttpService(new Request("https://my-relay.example/~FS/anything"), {
91
+ key: "FS",
92
+ port: connection.port,
93
+ });
99
94
  ```
100
95
 
101
- [`demo/demo-1.html`](./demo/demo-1.html) wires this to a Hono router
102
- serving a mini site; [`demo/demo-2.html`](./demo/demo-2.html) pipes a
103
- local-disk folder (File System Access API) through it.
96
+ Any tab whose `fetch` reaches the relay worker for `https://my-relay.example/~FS/…`
97
+ is answered by this page's handler. `callHttpService` reaches the service over
98
+ the iframe's port instead, which is what a caller on another origin uses. It
99
+ rejects with `No service with key "FS"` when the worker refuses the
100
+ connection.
104
101
 
105
- ### Mounting a service at a path
102
+ `newRemoteRelayChannel(options?)` takes `baseUrl` (default: the package's own
103
+ `public-relay/` directory, resolved from the module URL), `url` (default
104
+ `relay.html` under `baseUrl`) and `container` (default `document.body`). It
105
+ resolves `{ baseUrl, port, close() }`.
106
106
 
107
- A service can claim a path prefix instead of living at `/~<key>/`. Both the
108
- key and the path are the caller's, and several services can share **one**
109
- relay connection — the shape mounts exist for is an app at the origin root
110
- and, say, a gateway one level down, both reachable through the same iframe:
107
+ ### Relay mode: mount services at paths
111
108
 
112
- ```ts
113
- const connection = await newRemoteRelayChannel(/* … */);
109
+ A service can claim a path prefix instead of `/~<key>/`. Several services can
110
+ share one relay connection:
114
111
 
112
+ ```ts
115
113
  await initHttpService(appHandler, { key: "app", path: "/", port: connection.port });
116
114
  await initHttpService(meshHandler, { key: "mesh", path: "/peers/", port: connection.port });
117
115
  ```
118
116
 
119
- A `CONNECT` is routed to the service named in its `key`, so the two never see
120
- each other's calls even though they share one port. (Earlier builds routed
121
- every `CONNECT` on a connection to every registered service, which collided
122
- whenever more than one service shared a port — fixed before this shipped.)
123
-
124
- The worker routes by the longest matching path prefix, so a catch-all at `/`
125
- does not shadow `/peers/`, and registration order does not matter. A service
126
- registered with no `path` is reachable at `/~<key>/`, exactly as before —
127
- unless the host declared that key in `mounts`, in which case the host's mount
128
- stands and the page need not repeat it.
129
-
130
- **A request that matches no mount is not the relay's** — the worker does not
131
- answer it at all, so the browser performs it exactly as it would with no
132
- worker installed. That is what lets a host serve its own files from the same
133
- origin, and it is why a root mount needs `exclude`.
134
-
135
- **The matched prefix is NOT stripped.** A handler mounted at `/peers/`
136
- receives `/peers/12D3Koo/llm`, not `/12D3Koo/llm` — the request reaches it
137
- with the path the browser asked for, whichever mount matched. A handler that
138
- wants to route relative to its mount keeps its own `basePath` and strips the
117
+ The worker routes by the longest matching prefix, so a catch-all at `/` does
118
+ not shadow `/peers/`, and registration order does not matter. The matched
119
+ prefix is not stripped: a handler mounted at `/peers/` receives
120
+ `/peers/12D3Koo/llm`. A handler that routes relative to its mount strips the
139
121
  prefix itself.
140
122
 
141
- These options are read by `startRelayServiceWorker`, which a host that
142
- bundles its own relay worker imports from
143
- `@statewalker/webrun-http-browser/relay-worker` and calls directly (see the
144
- options table below for the prebuilt-bundle equivalent):
123
+ ### Relay mode: build your own relay worker
145
124
 
146
125
  ```ts
126
+ /// <reference lib="webworker" />
147
127
  import { startRelayServiceWorker } from "@statewalker/webrun-http-browser/relay-worker";
148
128
 
149
- startRelayServiceWorker(self, {
129
+ declare const self: ServiceWorkerGlobalScope;
130
+
131
+ const stop = startRelayServiceWorker(self, {
150
132
  exclude: (url) =>
151
133
  url.pathname === "/index.html" ||
152
134
  url.pathname === "/relay.html" ||
153
135
  url.pathname === "/relay-sw.js",
154
136
  takeover: "first-wins",
155
137
  canRegister: (client, _key) => new URL(client.url).pathname === "/relay.html",
156
- decorateResponse: (response) => withMyHeaders(response),
138
+ decorateResponse: (response) => response,
157
139
  });
158
140
  ```
159
141
 
160
- > **Mounting at `/` — set `takeover: "first-wins"` and `canRegister`.**
161
- > The default is `takeover: "last-wins"` with no `canRegister`, which is what
162
- > the relay has always done: the last page to REGISTER a key gets it. Before
163
- > mounts the worst that bought a rogue or buggy same-origin page was
164
- > `/~<key>/`; with mounts it can claim the **origin root**, and the mount is
165
- > persisted in IndexedDB, so it outlives the page and every worker restart.
166
- > On an origin where more than the host's own page can reach the relay,
167
- > `takeover: "first-wins"` keeps a live holder's key and `canRegister` says
168
- > which client may ask for it — set both, together, for any mount at `/`.
169
-
170
- A root mount claims *every* path under the worker's scope, including the
171
- host's own navigation. If `exclude` only covers the relay page and its
172
- worker script, a reload requests the host's own entry page — say
173
- `/index.html` — through the mount too, the mount has no handler for it, and
174
- the origin cannot come back. `exclude` needs three things, always: the relay
175
- page, the worker script, and the host's own entry page. That third one is
176
- easy to miss because nothing fails until the first reload.
177
-
178
- An excluded page is one the worker does not answer, and in Firefox such a
179
- page can load **uncontrolled** even while the worker is running — then its
180
- own `fetch()` never reaches the worker and its mounts look dead. The remedy
181
- is the one this package already ships for that case: call
182
- `awaitServiceWorkerControl(registration)` (exported from the package root)
183
- before relying on `fetch()` from an excluded page. A page *served by* a mount
184
- is a navigation the worker answers, so it is controlled from its first byte
185
- and needs nothing.
186
-
187
- #### `self.RELAY_OPTIONS` — options for the prebuilt worker
188
-
189
- `dist/relay-sw.js` is an IIFE loaded via classic `importScripts`, so a host
190
- that uses the shipped worker (rather than building its own from
191
- `startRelayServiceWorker`) cannot pass options as arguments. It reads them
192
- instead from `self.RELAY_OPTIONS`, which the host's own tiny worker script
193
- sets *before* importing the bundle:
142
+ | Option | Default | What it does |
143
+ | --- | --- | --- |
144
+ | `mounts` | none | Fixed table `Array<{ key, path?, match? }>`, for a host that knows its services at build time. A key declared here belongs to the host: a page's `REGISTER` or `UNREGISTER` for it never replaces or removes the mount, so the page can register with no `path`. |
145
+ | `exclude` | none | `(url) => boolean`. Paths the relay never claims, through the table or through `/~<key>/`. Checked first. |
146
+ | `canRegister` | everyone | `(client, key) => boolean \| Promise<boolean>`. Refuses a registration with `this client may not register "<key>"`. |
147
+ | `takeover` | `"last-wins"` | `"first-wins"` keeps a live holder's key; a second page gets `"<key>" is already served by another client`. A holder that reloaded is no longer live, so it can always re-register. |
148
+ | `decorateResponse` | none | `(response, request) => Response`. Stamps headers on responses the relay makes, including its error responses. Not applied to requests the worker leaves to the network. |
149
+
150
+ `startRelayServiceWorker` returns a function that removes its listeners.
151
+
152
+ ### Relay mode: options for the prebuilt worker
153
+
154
+ `dist/relay-sw.js` is an IIFE loaded with classic `importScripts`, which cannot
155
+ pass arguments. It reads its options from `self.RELAY_OPTIONS`, set by the
156
+ host's own worker script before the import:
194
157
 
195
158
  ```js
196
- // relay-sw.js — served next to your relay page.
159
+ // relay-sw.js, served next to your relay page
197
160
  self.RELAY_OPTIONS = {
198
- exclude: (url) => url.pathname === "/index.html" || url.pathname === "/relay.html" || url.pathname === "/relay-sw.js",
161
+ exclude: (url) =>
162
+ url.pathname === "/index.html" ||
163
+ url.pathname === "/relay.html" ||
164
+ url.pathname === "/relay-sw.js",
199
165
  takeover: "first-wins",
200
166
  };
201
- importScripts("/path/to/node_modules/@statewalker/webrun-http-browser/dist/relay-sw.js");
167
+ importScripts("/path/to/@statewalker/webrun-http-browser/dist/relay-sw.js");
202
168
  ```
203
169
 
204
- This is the only way a prebuilt-worker host reaches `exclude`, `takeover`,
205
- `canRegister` or `decorateResponse` — omit it and the worker boots with `{}`,
206
- exactly as it did before mounts. The bundle ships no declarations, so to type
207
- that object (in a TypeScript loader script, or to check it before shipping)
208
- import the type from the runtime entry:
170
+ Without it the worker starts with `{}`. To type the object, import the type from
171
+ the typed subpath:
209
172
 
210
173
  ```ts
211
174
  import type { RelayServiceWorkerOptions } from "@statewalker/webrun-http-browser/relay-worker";
@@ -213,63 +176,68 @@ import type { RelayServiceWorkerOptions } from "@statewalker/webrun-http-browser
213
176
  declare const self: ServiceWorkerGlobalScope & { RELAY_OPTIONS?: RelayServiceWorkerOptions };
214
177
  ```
215
178
 
216
- | Option | Default | What it does |
217
- | --- | --- | --- |
218
- | `mounts` | none | A fixed table, for a host that knows its services at build time. Each entry is `{ key, path? , match? }`. A key declared here is the host's: a page's REGISTER or UNREGISTER for the same key never replaces or removes it, so the page can register with no `path` of its own. |
219
- | `exclude` | none | Paths the relay never claims — neither through the table nor through the `/~<key>/` spelling. Checked first. |
220
- | `canRegister` | everyone | Refuse a registration from the wrong page. |
221
- | `takeover` | `"last-wins"` | `"first-wins"` keeps a live holder's key. |
222
- | `decorateResponse` | none | Stamp headers on responses the relay makes; not applied to network fetches. |
179
+ ### Relay mode: the iframe page
223
180
 
224
- ### Same-origin mode
181
+ `public-relay/relay.html` is this, and a self-hosted relay page looks the same:
182
+
183
+ ```html
184
+ <script type="module">
185
+ import { getRelayWindowMessageHandler } from "../dist/index.js";
186
+
187
+ window.onmessage = getRelayWindowMessageHandler({
188
+ swUrl: `${new URL("./relay-sw.js", import.meta.url)}`,
189
+ scopeUrl: `${new URL("./", import.meta.url)}`,
190
+ });
191
+ </script>
192
+ ```
193
+
194
+ Pass `swUrl` explicitly. Its default is `index-sw.js` next to the module, a
195
+ file the build does not produce. `timeout` bounds the wait for the worker to
196
+ activate. If the worker cannot start, every call the parent makes on the port
197
+ is answered with that error, so the parent's `initHttpService` /
198
+ `callHttpService` reject instead of hanging.
225
199
 
226
- Your page registers its own SW, handlers are local to the page:
200
+ ### Same-origin mode
227
201
 
228
202
  ```ts
229
203
  import { SwHttpAdapter } from "@statewalker/webrun-http-browser/sw";
230
204
 
231
- const KEY = "demo"; // also the first URL segment the SW routes here
205
+ const KEY = "demo"; // also the first URL segment the worker routes here
232
206
  const adapter = new SwHttpAdapter({
233
207
  key: KEY,
234
208
  serviceWorkerUrl: new URL("./sw-worker.js", import.meta.url).toString(),
235
209
  });
236
210
  await adapter.start();
237
211
 
238
- const { baseUrl } = await adapter.register(`${KEY}/api/`, async (request) => {
239
- return new Response(JSON.stringify({ now: Date.now() }), {
240
- headers: { "Content-Type": "application/json" },
241
- });
242
- });
212
+ const { baseUrl, remove } = await adapter.register(`${KEY}/api/`, async () =>
213
+ Response.json({ now: Date.now() }),
214
+ );
243
215
 
244
- // fetch(`${baseUrl}anything`) is intercepted by the SW.
216
+ const res = await fetch(`${baseUrl}anything`); // answered by the handler above
245
217
  ```
246
218
 
247
- `start()` resolves once the worker is activated **and controls the page** —
248
- only a controlled page's `fetch()` reaches the worker. Two options bound it:
219
+ The worker script is a one-line loader served next to the app pages, so the
220
+ worker's default scope covers them:
221
+
222
+ ```js
223
+ // sw-worker.js
224
+ importScripts("/path/to/@statewalker/webrun-http-browser/dist/sw-worker.js");
225
+ ```
226
+
227
+ `SwHttpAdapter` options:
249
228
 
250
229
  | Option | Default | Meaning |
251
230
  | --- | --- | --- |
252
- | `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. |
253
- | `reloadIfUncontrolled` | `false` | If the page is still uncontrolled once the worker is active, reload it once instead of rejecting (see below). |
254
-
255
- #### When the page is not controlled
256
-
257
- A page can load **uncontrolled although its worker is active**: a hard reload
258
- (Ctrl+Shift+R / Cmd+Shift+R) bypasses ServiceWorkers for that load, and the
259
- worker's `clients.claim()` already ran when it activated, so nothing ever
260
- hands the page to it. (Firefox has also been seen leaving a second page of a
261
- running worker uncontrolled.) `start()` then asks the worker to claim the page
262
- again — a `CLAIM` call this package's workers answer with `clients.claim()` —
263
- and waits for `controllerchange`. That takes the page over in Chromium and
264
- Firefox, both verified by the browser tests.
265
-
266
- If it still is not controlled (a worker that does not answer `CLAIM`, a page
267
- outside the worker's scope), `start()` rejects with a
268
- `ServiceWorkerControlError` whose `reason` is `"uncontrolled"` and whose message
269
- says what happened and what to do. With `reloadIfUncontrolled: true` it reloads
270
- the page instead — a normal reload is a controlled navigation — at most once:
271
- a `sessionStorage` marker makes a second uncontrolled load reject rather than
272
- loop. After a failure, calling `start()` again retries.
231
+ | `key` | required | First URL segment under the scope that the worker routes to this page. |
232
+ | `serviceWorkerUrl` | none; pass it | Resolved against `location.href`. An unresolvable value throws `Invalid serviceWorkerUrl: "<value>" (relative to <href>)`. |
233
+ | `scope` | directory of `serviceWorkerUrl` | Registration scope. |
234
+ | `timeout` | `30_000` | Upper bound, in ms, for activation, control and the adapter's handshake. Past it `start()` rejects with a `ServiceWorkerControlError`. |
235
+ | `reloadIfUncontrolled` | `false` | Reload the page once instead of rejecting when it stays uncontrolled. |
236
+
237
+ `register(prefix, handler)` resolves `{ baseUrl, prefix, remove() }`. The
238
+ prefix must start with the adapter's `key`.
239
+
240
+ ### Same-origin mode: a page that is not controlled
273
241
 
274
242
  ```ts
275
243
  try {
@@ -280,344 +248,280 @@ try {
280
248
  }
281
249
  ```
282
250
 
283
- Check `name` or `reason`, not `instanceof`: each bundle of this package carries
284
- its own copy of the class.
285
-
286
- The SW script itself ships as a pre-built IIFE bundle. Put a tiny loader
287
- next to your app pages so the SW's default scope covers them:
288
-
289
- ```js
290
- // public/sw-worker.js — served next to your app pages.
291
- importScripts(
292
- "/path/to/node_modules/@statewalker/webrun-http-browser/dist/sw-worker.js",
293
- );
294
- ```
295
-
296
- The working example lives in [`public/`](./public).
251
+ Check `name` or `reason`, not `instanceof`: each bundle carries its own copy of
252
+ the class. After a failure, calling `start()` again retries.
297
253
 
298
254
  ### Running the bundled examples
299
255
 
300
256
  ```sh
301
- pnpm run example:same-origin # public/index.html — same-origin SW demo
302
- pnpm run example:relay-site # demo/demo-1.html — relay + Hono dynamic site
303
- pnpm run example:relay-files # demo/demo-2.html — relay + local-disk file server
304
- pnpm run serve # just a static server on :5173 (no auto-open)
257
+ pnpm run example:same-origin # build, serve on :5173, open public/index.html
258
+ pnpm run example:relay-site # build, serve on :5173, open demo/demo-1.html
259
+ pnpm run example:relay-files # build, serve on :5173, open demo/demo-2.html
260
+ pnpm run serve # static server on :5173, no build, no browser
305
261
  ```
306
262
 
307
- Each `example:*` script builds first, starts a static server on `:5173`,
308
- then opens the target page in the default browser. ServiceWorkers only
309
- register over `http://localhost` or HTTPS, so always visit through
310
- `http://localhost:5173/…` — `file://` won't work.
311
-
312
- #### [`public/index.html`](./public/index.html) — minimal same-origin SW
313
-
314
- The smallest possible in-browser HTTP server. The page registers
315
- `public/sw-worker.js` (which `importScripts`es the shipped
316
- `dist/sw-worker.js`), constructs a `SwHttpAdapter` with key `"demo"`,
317
- and registers a single handler at `demo/api/` that returns JSON. The
318
- page then makes a standard `fetch(baseUrl + "anything")` and logs the
319
- result.
320
-
321
- Why it's interesting:
322
-
323
- - **No framework, no glue, ~40 lines of inline JS.** This is the
324
- unwrapped pattern — everything
325
- [`@statewalker/webrun-site-host`](../webrun-site-host) and
326
- [`@statewalker/webrun-site-builder`](../webrun-site-builder) build on
327
- top of. Useful as a reference for exactly what the SW lifecycle
328
- looks like at its lowest level.
329
- - **Shows the SW-routing contract.** The adapter's `key: "demo"` is
330
- the first URL segment the SW uses to find this page's registration;
331
- `adapter.register(\`${KEY}/api/\`, ...)` mounts the handler prefix
332
- under the same key. The mapping is visible and inspectable — great
333
- for debugging your own SW-based code.
334
-
335
- #### [`demo/demo-1.html`](./demo/demo-1.html) — relay + Hono dynamic site
336
-
337
- A full-blown mini web site running in a single tab, behind the
338
- **relay** ServiceWorker. The page spins up a Hono router with a
339
- `/api/:name` endpoint and a static-file catch-all, registers it as
340
- service `MY_SITE`, and embeds the service root in an iframe. Inside
341
- the iframe, typing into an input fires `fetch("./api/" + name)` and
342
- renders the JSON response — the whole back-end is the Hono app
343
- running in the outer tab.
344
-
345
- Why it's interesting:
346
-
347
- - **An entire web framework running client-side.** Hono is a normal
348
- Node/Deno/CF-Workers framework — here it's loaded from esm.sh and
349
- mounted inside the browser with no server involvement. The
350
- `(Request) ⇒ Response` contract makes this transparent.
351
- - **Relay mode = cross-origin friendly.** Because the SW lives at the
352
- relay origin (not the page's origin), this pattern also works when
353
- your page is served from Observable, unpkg, a notebook, or a static
354
- `file://` — places where registering your own SW isn't possible.
355
- The hidden relay iframe does the SW registration on your behalf.
356
- - **Two ways to call the service.** The iframe uses plain `fetch()`
357
- through the SW; any other browser tab pointing at
358
- `<relay-origin>/~MY_SITE/...` is also routed to this tab's Hono
359
- app. Demonstrates that the page hosting the handler and the caller
360
- don't have to share an origin.
361
-
362
- #### [`demo/demo-2.html`](./demo/demo-2.html) — FS Access API folder as a site
363
-
364
- Click **Open folder**, grant read access, and any directory on your
365
- local disk is exposed as an in-browser HTTP site under
366
- `<relay-origin>/~FS/…`. The left panel shows a live file tree; clicking
367
- a file loads it in the iframe preview. The service handler is a ~20-line
368
- function that resolves paths via
369
- [`FileSystemDirectoryHandle.getFileHandle`](https://developer.mozilla.org/docs/Web/API/FileSystemDirectoryHandle/getFileHandle)
370
- and streams the file's bytes back through the SW.
371
-
372
- Why it's interesting:
373
-
374
- - **Zero installs, real files.** Browse arbitrary directories as if
375
- they were hosted — open a local project's `index.html` and it just
376
- runs. Relative URLs inside the hosted files resolve correctly because
377
- the SW serves every asset, CSS, and JS under the same origin.
378
- - **Permissioned + sandboxed.** The browser's File System Access API
379
- provides the "backend" (read permission granted per-folder by the
380
- user); the relay SW provides the "network". You get the ergonomics
381
- of a local HTTP dev server without running one.
382
- - **Directory picker + request router in <100 lines.** No build step,
383
- no tooling. Shows how small the glue between a platform API and a
384
- `(Request) ⇒ Response` handler can be.
385
-
386
- ## Exports
387
-
388
- The package root re-exports everything from
389
- [`@statewalker/webrun-streams`](../webrun-streams) and
390
- [`@statewalker/webrun-http-streams`](../webrun-http-streams), so existing
391
- imports keep working after those extractions. Its own surface is below.
392
-
393
- ### Relay mode
394
-
395
- | Export | Kind | Purpose |
396
- | --- | --- | --- |
397
- | `newRemoteRelayChannel(opts?)` | function | Embeds the hidden relay iframe, handshakes a `MessageChannel`, resolves a `RemoteRelayChannel`. |
398
- | `RemoteRelayChannelOptions` | interface | `baseUrl`, `url`, `container` — where the relay lives and what to append the iframe to. |
399
- | `RemoteRelayChannel` | interface | `{ baseUrl, port, close() }`. |
400
- | `initHttpService(handler, opts)` | function | Registers `handler` as the server for a service `key` on the relay, optionally mounted at `path`. Several services may share one `port`. Returns a cleanup. |
401
- | `callHttpService(request, opts)` | function | Sends a `Request` to the service under `key`; resolves its `Response`. |
402
- | `ServiceOptions` | interface | `{ key: string; path?: string; port: MessageTarget }` — shared by the two above. `path` mounts the service (see [Mounting a service at a path](#mounting-a-service-at-a-path)); omitted, it stays at `/~<key>/`. |
403
- | `getRelayWindowMessageHandler(opts?)` | function | The `window.onmessage` handler that runs *inside* the relay iframe. |
404
- | `RelayWindowHandlerOptions` | interface | `swUrl`, `scopeUrl` for that handler. |
405
- | `splitServiceUrl(url, separator?)` | function | Splits a relay URL into service key + remaining path (default separator `~`); anchored to the pathname, so a query string like `?q=~foo` is never read as a service. |
406
- | `SplitServiceUrl` | interface | Its result shape. |
407
- | `startRelayServiceWorker(self, opts?)` | function | The SW side of the relay: routes `fetch` to the client that registered a mount, and answers `REGISTER`/`UNREGISTER`/`CONNECT`. Not re-exported from the package root — it is what the prebuilt `dist/relay-sw.js` calls internally; see [`self.RELAY_OPTIONS`](#selfrelay_options--options-for-the-prebuilt-worker). |
408
- | `RelayServiceWorkerOptions` | interface | `{ mounts?, exclude?, canRegister?, takeover?, decorateResponse? }` — see the options table in [Mounting a service at a path](#mounting-a-service-at-a-path). |
409
-
410
- ### ServiceWorker lifecycle
411
-
412
- | Export | Kind | Purpose |
413
- | --- | --- | --- |
414
- | `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`. |
415
- | `InitServiceWorkerOptions` | interface | `{ swUrl, scopeUrl?, type?, timeout? }`. |
416
- | `newServiceWorkerPort(registration?)` | function | A `MessagePort` that transparently bridges to the controlling SW, or to `registration.active` while the page is not controlled. |
417
- | `awaitActiveServiceWorker(registration, opts?)` | function | Resolves with the registration's worker once `activated`; rejects (`reason: "activation-timeout"`) past `timeout`. |
418
- | `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. |
419
- | `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. |
420
- | `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). |
421
- | `DEFAULT_SERVICE_WORKER_TIMEOUT` / `CLAIM_CALL` | const | `30_000` ms / `"CLAIM"`. |
422
-
423
- ### Connection registry
424
-
425
- | Export | Kind | Purpose |
426
- | --- | --- | --- |
427
- | `initializeConnection(opts)` | function | Sends `CONNECT` for a service `key`; resolves a `MessagePort`, or `null` if no such service. |
428
- | `InitializeConnectionOptions` | interface | `{ key, communicationPort, ...extra }` — extra fields ride along in the CONNECT payload. |
429
- | `registerConnectionsHandler(opts)` | function | Registers a `key` and answers inbound `CONNECT`s. Returns a cleanup that unregisters. |
430
- | `RegisterConnectionsHandlerOptions` | interface | `{ key, handler, communicationPort }`. |
431
-
432
- ### Messaging primitives
263
+ Open them through `http://localhost:5173/…`; ServiceWorkers do not register
264
+ from `file://`. The relay demos fetch their service through
265
+ `<relay-base>/~KEY/` URLs under `public-relay/`, which the relay worker
266
+ does not claim; see
267
+ [`/~<key>/` is recognised only at the origin root](#key-is-recognised-only-at-the-origin-root).
433
268
 
434
- | Export | Kind | Purpose |
435
- | --- | --- | --- |
436
- | `callChannel(target, type, data, port?)` | function | One typed request/response over a `MessageTarget`. |
437
- | `handleChannelCalls(target, type, handler)` | function | Answer those calls. Returns an unsubscribe. |
438
- | `ChannelCallHandler` | type | The handler signature the two above exchange. |
439
- | `newInvokationChannel(opts)` | function | Multiplexed invocations over one target. |
440
- | `InvocationChannel` / `NewInvocationChannelOptions` | interface | Its result and options. |
441
- | `handleStreams(...)` / `StreamHandler<T>` | function / type | Stream-shaped invocations over the same channel. |
442
-
443
- > **These stream primitives have no backpressure.** `sendStream`'s chunk sender
444
- > discards the promise it is handed, so a fast producer over a slow consumer
445
- > accumulates without bound; there is also no per-stream timeout and no chunking
446
- > to a transport's message ceiling. `@statewalker/webrun-rpc`'s `duplexOverPort`
447
- > is the replacement — one `Duplex` over one port, with the confirmation
448
- > withheld until the consumer has pulled. Migrating this package onto it is
449
- > planned, not done.
450
- | `MessageTarget` / `MessageSource` / `MessageSink` / `MessageListener` | interface / type | The structural port view everything above accepts — a `MessagePort`, a `Worker`, or a SW bridge. Defined in [`@statewalker/webrun-streams`](../webrun-streams) and re-exported here. |
451
- | `newRegistry(onError?)` | function | Small cleanup registry used for teardown. |
452
- | `Registry` / `NewRegistryResult` / `CleanupAction` | interface / type | Its shapes. |
453
-
454
- ### HTTP over a port
455
-
456
- | Export | Kind | Purpose |
457
- | --- | --- | --- |
458
- | `sendHttpRequest(port, request)` | function | **Deprecated.** Ship a `Request` over a `MessageTarget`, await the `Response`. |
459
- | `handleHttpRequests(port, handler)` | function | **Deprecated.** Serve an `HttpHandler` on the other end of one. |
269
+ ## Internals
460
270
 
461
- ### Subpath entry points
271
+ ### How a request travels
462
272
 
463
- | Entry | Purpose |
464
- | --- | --- |
465
- | `@statewalker/webrun-http-browser/sw` | `SwHttpAdapter` — the same-origin ServiceWorker adapter. |
466
- | `@statewalker/webrun-http-browser/relay-sw` | IIFE relay SW runtime, loadable via `importScripts(...)`. Reads its options from `self.RELAY_OPTIONS`, set before the `importScripts` call. |
467
- | `@statewalker/webrun-http-browser/sw-worker` | IIFE same-origin SW runtime, loadable via `importScripts(...)`. |
273
+ Relay mode:
468
274
 
469
- ## Internals
275
+ ```
276
+ caller tab relay origin serving page
277
+ fetch(<relay>/~KEY/x) ──► relay worker ── CONNECT(key) ──► relay iframe ──port──► initHttpService handler
278
+ (mount table, │ │
279
+ IndexedDB) └──────── MessagePort per call ◄───────────┘
280
+ callHttpService(req, {key, port}) ──port──► relay iframe ──► relay worker ──► same path
281
+ ```
470
282
 
471
- ### Source layout
283
+ Same-origin mode:
472
284
 
473
285
  ```
474
- src/
475
- ├── core/ ┐
476
- │ ├── data-calls.ts │ Transport primitives over a
477
- │ ├── data-channels.ts │ `MessageTarget`: one-shot
478
- │ ├── message-target.ts │ `callChannel` / `handleChannelCalls`,
479
- │ ├── registry.ts │ the request/response
480
- │ │ `newInvokationChannel`, streaming
481
- │ └── service-worker-control.ts │ `sendStream` / `handleStreams` with
482
- │ │ backpressure, and `newRegistry`.
483
- │ │ Bounded waits for a worker to
484
- │ │ activate / take control, and the
485
- │ │ `CLAIM` request that takes over an
486
- │ │ uncontrolled page.
487
- │ │ Also re-exports
488
- │ │ `@statewalker/webrun-streams`.
489
- │ ┘
490
- ├── http/ ┐
491
- │ ├── http-send-recieve.ts │ Browser-specific HTTP transport:
492
- │ │ │ `handleHttpRequests` /
493
- │ │ │ `sendHttpRequest` over `MessageTarget`s,
494
- │ │ │ built on the client/server stubs.
495
- │ └── index.ts │ Re-exports
496
- │ ┘ `@statewalker/webrun-http-streams`.
497
- ├── sw/ ┐
498
- │ ├── sw-dispatcher.ts │ Same-origin mode:
499
- │ │ │ `SwPortHandler` (page) /
500
- │ │ │ `SwPortDispatcher` (SW side,
501
- │ │ │ IndexedDB-persisted client index).
502
- │ ├── http-sw-dispatcher.ts │ `SwHttpAdapter` /
503
- │ │ │ `SwHttpDispatcher` /
504
- │ └── index.ts │ `startHttpDispatcher`.
505
- │ ┘
506
- ├── relay/ ┐
507
- │ ├── index.ts │ Relay mode page-side:
508
- │ │ │ `newRemoteRelayChannel`,
509
- │ │ │ `initHttpService`,
510
- │ │ │ `callHttpService`,
511
- │ │ │ `getRelayWindowMessageHandler`.
512
- │ ├── index-sw.ts │ `startRelayServiceWorker` — the SW
513
- │ │ │ side (registry keyed by service key).
514
- │ └── split-service-url.ts │ `<base>/~<key>/<path>` parser.
515
- │ ┘
516
- ├── index.ts — public entry: core + http + relay.
517
- ├── sw.ts — `./sw` subpath entry.
518
- ├── relay-sw.ts — relay SW bootstrap (IIFE target).
519
- └── sw-worker.ts — same-origin SW bootstrap (IIFE target).
286
+ page fetch(<scope>/KEY/...) ──► same-origin worker ── first path segment = KEY ──► page's port
287
+ └► SwHttpAdapter
288
+ (first registered prefix that matches)
520
289
  ```
521
290
 
522
- ### Design notes
523
-
524
- - **Two SW strategies**. Same-origin mode needs the SW to be served next
525
- to the app (scope-rooted loader); relay mode puts the SW anywhere and
526
- ferries messages through an iframe, at the cost of a `CONNECT`
527
- round-trip per call. Pick the stricter mode when you own the origin.
528
- - **Adapter key = URL segment**. For the same-origin path, the adapter's
529
- `key` option **must match** the first URL segment the SW routes to it:
530
- if `key: "demo"` and the SW scope is `/public/`, handlers answer at
531
- `/public/demo/…`. The SW extracts the segment from the URL and looks up
532
- `handlersIndex` by key. This is why
533
- `adapter.register(\`${KEY}/api/\`, …)` prefixes the registration path
534
- with the same key.
535
- - **IIFE for SW bundles**. The SW runtime bundles (`relay-sw.js`,
536
- `sw-worker.js`) are IIFE rather than ESM so a classic
537
- `importScripts(...)` loader script can pull them in. Registering as
538
- `{ type: "module" }` SWs would work but is subject to
539
- `Service-Worker-Allowed` header games for a scope wider than the
540
- bundle's directory.
541
- - **ESM page-side bundles are self-contained**. `dist/index.js` and
542
- `dist/sw.js` inline their dependencies (`idb-keyval`,
543
- `@statewalker/webrun-http-streams`, `@statewalker/webrun-streams`) so a
544
- page can load them straight from a static host without a bundler or
545
- import map.
546
- - **Streaming uses `newAsyncGenerator`** (from `@statewalker/webrun-streams`,
547
- via `recieveIterator`). The queue-based async generator gives explicit
548
- backpressure — each `next(value)` returns a `Promise<boolean>` that resolves
549
- once the consumer has dequeued — and drains in-flight producers on consumer
550
- exit.
551
- - **No literal `new URL("…", import.meta.url)` in page-side code**.
552
- Bundlers (Vite among them) turn that pattern into an emitted asset at build
553
- time, before tree-shaking; the relay defaults resolved `"../"` that way,
554
- which is the package itself, so every Vite consumer shipped a dead copy of
555
- `dist/index.js`. They resolve against a `moduleUrl` variable instead, and
556
- `tests/dist/vite-consumer.dist.ts` builds a Vite app to prove nothing is
557
- emitted.
558
- - **Uncontrolled pages are handled per mode**. Same-origin mode needs
559
- control — an uncontrolled page's `fetch()` never reaches the worker — so it
560
- asks the worker to claim the page. Relay mode needs only a worker to message,
561
- so an uncontrolled relay page bridges to `registration.active`.
562
- - **SW client registry is IndexedDB-persisted**. Both `SwPortDispatcher`
563
- (same-origin) and `relay/index-sw.ts` keep their client-lookup tables in
564
- IndexedDB so a SW wake-up after idle doesn't lose its bindings.
291
+ Each call opens its own `MessageChannel`. The request is turned into a
292
+ `SerializedHttpEnvelope` by `newHttpClientStub` and rebuilt on the other side
293
+ by `newHttpServerStub` (both from `@statewalker/webrun-http-streams`), with the
294
+ body streamed as chunks over the port.
295
+
296
+ ### Every bundle is self-contained
297
+
298
+ This package does not externalise its dependencies. Its own HTML loads
299
+ `../dist/index.js` from a static host with no import map, and the two IIFE
300
+ workers are loaded with `importScripts`, which cannot resolve a bare
301
+ specifier. So each of the five outputs is one file with `idb-keyval`,
302
+ `@statewalker/webrun-streams` and `@statewalker/webrun-http-streams` inlined,
303
+ built from its own rolldown config so no chunks are shared. The cost is
304
+ duplicated code across bundles, and that `instanceof` does not hold across this
305
+ package's boundary.
306
+
307
+ The workers are IIFE rather than module workers so a classic `importScripts`
308
+ loader can pull them in. The relay page registers its worker with no `type`.
309
+
310
+ Page-side defaults resolve against a `moduleUrl` variable instead of a literal
311
+ `new URL("…", import.meta.url)`. Bundlers such as Vite rewrite the literal form
312
+ into an emitted asset at build time, which made consumers ship a dead copy of
313
+ `dist/index.js`.
314
+
315
+ ### Relay routing: a request that is nobody's goes to the network
316
+
317
+ For each `fetch` the relay worker decides, in this order:
318
+
319
+ 1. Another origin: not the relay's.
320
+ 2. `exclude(url)` is true: not the relay's.
321
+ 3. The longest matching mount prefix, then `match` predicates in registration
322
+ order.
323
+ 4. The `/~<key>/` spelling.
324
+
325
+ A request that matches nothing is not answered at all: the worker does not call
326
+ `respondWith`, so the browser performs it as if no worker were installed. That
327
+ is what lets a host serve its own files from the relay origin, and it is why a
328
+ root mount needs `exclude`.
329
+
330
+ A root mount claims every path under the scope, including the host's own
331
+ navigation. `exclude` needs three entries: the relay page, the worker script,
332
+ and the host's own entry page. Miss the third and nothing fails until the first
333
+ reload, which then requests the entry page through the mount and cannot get
334
+ back.
335
+
336
+ Mounts live in memory and registrations in IndexedDB, so a restarted worker
337
+ reads the registrations back before routing. Requests that arrive before that
338
+ read settles are claimed and, if nobody's, re-issued with `fetch(request)`.
339
+ After it settles the decision is synchronous again, which keeps navigation
340
+ preload and `cache: "only-if-cached"` working. A failed restore is logged
341
+ (`[relay] failed to restore mounts from the registry`) and routing continues
342
+ with whatever the table holds.
343
+
344
+ ### `/~<key>/` is recognised only at the origin root
345
+
346
+ `splitServiceUrl` is anchored to the start of the pathname:
347
+ `https://host/~FS/a/b` yields key `FS`, while
348
+ `http://localhost:5173/public-relay/~FS/a` yields no key. So a relay worker
349
+ whose scope is a subdirectory, like the shipped `public-relay/`, does not claim
350
+ `fetch()` calls to `<scope>/~<key>/…`; they go to the network. `callHttpService`
351
+ still works there, since it does not depend on URL routing, and so does a
352
+ service registered with a `path` inside the scope. Query strings and fragments
353
+ are ignored, so `?q=~foo` is never read as a service.
354
+
355
+ ### Who may register a key
356
+
357
+ The default `takeover: "last-wins"` lets the last page that registers a key
358
+ take it. With mounts, a rogue or buggy same-origin page could then claim the
359
+ origin root, and the registration persists in IndexedDB across page loads and
360
+ worker restarts. On any origin where more than the host's own page can reach
361
+ the relay, set `takeover: "first-wins"` and `canRegister` together for a mount
362
+ at `/`.
363
+
364
+ A `CONNECT` is delivered only to the service named by its key. Services on one
365
+ port that are not the addressee stay silent; a reply of `false` would reach the
366
+ worker first and turn into a 403.
367
+
368
+ ### Relay error responses
369
+
370
+ When the relay worker cannot hand a request to a service, it answers with JSON
371
+ (`{ status, statusText, message, url, key, baseUrl, path }`):
372
+
373
+ | Cause | Status |
374
+ | --- | --- |
375
+ | No live client for the key (the page closed) | `410`, `Error 410: Resource Gone` |
376
+ | The page refused the connection | `403`, `Error 403: Forbidden` |
377
+ | Anything else thrown | `500`, with `statusText` `Bad Request` (from `HttpError.fromError`) |
378
+
379
+ ### Same-origin routing
380
+
381
+ The worker takes the first path segment under its scope as the key and forwards
382
+ the request to the page that registered it. A key that was registered at some
383
+ point but has no live page answers `404 Not Found (no active handler)` rather
384
+ than falling through to the network: a dev server's SPA fallback would
385
+ otherwise serve the app shell for every site URL. Any other URL is passed
386
+ through with `fetch(event.request)` unchanged; rebuilding the request would
387
+ drop `mode`, and the constructor throws for `only-if-cached` navigations. On the
388
+ page, a request matching no registered prefix gets `404 Error 404: Not found`.
389
+
390
+ The worker keeps the claimed keys and client ids in IndexedDB (`claimedKeys`,
391
+ `clientIds`) and asks each known client for a fresh port when it restarts. The
392
+ relay keeps its registry under `clientsIds` as `{ clientId, path }` entries and
393
+ still reads the older bare-client-id shape.
394
+
395
+ `SwPortHandler.stop()` unregisters every ServiceWorker registration of the
396
+ origin, not only its own.
397
+
398
+ ### A page can load uncontrolled although its worker is active
399
+
400
+ A hard reload (Ctrl+Shift+R / Cmd+Shift+R) bypasses ServiceWorkers for that
401
+ load, and the worker's `clients.claim()` already ran at activation, so nothing
402
+ hands the page to it. Firefox has also left a second page of a running worker
403
+ uncontrolled.
404
+
405
+ Same-origin mode needs control, because only a controlled page's `fetch()`
406
+ reaches the worker. `awaitServiceWorkerControl` (used by `SwHttpAdapter.start()`)
407
+ sends a `CLAIM` call, which this package's workers answer with
408
+ `clients.claim()`, and waits for `controllerchange` plus a 1 s grace period.
409
+ If control still does not come it rejects with `ServiceWorkerControlError`,
410
+ `reason: "uncontrolled"`, and a message that starts
411
+ `This page is not controlled by its ServiceWorker …`. With
412
+ `reloadIfUncontrolled: true` it reloads once instead; a `sessionStorage`
413
+ marker makes a second uncontrolled load reject rather than loop. A worker of
414
+ your own should call `handleClaimRequests(self)`.
415
+
416
+ Relay mode needs only a worker to message, so an uncontrolled relay page
417
+ bridges to `registration.active`. A page excluded from a root mount is not
418
+ answered by the worker and can load uncontrolled in Firefox; call
419
+ `awaitServiceWorkerControl(registration)` before relying on its `fetch()`.
420
+
421
+ `ServiceWorkerControlError.reason` is one of:
422
+
423
+ - `activation-timeout`: the worker did not activate within `timeout`
424
+ (`ServiceWorker "<url>" (scope <scope>) did not activate within <n> ms …`);
425
+ - `uncontrolled`: active, but the page is not controlled;
426
+ - `unresponsive`: controls the page but did not answer the adapter's
427
+ `UPDATE_COMMUNICATION_PORT` handshake in time.
428
+
429
+ ### The port transport has no backpressure
430
+
431
+ `sendStream` / `handleStreams`, and the `sendHttpRequest` /
432
+ `handleHttpRequests` pair built on them, discard the promise their chunk
433
+ sender returns, so a fast producer over a slow consumer accumulates without
434
+ bound. There is also no per-stream timeout and no chunking to a transport's
435
+ message size limit. `sendHttpRequest` and `handleHttpRequests` are marked
436
+ `@deprecated` in favour of `duplexOverPort` / `serveDuplexOverPort` from
437
+ `@statewalker/webrun-rpc` with `httpFetch` / `httpServe` from
438
+ `@statewalker/webrun-http-streams`, which do have backpressure. Both modes of
439
+ this package still use the deprecated pair internally. A caller that stops
440
+ reading early does tell the peer to stop producing.
441
+
442
+ ### Defaults that point at a file the build does not produce
443
+
444
+ `getRelayWindowMessageHandler`'s default `swUrl` and `SwPortHandler`'s default
445
+ worker URL both resolve to `index-sw.js` next to the module. No such file is
446
+ built. Pass `swUrl` / `serviceWorkerUrl` explicitly. Constructing a
447
+ `SwHttpAdapter` with neither `serviceWorkerUrl` nor `scope` fails with
448
+ `RangeError: Maximum call stack size exceeded`, because each default is
449
+ computed from the other.
565
450
 
566
451
  ### Constraints
567
452
 
568
- - **Request bodies are buffered on Firefox.** The stubs this package uses
569
- (`newHttpClientStub` / `newHttpServerStub` from
570
- [`@statewalker/webrun-http-streams`](../webrun-http-streams)) stream request
571
- bodies wherever the runtime implements `Request.prototype.body`. Firefox
572
- does not (checked against 146), so on that browser the whole request body is
573
- buffered into memory on both the sending and the receiving side — a large
574
- upload is a proportionally large allocation, and a handler that wanted to
575
- stream its request body cannot. Response streaming is unaffected on every
576
- browser. See that package's README for the details and for the Safari
577
- caveat.
578
- - **ServiceWorker scope rules apply.** A SW registered at `/public/sw-worker.js`
579
- only controls pages and fetches under `/public/`. If you need a broader
580
- scope, the SW script must be served with the
581
- `Service-Worker-Allowed` HTTP header, *or* live higher in the origin.
582
- - **A hard reload loads the page without its worker.** Handled — see
583
- [When the page is not controlled](#when-the-page-is-not-controlled) — as long
584
- as the worker answers `CLAIM`. A worker script of your own that
585
- `importScripts` this package's `sw-worker.js` or `relay-sw.js` does; one
586
- that does not should call `handleClaimRequests(self)`.
587
- - **`http://localhost` or HTTPS only.** Browsers refuse to register SWs
588
- on other `http://` origins.
589
- - **Relay mode needs an iframe-capable sandbox.** Pages with strict CSP
590
- that blocks `frame-src` to the relay origin can't use the relay path.
591
- - **Consumer-side `fetch()` only works from pages under the SW's scope.**
592
- When your caller is on another origin, use `callHttpService(request,
593
- …)` — it reaches the SW through the iframe's MessagePort and bypasses
594
- the browser's fetch routing.
453
+ - **Firefox buffers request bodies.** Firefox has no `Request.prototype.body`,
454
+ so the stubs read the whole request body into memory on both sides. Response
455
+ streaming is unaffected.
456
+ - **Scope rules apply.** A worker at `/public/sw-worker.js` controls only
457
+ `/public/`. A wider scope needs the `Service-Worker-Allowed` header, or the
458
+ script must live higher up.
459
+ - **Relay mode needs an iframe.** A page whose CSP blocks `frame-src` to the
460
+ relay origin cannot use it.
461
+ - **Plain `fetch()` works only from pages under the worker's scope.** A caller
462
+ on another origin uses `callHttpService`.
595
463
 
596
- ### Dependencies
464
+ ### Exports of the root entry
465
+
466
+ Relay, page side:
597
467
 
598
- Runtime:
468
+ | Export | Purpose |
469
+ | --- | --- |
470
+ | `newRemoteRelayChannel(opts?)` / `RemoteRelayChannel`, `RemoteRelayChannelOptions` | Embed the relay iframe; resolves `{ baseUrl, port, close() }`. |
471
+ | `initHttpService(handler, { key, path?, port })` | Register a service; resolves a cleanup that unregisters it. |
472
+ | `callHttpService(request, { key, port })` | Call a service through the port. |
473
+ | `ServiceOptions` | `{ key, path?, port }`. |
474
+ | `getRelayWindowMessageHandler(opts?)` / `RelayWindowHandlerOptions` | The relay iframe's `window.onmessage`; options `swUrl`, `scopeUrl`, `timeout`. |
475
+ | `initializeConnection(opts)` / `InitializeConnectionOptions` | Send `CONNECT` for a key; resolves a `MessagePort`, or `null` if refused. Extra fields ride in the payload. |
476
+ | `registerConnectionsHandler(opts)` / `RegisterConnectionsHandlerOptions` | Register a key (and optional `path`) and answer its `CONNECT`s; resolves a cleanup. |
477
+ | `splitServiceUrl(url, separator = "~")` / `SplitServiceUrl` | Parse `<origin>/~<key>/<path>` into `{ url, key, baseUrl, path }`. |
478
+
479
+ ServiceWorker lifecycle:
480
+
481
+ | Export | Purpose |
482
+ | --- | --- |
483
+ | `initServiceWorker({ swUrl, scopeUrl?, type?, timeout? })` / `InitServiceWorkerOptions` | Register a worker; resolves the controller, or the active worker if the page is not controlled. |
484
+ | `newServiceWorkerPort(registration?)` | A `MessagePort` bridged to the controller, or to `registration.active`. |
485
+ | `awaitActiveServiceWorker(registration, { timeout? })` | Resolves once `activated`. |
486
+ | `awaitServiceWorkerControl(registration, { timeout?, reloadIfUncontrolled? })` | Resolves once the page is controlled, asking the worker to claim it. |
487
+ | `handleClaimRequests(self)` | Worker side: answer `CLAIM` with `clients.claim()`. |
488
+ | `ServiceWorkerControlError`, `ServiceWorkerControlFailure` | Error class and its `reason` union. |
489
+ | `AwaitServiceWorkerOptions`, `AwaitServiceWorkerControlOptions` | Option types. |
490
+ | `DEFAULT_SERVICE_WORKER_TIMEOUT`, `CLAIM_CALL` | `30_000`, `"CLAIM"`. |
491
+
492
+ Messaging primitives:
493
+
494
+ | Export | Purpose |
495
+ | --- | --- |
496
+ | `callChannel(target, type, params, ...transfers)` / `handleChannelCalls(target, type, handler)` / `ChannelCallHandler` | One typed request/response over a `MessageTarget`; errors travel serialised. |
497
+ | `newInvokationChannel(opts)` / `InvocationChannel`, `NewInvocationChannelOptions` | Numbered invocations over one target. |
498
+ | `sendStream(port, input, params?)` / `handleStreams(port, handler)` / `StreamHandler` | Stream-shaped calls, one `MessageChannel` each. No backpressure. |
499
+ | `sendHttpRequest(port, request)` / `handleHttpRequests(port, handler)` | Deprecated. HTTP over the stream calls. |
500
+ | `newRegistry(onError?)` / `Registry`, `NewRegistryResult`, `CleanupAction` | Cleanup registry. |
501
+ | `MessageTarget`, `MessageSource`, `MessageSink`, `MessageListener` | Port view types, re-exported from `@statewalker/webrun-rpc`. |
502
+
503
+ The root entry also re-exports all of `@statewalker/webrun-streams` and
504
+ `@statewalker/webrun-http-streams`.
505
+
506
+ ### Dependencies
599
507
 
600
- - `@statewalker/webrun-http-streams` — the `newHttpClientStub` /
601
- `newHttpServerStub` pair this package's MessagePort transport is built
602
- on, plus `HttpError`. Workspace-local.
603
- - `@statewalker/webrun-streams` — iterator/stream primitives and
604
- serialisable errors. Workspace-local.
605
- - `idb-keyval` — tiny (<1 KB) IndexedDB KV used by both SW modes to keep
606
- client/service registrations across SW restarts.
508
+ All four runtime dependencies are bundled into `dist/`:
607
509
 
608
- Dev: TypeScript, vitest, rolldown, rimraf, `http-server` (for the
609
- `example:*` scripts), `@types/node` (catalog versions from the monorepo
610
- root), `playwright` and `vite` (for `test:browser`).
510
+ - `@statewalker/webrun-http-streams`: the client/server stubs the port
511
+ transport is built on, and `HttpError`.
512
+ - `@statewalker/webrun-streams`: iterator helpers and error serialisation.
513
+ - `@statewalker/webrun-rpc`: only the `MessageTarget` types.
514
+ - `idb-keyval`: a small IndexedDB key-value store that keeps registrations
515
+ across worker restarts.
611
516
 
612
- ## Tests
517
+ ### Tests
613
518
 
614
- - `pnpm test` — unit tests under Node, against the source.
615
- - `pnpm test:browser` — builds, then runs `tests/browser/` in real Chromium and
616
- Firefox through Playwright against the built bundles (first visit, normal
617
- reload, hard reload, second tab, the relay page, and the timeout and
618
- uncontrolled errors), plus `tests/dist/`, which builds a Vite consumer of
619
- the bundles. Needs the browsers: `npx playwright install chromium firefox`.
519
+ - `pnpm test`: unit tests under Node, against the source.
520
+ - `pnpm run test:browser`: builds, then runs `tests/browser/` in Chromium and
521
+ Firefox through Playwright against the built bundles, plus
522
+ `tests/packaging/`, which checks the published exports map. Needs the
523
+ browsers: `pnpm exec playwright install chromium firefox`.
620
524
 
621
525
  ## License
622
526
 
623
- MIT © statewalker — see [LICENSE](../../LICENSE).
527
+ MIT
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-browser",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ServiceWorker-based HTTP server for browsers, with relay and same-origin dispatch modes",
7
- "homepage": "https://github.com/statewalker/webrun-wire",
7
+ "bugs": {
8
+ "url": "https://github.com/statewalker/webrun-wire/issues"
9
+ },
10
+ "homepage": "https://github.com/statewalker/webrun-wire/tree/main/packages/webrun-http-browser#readme",
8
11
  "author": {
9
12
  "name": "Mikhail Kotelnikov",
10
13
  "email": "mikhail.kotelnikov@gmail.com"
@@ -12,7 +15,8 @@
12
15
  "license": "MIT",
13
16
  "repository": {
14
17
  "type": "git",
15
- "url": "git@github.com:statewalker/webrun-wire.git"
18
+ "url": "git+https://github.com/statewalker/webrun-wire.git",
19
+ "directory": "packages/webrun-http-browser"
16
20
  },
17
21
  "main": "./dist/index.js",
18
22
  "module": "./dist/index.js",
@@ -29,7 +33,6 @@
29
33
  "import": "./dist/sw.js"
30
34
  },
31
35
  "./relay-sw": {
32
- "source": "./src/relay-sw.ts",
33
36
  "default": "./dist/relay-sw.js"
34
37
  },
35
38
  "./relay-worker": {
@@ -38,7 +41,6 @@
38
41
  "import": "./dist/relay-worker.js"
39
42
  },
40
43
  "./sw-worker": {
41
- "source": "./src/sw-worker.ts",
42
44
  "default": "./dist/sw-worker.js"
43
45
  }
44
46
  },
@@ -51,9 +53,9 @@
51
53
  ],
52
54
  "dependencies": {
53
55
  "idb-keyval": "^6.3.0",
54
- "@statewalker/webrun-http-streams": "^0.2.4",
55
- "@statewalker/webrun-rpc": "^0.4.2",
56
- "@statewalker/webrun-streams": "^0.2.1"
56
+ "@statewalker/webrun-rpc": "^0.4.3",
57
+ "@statewalker/webrun-streams": "^0.2.2",
58
+ "@statewalker/webrun-http-streams": "^0.2.5"
57
59
  },
58
60
  "devDependencies": {
59
61
  "@biomejs/biome": "^2.5.15",