@statewalker/webrun-http-browser 0.6.3 → 0.6.5
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/README.md +403 -495
- package/dist/index.js +1 -1
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/sw/sw-dispatcher.d.ts +2 -1
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw.js +10 -7
- package/package.json +10 -6
- package/src/relay/index.ts +2 -2
- package/src/sw/sw-dispatcher.ts +11 -10
package/README.md
CHANGED
|
@@ -1,211 +1,174 @@
|
|
|
1
1
|
# @statewalker/webrun-http-browser
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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** (
|
|
11
|
-
|
|
12
|
-
- **Relay** (
|
|
13
|
-
|
|
14
|
-
a
|
|
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
|
|
19
|
-
|
|
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. **
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
control *and* in an embed you don't.
|
|
29
|
+
The same handler code works in both modes.
|
|
32
30
|
|
|
33
|
-
##
|
|
31
|
+
## How to use
|
|
34
32
|
|
|
35
33
|
```sh
|
|
36
|
-
|
|
34
|
+
pnpm add @statewalker/webrun-http-browser
|
|
37
35
|
```
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
41
|
+
### Entry points
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
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/
|
|
80
|
+
url: new URL("https://my-relay.example/relay.html"),
|
|
80
81
|
});
|
|
81
82
|
|
|
82
|
-
// 2. Register a handler for service "FS".
|
|
83
|
-
const
|
|
84
|
-
|
|
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
|
-
//
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
138
|
+
decorateResponse: (response) => response,
|
|
157
139
|
});
|
|
158
140
|
```
|
|
159
141
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
159
|
+
// relay-sw.js, served next to your relay page
|
|
197
160
|
self.RELAY_OPTIONS = {
|
|
198
|
-
exclude: (url) =>
|
|
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
|
|
167
|
+
importScripts("/path/to/@statewalker/webrun-http-browser/dist/relay-sw.js");
|
|
202
168
|
```
|
|
203
169
|
|
|
204
|
-
|
|
205
|
-
|
|
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,70 @@ import type { RelayServiceWorkerOptions } from "@statewalker/webrun-http-browser
|
|
|
213
176
|
declare const self: ServiceWorkerGlobalScope & { RELAY_OPTIONS?: RelayServiceWorkerOptions };
|
|
214
177
|
```
|
|
215
178
|
|
|
216
|
-
|
|
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
|
-
|
|
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
|
+
Without `swUrl` the handler registers the bundle's own `dist/relay-sw.js`,
|
|
195
|
+
and without `scopeUrl` the scope is that worker's directory, `dist/`: the
|
|
196
|
+
widest scope a script there may claim without a `Service-Worker-Allowed`
|
|
197
|
+
header. A relay page that serves services anywhere else passes both, as above.
|
|
198
|
+
`timeout` bounds the wait for the worker to activate. If the worker cannot start, every call the parent makes on the port
|
|
199
|
+
is answered with that error, so the parent's `initHttpService` /
|
|
200
|
+
`callHttpService` reject instead of hanging.
|
|
225
201
|
|
|
226
|
-
|
|
202
|
+
### Same-origin mode
|
|
227
203
|
|
|
228
204
|
```ts
|
|
229
205
|
import { SwHttpAdapter } from "@statewalker/webrun-http-browser/sw";
|
|
230
206
|
|
|
231
|
-
const KEY = "demo"; // also the first URL segment the
|
|
207
|
+
const KEY = "demo"; // also the first URL segment the worker routes here
|
|
232
208
|
const adapter = new SwHttpAdapter({
|
|
233
209
|
key: KEY,
|
|
234
210
|
serviceWorkerUrl: new URL("./sw-worker.js", import.meta.url).toString(),
|
|
235
211
|
});
|
|
236
212
|
await adapter.start();
|
|
237
213
|
|
|
238
|
-
const { baseUrl } = await adapter.register(`${KEY}/api/`, async (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
});
|
|
242
|
-
});
|
|
214
|
+
const { baseUrl, remove } = await adapter.register(`${KEY}/api/`, async () =>
|
|
215
|
+
Response.json({ now: Date.now() }),
|
|
216
|
+
);
|
|
243
217
|
|
|
244
|
-
|
|
218
|
+
const res = await fetch(`${baseUrl}anything`); // answered by the handler above
|
|
245
219
|
```
|
|
246
220
|
|
|
247
|
-
|
|
248
|
-
|
|
221
|
+
The worker script is a one-line loader served next to the app pages, so the
|
|
222
|
+
worker's default scope covers them:
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
// sw-worker.js
|
|
226
|
+
importScripts("/path/to/@statewalker/webrun-http-browser/dist/sw-worker.js");
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`SwHttpAdapter` options:
|
|
249
230
|
|
|
250
231
|
| Option | Default | Meaning |
|
|
251
232
|
| --- | --- | --- |
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
233
|
+
| `key` | required | First URL segment under the scope that the worker routes to this page. |
|
|
234
|
+
| `serviceWorkerUrl` | `sw-worker.js` at the scope | Resolved against `location.href`. An unresolvable value throws `Invalid serviceWorkerUrl: "<value>" (relative to <href>)`. |
|
|
235
|
+
| `scope` | directory of `serviceWorkerUrl` | Registration scope. |
|
|
236
|
+
| `timeout` | `30_000` | Upper bound, in ms, for activation, control and the adapter's handshake. Past it `start()` rejects with a `ServiceWorkerControlError`. |
|
|
237
|
+
| `reloadIfUncontrolled` | `false` | Reload the page once instead of rejecting when it stays uncontrolled. |
|
|
238
|
+
|
|
239
|
+
`register(prefix, handler)` resolves `{ baseUrl, prefix, remove() }`. The
|
|
240
|
+
prefix must start with the adapter's `key`.
|
|
241
|
+
|
|
242
|
+
### Same-origin mode: a page that is not controlled
|
|
273
243
|
|
|
274
244
|
```ts
|
|
275
245
|
try {
|
|
@@ -280,344 +250,282 @@ try {
|
|
|
280
250
|
}
|
|
281
251
|
```
|
|
282
252
|
|
|
283
|
-
Check `name` or `reason`, not `instanceof`: each bundle
|
|
284
|
-
|
|
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).
|
|
253
|
+
Check `name` or `reason`, not `instanceof`: each bundle carries its own copy of
|
|
254
|
+
the class. After a failure, calling `start()` again retries.
|
|
297
255
|
|
|
298
256
|
### Running the bundled examples
|
|
299
257
|
|
|
300
258
|
```sh
|
|
301
|
-
pnpm run example:same-origin # public/index.html
|
|
302
|
-
pnpm run example:relay-site # demo/demo-1.html
|
|
303
|
-
pnpm run example:relay-files # demo/demo-2.html
|
|
304
|
-
pnpm run serve #
|
|
259
|
+
pnpm run example:same-origin # build, serve on :5173, open public/index.html
|
|
260
|
+
pnpm run example:relay-site # build, serve on :5173, open demo/demo-1.html
|
|
261
|
+
pnpm run example:relay-files # build, serve on :5173, open demo/demo-2.html
|
|
262
|
+
pnpm run serve # static server on :5173, no build, no browser
|
|
305
263
|
```
|
|
306
264
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
265
|
+
Open them through `http://localhost:5173/…`; ServiceWorkers do not register
|
|
266
|
+
from `file://`. The relay demos fetch their service through
|
|
267
|
+
`<relay-base>/~KEY/` URLs under `public-relay/`, which the relay worker
|
|
268
|
+
does not claim; see
|
|
269
|
+
[`/~<key>/` is recognised only at the origin root](#key-is-recognised-only-at-the-origin-root).
|
|
433
270
|
|
|
434
|
-
|
|
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. |
|
|
271
|
+
## Internals
|
|
460
272
|
|
|
461
|
-
###
|
|
273
|
+
### How a request travels
|
|
462
274
|
|
|
463
|
-
|
|
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(...)`. |
|
|
275
|
+
Relay mode:
|
|
468
276
|
|
|
469
|
-
|
|
277
|
+
```
|
|
278
|
+
caller tab relay origin serving page
|
|
279
|
+
fetch(<relay>/~KEY/x) ──► relay worker ── CONNECT(key) ──► relay iframe ──port──► initHttpService handler
|
|
280
|
+
(mount table, │ │
|
|
281
|
+
IndexedDB) └──────── MessagePort per call ◄───────────┘
|
|
282
|
+
callHttpService(req, {key, port}) ──port──► relay iframe ──► relay worker ──► same path
|
|
283
|
+
```
|
|
470
284
|
|
|
471
|
-
|
|
285
|
+
Same-origin mode:
|
|
472
286
|
|
|
473
287
|
```
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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).
|
|
288
|
+
page fetch(<scope>/KEY/...) ──► same-origin worker ── first path segment = KEY ──► page's port
|
|
289
|
+
└► SwHttpAdapter
|
|
290
|
+
(first registered prefix that matches)
|
|
520
291
|
```
|
|
521
292
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
293
|
+
Each call opens its own `MessageChannel`. The request is turned into a
|
|
294
|
+
`SerializedHttpEnvelope` by `newHttpClientStub` and rebuilt on the other side
|
|
295
|
+
by `newHttpServerStub` (both from `@statewalker/webrun-http-streams`), with the
|
|
296
|
+
body streamed as chunks over the port.
|
|
297
|
+
|
|
298
|
+
### Every bundle is self-contained
|
|
299
|
+
|
|
300
|
+
This package does not externalise its dependencies. Its own HTML loads
|
|
301
|
+
`../dist/index.js` from a static host with no import map, and the two IIFE
|
|
302
|
+
workers are loaded with `importScripts`, which cannot resolve a bare
|
|
303
|
+
specifier. So each of the five outputs is one file with `idb-keyval`,
|
|
304
|
+
`@statewalker/webrun-streams` and `@statewalker/webrun-http-streams` inlined,
|
|
305
|
+
built from its own rolldown config so no chunks are shared. The cost is
|
|
306
|
+
duplicated code across bundles, and that `instanceof` does not hold across this
|
|
307
|
+
package's boundary.
|
|
308
|
+
|
|
309
|
+
The workers are IIFE rather than module workers so a classic `importScripts`
|
|
310
|
+
loader can pull them in. The relay page registers its worker with no `type`.
|
|
311
|
+
|
|
312
|
+
Page-side defaults resolve against a `moduleUrl` variable instead of a literal
|
|
313
|
+
`new URL("…", import.meta.url)`. Bundlers such as Vite rewrite the literal form
|
|
314
|
+
into an emitted asset at build time, which made consumers ship a dead copy of
|
|
315
|
+
`dist/index.js`.
|
|
316
|
+
|
|
317
|
+
### Relay routing: a request that is nobody's goes to the network
|
|
318
|
+
|
|
319
|
+
For each `fetch` the relay worker decides, in this order:
|
|
320
|
+
|
|
321
|
+
1. Another origin: not the relay's.
|
|
322
|
+
2. `exclude(url)` is true: not the relay's.
|
|
323
|
+
3. The longest matching mount prefix, then `match` predicates in registration
|
|
324
|
+
order.
|
|
325
|
+
4. The `/~<key>/` spelling.
|
|
326
|
+
|
|
327
|
+
A request that matches nothing is not answered at all: the worker does not call
|
|
328
|
+
`respondWith`, so the browser performs it as if no worker were installed. That
|
|
329
|
+
is what lets a host serve its own files from the relay origin, and it is why a
|
|
330
|
+
root mount needs `exclude`.
|
|
331
|
+
|
|
332
|
+
A root mount claims every path under the scope, including the host's own
|
|
333
|
+
navigation. `exclude` needs three entries: the relay page, the worker script,
|
|
334
|
+
and the host's own entry page. Miss the third and nothing fails until the first
|
|
335
|
+
reload, which then requests the entry page through the mount and cannot get
|
|
336
|
+
back.
|
|
337
|
+
|
|
338
|
+
Mounts live in memory and registrations in IndexedDB, so a restarted worker
|
|
339
|
+
reads the registrations back before routing. Requests that arrive before that
|
|
340
|
+
read settles are claimed and, if nobody's, re-issued with `fetch(request)`.
|
|
341
|
+
After it settles the decision is synchronous again, which keeps navigation
|
|
342
|
+
preload and `cache: "only-if-cached"` working. A failed restore is logged
|
|
343
|
+
(`[relay] failed to restore mounts from the registry`) and routing continues
|
|
344
|
+
with whatever the table holds.
|
|
345
|
+
|
|
346
|
+
### `/~<key>/` is recognised only at the origin root
|
|
347
|
+
|
|
348
|
+
`splitServiceUrl` is anchored to the start of the pathname:
|
|
349
|
+
`https://host/~FS/a/b` yields key `FS`, while
|
|
350
|
+
`http://localhost:5173/public-relay/~FS/a` yields no key. So a relay worker
|
|
351
|
+
whose scope is a subdirectory, like the shipped `public-relay/`, does not claim
|
|
352
|
+
`fetch()` calls to `<scope>/~<key>/…`; they go to the network. `callHttpService`
|
|
353
|
+
still works there, since it does not depend on URL routing, and so does a
|
|
354
|
+
service registered with a `path` inside the scope. Query strings and fragments
|
|
355
|
+
are ignored, so `?q=~foo` is never read as a service.
|
|
356
|
+
|
|
357
|
+
### Who may register a key
|
|
358
|
+
|
|
359
|
+
The default `takeover: "last-wins"` lets the last page that registers a key
|
|
360
|
+
take it. With mounts, a rogue or buggy same-origin page could then claim the
|
|
361
|
+
origin root, and the registration persists in IndexedDB across page loads and
|
|
362
|
+
worker restarts. On any origin where more than the host's own page can reach
|
|
363
|
+
the relay, set `takeover: "first-wins"` and `canRegister` together for a mount
|
|
364
|
+
at `/`.
|
|
365
|
+
|
|
366
|
+
A `CONNECT` is delivered only to the service named by its key. Services on one
|
|
367
|
+
port that are not the addressee stay silent; a reply of `false` would reach the
|
|
368
|
+
worker first and turn into a 403.
|
|
369
|
+
|
|
370
|
+
### Relay error responses
|
|
371
|
+
|
|
372
|
+
When the relay worker cannot hand a request to a service, it answers with JSON
|
|
373
|
+
(`{ status, statusText, message, url, key, baseUrl, path }`):
|
|
374
|
+
|
|
375
|
+
| Cause | Status |
|
|
376
|
+
| --- | --- |
|
|
377
|
+
| No live client for the key (the page closed) | `410`, `Error 410: Resource Gone` |
|
|
378
|
+
| The page refused the connection | `403`, `Error 403: Forbidden` |
|
|
379
|
+
| Anything else thrown | `500`, with `statusText` `Bad Request` (from `HttpError.fromError`) |
|
|
380
|
+
|
|
381
|
+
### Same-origin routing
|
|
382
|
+
|
|
383
|
+
The worker takes the first path segment under its scope as the key and forwards
|
|
384
|
+
the request to the page that registered it. A key that was registered at some
|
|
385
|
+
point but has no live page answers `404 Not Found (no active handler)` rather
|
|
386
|
+
than falling through to the network: a dev server's SPA fallback would
|
|
387
|
+
otherwise serve the app shell for every site URL. Any other URL is passed
|
|
388
|
+
through with `fetch(event.request)` unchanged; rebuilding the request would
|
|
389
|
+
drop `mode`, and the constructor throws for `only-if-cached` navigations. On the
|
|
390
|
+
page, a request matching no registered prefix gets `404 Error 404: Not found`.
|
|
391
|
+
|
|
392
|
+
The worker keeps the claimed keys and client ids in IndexedDB (`claimedKeys`,
|
|
393
|
+
`clientIds`) and asks each known client for a fresh port when it restarts. The
|
|
394
|
+
relay keeps its registry under `clientsIds` as `{ clientId, path }` entries and
|
|
395
|
+
still reads the older bare-client-id shape.
|
|
396
|
+
|
|
397
|
+
`SwPortHandler.stop()` unregisters only the registration its own `start()`
|
|
398
|
+
made; other workers on the origin stay registered. After a `start()` that
|
|
399
|
+
rejected, `stop()` unregisters nothing, since that attempt kept the worker.
|
|
400
|
+
|
|
401
|
+
### A page can load uncontrolled although its worker is active
|
|
402
|
+
|
|
403
|
+
A hard reload (Ctrl+Shift+R / Cmd+Shift+R) bypasses ServiceWorkers for that
|
|
404
|
+
load, and the worker's `clients.claim()` already ran at activation, so nothing
|
|
405
|
+
hands the page to it. Firefox has also left a second page of a running worker
|
|
406
|
+
uncontrolled.
|
|
407
|
+
|
|
408
|
+
Same-origin mode needs control, because only a controlled page's `fetch()`
|
|
409
|
+
reaches the worker. `awaitServiceWorkerControl` (used by `SwHttpAdapter.start()`)
|
|
410
|
+
sends a `CLAIM` call, which this package's workers answer with
|
|
411
|
+
`clients.claim()`, and waits for `controllerchange` plus a 1 s grace period.
|
|
412
|
+
If control still does not come it rejects with `ServiceWorkerControlError`,
|
|
413
|
+
`reason: "uncontrolled"`, and a message that starts
|
|
414
|
+
`This page is not controlled by its ServiceWorker …`. With
|
|
415
|
+
`reloadIfUncontrolled: true` it reloads once instead; a `sessionStorage`
|
|
416
|
+
marker makes a second uncontrolled load reject rather than loop. A worker of
|
|
417
|
+
your own should call `handleClaimRequests(self)`.
|
|
418
|
+
|
|
419
|
+
Relay mode needs only a worker to message, so an uncontrolled relay page
|
|
420
|
+
bridges to `registration.active`. A page excluded from a root mount is not
|
|
421
|
+
answered by the worker and can load uncontrolled in Firefox; call
|
|
422
|
+
`awaitServiceWorkerControl(registration)` before relying on its `fetch()`.
|
|
423
|
+
|
|
424
|
+
`ServiceWorkerControlError.reason` is one of:
|
|
425
|
+
|
|
426
|
+
- `activation-timeout`: the worker did not activate within `timeout`
|
|
427
|
+
(`ServiceWorker "<url>" (scope <scope>) did not activate within <n> ms …`);
|
|
428
|
+
- `uncontrolled`: active, but the page is not controlled;
|
|
429
|
+
- `unresponsive`: controls the page but did not answer the adapter's
|
|
430
|
+
`UPDATE_COMMUNICATION_PORT` handshake in time.
|
|
431
|
+
|
|
432
|
+
### The port transport has no backpressure
|
|
433
|
+
|
|
434
|
+
`sendStream` / `handleStreams`, and the `sendHttpRequest` /
|
|
435
|
+
`handleHttpRequests` pair built on them, discard the promise their chunk
|
|
436
|
+
sender returns, so a fast producer over a slow consumer accumulates without
|
|
437
|
+
bound. There is also no per-stream timeout and no chunking to a transport's
|
|
438
|
+
message size limit. `sendHttpRequest` and `handleHttpRequests` are marked
|
|
439
|
+
`@deprecated` in favour of `duplexOverPort` / `serveDuplexOverPort` from
|
|
440
|
+
`@statewalker/webrun-rpc` with `httpFetch` / `httpServe` from
|
|
441
|
+
`@statewalker/webrun-http-streams`, which do have backpressure. Both modes of
|
|
442
|
+
this package still use the deprecated pair internally. A caller that stops
|
|
443
|
+
reading early does tell the peer to stop producing.
|
|
444
|
+
|
|
445
|
+
### The adapter's default worker url follows the scope
|
|
446
|
+
|
|
447
|
+
Without `serviceWorkerUrl`, `SwPortHandler` (and so `SwHttpAdapter`) uses
|
|
448
|
+
`sw-worker.js` at its scope, the scope resolved against the bundle's url. With
|
|
449
|
+
`scope: "/dist/"` that is the shipped `dist/sw-worker.js`; with `scope: "/"` it
|
|
450
|
+
is `/sw-worker.js`, a loader the host must serve there. Constructing a
|
|
451
|
+
`SwHttpAdapter` with neither `serviceWorkerUrl` nor `scope` fails with
|
|
452
|
+
`RangeError: Maximum call stack size exceeded`, because each default is
|
|
453
|
+
computed from the other.
|
|
565
454
|
|
|
566
455
|
### Constraints
|
|
567
456
|
|
|
568
|
-
- **
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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.
|
|
457
|
+
- **Firefox buffers request bodies.** Firefox has no `Request.prototype.body`,
|
|
458
|
+
so the stubs read the whole request body into memory on both sides. Response
|
|
459
|
+
streaming is unaffected.
|
|
460
|
+
- **Scope rules apply.** A worker at `/public/sw-worker.js` controls only
|
|
461
|
+
`/public/`. A wider scope needs the `Service-Worker-Allowed` header, or the
|
|
462
|
+
script must live higher up.
|
|
463
|
+
- **Relay mode needs an iframe.** A page whose CSP blocks `frame-src` to the
|
|
464
|
+
relay origin cannot use it.
|
|
465
|
+
- **Plain `fetch()` works only from pages under the worker's scope.** A caller
|
|
466
|
+
on another origin uses `callHttpService`.
|
|
595
467
|
|
|
596
|
-
###
|
|
468
|
+
### Exports of the root entry
|
|
469
|
+
|
|
470
|
+
Relay, page side:
|
|
597
471
|
|
|
598
|
-
|
|
472
|
+
| Export | Purpose |
|
|
473
|
+
| --- | --- |
|
|
474
|
+
| `newRemoteRelayChannel(opts?)` / `RemoteRelayChannel`, `RemoteRelayChannelOptions` | Embed the relay iframe; resolves `{ baseUrl, port, close() }`. |
|
|
475
|
+
| `initHttpService(handler, { key, path?, port })` | Register a service; resolves a cleanup that unregisters it. |
|
|
476
|
+
| `callHttpService(request, { key, port })` | Call a service through the port. |
|
|
477
|
+
| `ServiceOptions` | `{ key, path?, port }`. |
|
|
478
|
+
| `getRelayWindowMessageHandler(opts?)` / `RelayWindowHandlerOptions` | The relay iframe's `window.onmessage`; options `swUrl`, `scopeUrl`, `timeout`. |
|
|
479
|
+
| `initializeConnection(opts)` / `InitializeConnectionOptions` | Send `CONNECT` for a key; resolves a `MessagePort`, or `null` if refused. Extra fields ride in the payload. |
|
|
480
|
+
| `registerConnectionsHandler(opts)` / `RegisterConnectionsHandlerOptions` | Register a key (and optional `path`) and answer its `CONNECT`s; resolves a cleanup. |
|
|
481
|
+
| `splitServiceUrl(url, separator = "~")` / `SplitServiceUrl` | Parse `<origin>/~<key>/<path>` into `{ url, key, baseUrl, path }`. |
|
|
482
|
+
|
|
483
|
+
ServiceWorker lifecycle:
|
|
484
|
+
|
|
485
|
+
| Export | Purpose |
|
|
486
|
+
| --- | --- |
|
|
487
|
+
| `initServiceWorker({ swUrl, scopeUrl?, type?, timeout? })` / `InitServiceWorkerOptions` | Register a worker; resolves the controller, or the active worker if the page is not controlled. |
|
|
488
|
+
| `newServiceWorkerPort(registration?)` | A `MessagePort` bridged to the controller, or to `registration.active`. |
|
|
489
|
+
| `awaitActiveServiceWorker(registration, { timeout? })` | Resolves once `activated`. |
|
|
490
|
+
| `awaitServiceWorkerControl(registration, { timeout?, reloadIfUncontrolled? })` | Resolves once the page is controlled, asking the worker to claim it. |
|
|
491
|
+
| `handleClaimRequests(self)` | Worker side: answer `CLAIM` with `clients.claim()`. |
|
|
492
|
+
| `ServiceWorkerControlError`, `ServiceWorkerControlFailure` | Error class and its `reason` union. |
|
|
493
|
+
| `AwaitServiceWorkerOptions`, `AwaitServiceWorkerControlOptions` | Option types. |
|
|
494
|
+
| `DEFAULT_SERVICE_WORKER_TIMEOUT`, `CLAIM_CALL` | `30_000`, `"CLAIM"`. |
|
|
495
|
+
|
|
496
|
+
Messaging primitives:
|
|
497
|
+
|
|
498
|
+
| Export | Purpose |
|
|
499
|
+
| --- | --- |
|
|
500
|
+
| `callChannel(target, type, params, ...transfers)` / `handleChannelCalls(target, type, handler)` / `ChannelCallHandler` | One typed request/response over a `MessageTarget`; errors travel serialised. |
|
|
501
|
+
| `newInvokationChannel(opts)` / `InvocationChannel`, `NewInvocationChannelOptions` | Numbered invocations over one target. |
|
|
502
|
+
| `sendStream(port, input, params?)` / `handleStreams(port, handler)` / `StreamHandler` | Stream-shaped calls, one `MessageChannel` each. No backpressure. |
|
|
503
|
+
| `sendHttpRequest(port, request)` / `handleHttpRequests(port, handler)` | Deprecated. HTTP over the stream calls. |
|
|
504
|
+
| `newRegistry(onError?)` / `Registry`, `NewRegistryResult`, `CleanupAction` | Cleanup registry. |
|
|
505
|
+
| `MessageTarget`, `MessageSource`, `MessageSink`, `MessageListener` | Port view types, re-exported from `@statewalker/webrun-rpc`. |
|
|
506
|
+
|
|
507
|
+
The root entry also re-exports all of `@statewalker/webrun-streams` and
|
|
508
|
+
`@statewalker/webrun-http-streams`.
|
|
509
|
+
|
|
510
|
+
### Dependencies
|
|
599
511
|
|
|
600
|
-
|
|
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.
|
|
512
|
+
All four runtime dependencies are bundled into `dist/`:
|
|
607
513
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
514
|
+
- `@statewalker/webrun-http-streams`: the client/server stubs the port
|
|
515
|
+
transport is built on, and `HttpError`.
|
|
516
|
+
- `@statewalker/webrun-streams`: iterator helpers and error serialisation.
|
|
517
|
+
- `@statewalker/webrun-rpc`: only the `MessageTarget` types.
|
|
518
|
+
- `idb-keyval`: a small IndexedDB key-value store that keeps registrations
|
|
519
|
+
across worker restarts.
|
|
611
520
|
|
|
612
|
-
|
|
521
|
+
### Tests
|
|
613
522
|
|
|
614
|
-
- `pnpm test
|
|
615
|
-
- `pnpm test:browser
|
|
616
|
-
Firefox through Playwright against the built bundles
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
the bundles. Needs the browsers: `npx playwright install chromium firefox`.
|
|
523
|
+
- `pnpm test`: unit tests under Node, against the source.
|
|
524
|
+
- `pnpm run test:browser`: builds, then runs `tests/browser/` in Chromium and
|
|
525
|
+
Firefox through Playwright against the built bundles, plus
|
|
526
|
+
`tests/packaging/`, which checks the published exports map. Needs the
|
|
527
|
+
browsers: `pnpm exec playwright install chromium firefox`.
|
|
620
528
|
|
|
621
529
|
## License
|
|
622
530
|
|
|
623
|
-
MIT
|
|
531
|
+
MIT
|