@statewalker/webrun-http-browser 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -12
- package/dist/core/deadline.d.ts +7 -0
- package/dist/core/deadline.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +72 -0
- package/dist/core/service-worker-control.d.ts.map +1 -0
- package/dist/index.js +328 -67
- package/dist/relay/index-sw.d.ts +86 -1
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +32 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay/mount-table.d.ts +48 -0
- package/dist/relay/mount-table.d.ts.map +1 -0
- package/dist/relay/split-service-url.d.ts.map +1 -1
- package/dist/relay-sw.js +293 -50
- package/dist/relay-worker.d.ts +20 -0
- package/dist/relay-worker.d.ts.map +1 -0
- package/dist/relay-worker.js +1899 -0
- package/dist/sw/sw-dispatcher.d.ts +22 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw-worker.js +23 -2
- package/dist/sw.js +219 -36
- package/package.json +9 -1
- package/src/core/deadline.ts +31 -0
- package/src/core/index.ts +1 -0
- package/src/core/service-worker-control.ts +243 -0
- package/src/relay/index-sw.ts +293 -37
- package/src/relay/index.ts +125 -51
- package/src/relay/mount-table.ts +115 -0
- package/src/relay/split-service-url.ts +73 -13
- package/src/relay-sw.ts +11 -3
- package/src/relay-worker.ts +20 -0
- package/src/sw/sw-dispatcher.ts +72 -45
- package/public-relay/heartbeat.js +0 -31
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026 statewalker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -48,8 +48,9 @@ npm install @statewalker/webrun-http-browser
|
|
|
48
48
|
| Subpath | Purpose |
|
|
49
49
|
| --- | --- |
|
|
50
50
|
| `@statewalker/webrun-http-browser` | Page-side relay API: `newRemoteRelayChannel`, `initHttpService`, `callHttpService`, `splitServiceUrl`, `initServiceWorker`, `newServiceWorkerPort`, `getRelayWindowMessageHandler`; the MessagePort call primitives (`callChannel`, `handleChannelCalls`, `newInvokationChannel`, `sendStream`, `handleStreams`, `newRegistry`); plus everything re-exported from `@statewalker/webrun-http-streams` (`HttpError`, the client/server stubs), `@statewalker/webrun-streams` (stream and error helpers) and the `MessageTarget` family from `@statewalker/webrun-rpc` |
|
|
51
|
-
| `@statewalker/webrun-http-browser/sw` | Same-origin adapter classes: `SwHttpAdapter` (page), `SwHttpDispatcher` (SW), `startHttpDispatcher` bootstrap |
|
|
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 |
|
|
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`) |
|
|
53
54
|
| `@statewalker/webrun-http-browser/sw-worker` | IIFE bundle of the same-origin SW runtime — ditto, for same-origin apps |
|
|
54
55
|
|
|
55
56
|
## Examples
|
|
@@ -101,6 +102,125 @@ const res = await callHttpService(
|
|
|
101
102
|
serving a mini site; [`demo/demo-2.html`](./demo/demo-2.html) pipes a
|
|
102
103
|
local-disk folder (File System Access API) through it.
|
|
103
104
|
|
|
105
|
+
### Mounting a service at a path
|
|
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:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const connection = await newRemoteRelayChannel(/* … */);
|
|
114
|
+
|
|
115
|
+
await initHttpService(appHandler, { key: "app", path: "/", port: connection.port });
|
|
116
|
+
await initHttpService(meshHandler, { key: "mesh", path: "/peers/", port: connection.port });
|
|
117
|
+
```
|
|
118
|
+
|
|
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
|
|
139
|
+
prefix itself.
|
|
140
|
+
|
|
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):
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { startRelayServiceWorker } from "@statewalker/webrun-http-browser/relay-worker";
|
|
148
|
+
|
|
149
|
+
startRelayServiceWorker(self, {
|
|
150
|
+
exclude: (url) =>
|
|
151
|
+
url.pathname === "/index.html" ||
|
|
152
|
+
url.pathname === "/relay.html" ||
|
|
153
|
+
url.pathname === "/relay-sw.js",
|
|
154
|
+
takeover: "first-wins",
|
|
155
|
+
canRegister: (client, _key) => new URL(client.url).pathname === "/relay.html",
|
|
156
|
+
decorateResponse: (response) => withMyHeaders(response),
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
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:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
// relay-sw.js — served next to your relay page.
|
|
197
|
+
self.RELAY_OPTIONS = {
|
|
198
|
+
exclude: (url) => url.pathname === "/index.html" || url.pathname === "/relay.html" || url.pathname === "/relay-sw.js",
|
|
199
|
+
takeover: "first-wins",
|
|
200
|
+
};
|
|
201
|
+
importScripts("/path/to/node_modules/@statewalker/webrun-http-browser/dist/relay-sw.js");
|
|
202
|
+
```
|
|
203
|
+
|
|
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:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import type { RelayServiceWorkerOptions } from "@statewalker/webrun-http-browser/relay-worker";
|
|
212
|
+
|
|
213
|
+
declare const self: ServiceWorkerGlobalScope & { RELAY_OPTIONS?: RelayServiceWorkerOptions };
|
|
214
|
+
```
|
|
215
|
+
|
|
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. |
|
|
223
|
+
|
|
104
224
|
### Same-origin mode
|
|
105
225
|
|
|
106
226
|
Your page registers its own SW, handlers are local to the page:
|
|
@@ -124,6 +244,45 @@ const { baseUrl } = await adapter.register(`${KEY}/api/`, async (request) => {
|
|
|
124
244
|
// fetch(`${baseUrl}anything`) is intercepted by the SW.
|
|
125
245
|
```
|
|
126
246
|
|
|
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:
|
|
249
|
+
|
|
250
|
+
| Option | Default | Meaning |
|
|
251
|
+
| --- | --- | --- |
|
|
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.
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
try {
|
|
276
|
+
await adapter.start();
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if ((error as Error).name === "ServiceWorkerControlError") showReloadPrompt();
|
|
279
|
+
else throw error;
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Check `name` or `reason`, not `instanceof`: each bundle of this package carries
|
|
284
|
+
its own copy of the class.
|
|
285
|
+
|
|
127
286
|
The SW script itself ships as a pre-built IIFE bundle. Put a tiny loader
|
|
128
287
|
next to your app pages so the SW's default scope covers them:
|
|
129
288
|
|
|
@@ -238,21 +397,28 @@ imports keep working after those extractions. Its own surface is below.
|
|
|
238
397
|
| `newRemoteRelayChannel(opts?)` | function | Embeds the hidden relay iframe, handshakes a `MessageChannel`, resolves a `RemoteRelayChannel`. |
|
|
239
398
|
| `RemoteRelayChannelOptions` | interface | `baseUrl`, `url`, `container` — where the relay lives and what to append the iframe to. |
|
|
240
399
|
| `RemoteRelayChannel` | interface | `{ baseUrl, port, close() }`. |
|
|
241
|
-
| `initHttpService(handler, opts)` | function | Registers `handler` as the server for a service `key` on the relay
|
|
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. |
|
|
242
401
|
| `callHttpService(request, opts)` | function | Sends a `Request` to the service under `key`; resolves its `Response`. |
|
|
243
|
-
| `ServiceOptions` | interface | `{ key: string; port: MessageTarget }` — shared by the two above. |
|
|
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>/`. |
|
|
244
403
|
| `getRelayWindowMessageHandler(opts?)` | function | The `window.onmessage` handler that runs *inside* the relay iframe. |
|
|
245
404
|
| `RelayWindowHandlerOptions` | interface | `swUrl`, `scopeUrl` for that handler. |
|
|
246
|
-
| `splitServiceUrl(url, separator?)` | function | Splits a relay URL into service key + remaining path (default separator `~`). |
|
|
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. |
|
|
247
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). |
|
|
248
409
|
|
|
249
410
|
### ServiceWorker lifecycle
|
|
250
411
|
|
|
251
412
|
| Export | Kind | Purpose |
|
|
252
413
|
| --- | --- | --- |
|
|
253
|
-
| `initServiceWorker(opts)` | function | Registers a SW and resolves once it is activated
|
|
254
|
-
| `InitServiceWorkerOptions` | interface | `{ swUrl, scopeUrl?, type? }`. |
|
|
255
|
-
| `newServiceWorkerPort()` | function | A `MessagePort` that transparently bridges to the controlling SW. |
|
|
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"`. |
|
|
256
422
|
|
|
257
423
|
### Connection registry
|
|
258
424
|
|
|
@@ -297,7 +463,7 @@ imports keep working after those extractions. Its own surface is below.
|
|
|
297
463
|
| Entry | Purpose |
|
|
298
464
|
| --- | --- |
|
|
299
465
|
| `@statewalker/webrun-http-browser/sw` | `SwHttpAdapter` — the same-origin ServiceWorker adapter. |
|
|
300
|
-
| `@statewalker/webrun-http-browser/relay-sw` | IIFE relay SW runtime, loadable via `importScripts(...)`. |
|
|
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. |
|
|
301
467
|
| `@statewalker/webrun-http-browser/sw-worker` | IIFE same-origin SW runtime, loadable via `importScripts(...)`. |
|
|
302
468
|
|
|
303
469
|
## Internals
|
|
@@ -310,10 +476,14 @@ src/
|
|
|
310
476
|
│ ├── data-calls.ts │ Transport primitives over a
|
|
311
477
|
│ ├── data-channels.ts │ `MessageTarget`: one-shot
|
|
312
478
|
│ ├── message-target.ts │ `callChannel` / `handleChannelCalls`,
|
|
313
|
-
│
|
|
479
|
+
│ ├── registry.ts │ the request/response
|
|
314
480
|
│ │ `newInvokationChannel`, streaming
|
|
315
|
-
│
|
|
481
|
+
│ └── service-worker-control.ts │ `sendStream` / `handleStreams` with
|
|
316
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.
|
|
317
487
|
│ │ Also re-exports
|
|
318
488
|
│ │ `@statewalker/webrun-streams`.
|
|
319
489
|
│ ┘
|
|
@@ -378,6 +548,17 @@ src/
|
|
|
378
548
|
backpressure — each `next(value)` returns a `Promise<boolean>` that resolves
|
|
379
549
|
once the consumer has dequeued — and drains in-flight producers on consumer
|
|
380
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`.
|
|
381
562
|
- **SW client registry is IndexedDB-persisted**. Both `SwPortDispatcher`
|
|
382
563
|
(same-origin) and `relay/index-sw.ts` keep their client-lookup tables in
|
|
383
564
|
IndexedDB so a SW wake-up after idle doesn't lose its bindings.
|
|
@@ -398,6 +579,11 @@ src/
|
|
|
398
579
|
only controls pages and fetches under `/public/`. If you need a broader
|
|
399
580
|
scope, the SW script must be served with the
|
|
400
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)`.
|
|
401
587
|
- **`http://localhost` or HTTPS only.** Browsers refuse to register SWs
|
|
402
588
|
on other `http://` origins.
|
|
403
589
|
- **Relay mode needs an iframe-capable sandbox.** Pages with strict CSP
|
|
@@ -421,7 +607,16 @@ Runtime:
|
|
|
421
607
|
|
|
422
608
|
Dev: TypeScript, vitest, rolldown, rimraf, `http-server` (for the
|
|
423
609
|
`example:*` scripts), `@types/node` (catalog versions from the monorepo
|
|
424
|
-
root).
|
|
610
|
+
root), `playwright` and `vite` (for `test:browser`).
|
|
611
|
+
|
|
612
|
+
## Tests
|
|
613
|
+
|
|
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`.
|
|
425
620
|
|
|
426
621
|
## License
|
|
427
622
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settles like `promise` if it settles before `deadline` (a `Date.now()`
|
|
3
|
+
* value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
|
|
4
|
+
* other value resolves. Internal; not re-exported.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withDeadline<T, F>(deadline: number, promise: Promise<T>, onTimeout: () => F): Promise<T | (F extends Error ? never : F)>;
|
|
7
|
+
//# sourceMappingURL=deadline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deadline.d.ts","sourceRoot":"","sources":["../../src/core/deadline.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,CAAC,GACjB,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAqB5C"}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAGA,cAAc,6BAA6B,CAAC;AAE5C,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAGA,cAAc,6BAA6B,CAAC;AAE5C,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,eAAe,CAAC;AAC9B,cAAc,6BAA6B,CAAC"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long, by default, the page waits for its ServiceWorker to activate and
|
|
3
|
+
* to take control before giving up. Generous: a first install downloads and
|
|
4
|
+
* evaluates the worker script, which on a slow link takes seconds.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_SERVICE_WORKER_TIMEOUT = 30000;
|
|
7
|
+
/** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
|
|
8
|
+
export declare const CLAIM_CALL = "CLAIM";
|
|
9
|
+
/**
|
|
10
|
+
* - `activation-timeout`: the worker did not activate in time.
|
|
11
|
+
* - `uncontrolled`: the worker is active but the page is not controlled by it.
|
|
12
|
+
* - `unresponsive`: the page is controlled, but the worker did not answer the
|
|
13
|
+
* adapter's handshake in time.
|
|
14
|
+
*/
|
|
15
|
+
export type ServiceWorkerControlFailure = "activation-timeout" | "uncontrolled" | "unresponsive";
|
|
16
|
+
/**
|
|
17
|
+
* Why a page could not get a working ServiceWorker. `reason` says which wait
|
|
18
|
+
* failed; check it (or `name`) rather than `instanceof`, because each of this
|
|
19
|
+
* package's bundles carries its own copy of this class.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ServiceWorkerControlError extends Error {
|
|
22
|
+
readonly reason: ServiceWorkerControlFailure;
|
|
23
|
+
constructor(reason: ServiceWorkerControlFailure, message: string);
|
|
24
|
+
}
|
|
25
|
+
export interface AwaitServiceWorkerOptions {
|
|
26
|
+
/** Upper bound for the whole wait, in ms. Default `DEFAULT_SERVICE_WORKER_TIMEOUT`. */
|
|
27
|
+
timeout?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface AwaitServiceWorkerControlOptions extends AwaitServiceWorkerOptions {
|
|
30
|
+
/**
|
|
31
|
+
* When the page is still uncontrolled after asking the worker to claim it,
|
|
32
|
+
* reload the page once instead of rejecting. A normal reload is a
|
|
33
|
+
* navigation, and navigations are controlled. Guarded by `sessionStorage`
|
|
34
|
+
* so it never loops: if the reloaded page is uncontrolled too, it rejects.
|
|
35
|
+
* Default `false`.
|
|
36
|
+
*/
|
|
37
|
+
reloadIfUncontrolled?: boolean;
|
|
38
|
+
/** For tests. Default `navigator.serviceWorker`. */
|
|
39
|
+
container?: ServiceWorkerContainer;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolves with the registration's worker once it is `activated`. Rejects
|
|
43
|
+
* with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
|
|
44
|
+
* takes longer than `timeout` — an install that throws or never finishes
|
|
45
|
+
* would otherwise leave the caller waiting forever.
|
|
46
|
+
*/
|
|
47
|
+
export declare function awaitActiveServiceWorker(registration: ServiceWorkerRegistration, { timeout }?: AwaitServiceWorkerOptions): Promise<ServiceWorker>;
|
|
48
|
+
/**
|
|
49
|
+
* Resolves with the ServiceWorker that controls this page, once `registration`
|
|
50
|
+
* has an activated worker and the page is controlled by it.
|
|
51
|
+
*
|
|
52
|
+
* A page can stay uncontrolled while its worker is active, and then no
|
|
53
|
+
* `controllerchange` ever fires on its own:
|
|
54
|
+
* - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
|
|
55
|
+
* worker's `clients.claim()` already ran when it activated;
|
|
56
|
+
* - Firefox can leave a page loaded while the worker is running uncontrolled.
|
|
57
|
+
*
|
|
58
|
+
* So when the page is uncontrolled, this asks the active worker to claim it
|
|
59
|
+
* again (a `CLAIM` channel call — this package's workers answer it) and waits
|
|
60
|
+
* for `controllerchange`. Every wait is bounded by `timeout`. If control never
|
|
61
|
+
* comes it reloads once (`reloadIfUncontrolled`) or rejects with a
|
|
62
|
+
* `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
|
|
63
|
+
* happened and what to do.
|
|
64
|
+
*/
|
|
65
|
+
export declare function awaitServiceWorkerControl(registration: ServiceWorkerRegistration, { timeout, reloadIfUncontrolled, container, }?: AwaitServiceWorkerControlOptions): Promise<ServiceWorker>;
|
|
66
|
+
/**
|
|
67
|
+
* ServiceWorker side: answers the page's `CLAIM` request with
|
|
68
|
+
* `clients.claim()`, which takes over every uncontrolled client in scope.
|
|
69
|
+
* Returns a function that stops answering.
|
|
70
|
+
*/
|
|
71
|
+
export declare function handleClaimRequests(self: ServiceWorkerGlobalScope): () => void;
|
|
72
|
+
//# sourceMappingURL=service-worker-control.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-worker-control.d.ts","sourceRoot":"","sources":["../../src/core/service-worker-control.ts"],"names":[],"mappings":"AAKA;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,QAAS,CAAC;AAUrD,kFAAkF;AAClF,eAAO,MAAM,UAAU,UAAU,CAAC;AAElC;;;;;GAKG;AACH,MAAM,MAAM,2BAA2B,GAAG,oBAAoB,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjG;;;;GAIG;AACH,qBAAa,yBAA0B,SAAQ,KAAK;IAClD,QAAQ,CAAC,MAAM,EAAE,2BAA2B,CAAC;IAC7C,YAAY,MAAM,EAAE,2BAA2B,EAAE,OAAO,EAAE,MAAM,EAI/D;CACF;AAED,MAAM,WAAW,yBAAyB;IACxC,uFAAuF;IACvF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gCAAiC,SAAQ,yBAAyB;IACjF;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,oDAAoD;IACpD,SAAS,CAAC,EAAE,sBAAsB,CAAC;CACpC;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAC5C,YAAY,EAAE,yBAAyB,EACvC,EAAE,OAAwC,EAAE,GAAE,yBAA8B,GAC3E,OAAO,CAAC,aAAa,CAAC,CAaxB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,yBAAyB,EACvC,EACE,OAAwC,EACxC,oBAA4B,EAC5B,SAAmC,GACpC,GAAE,gCAAqC,GACvC,OAAO,CAAC,aAAa,CAAC,CA6CxB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,wBAAwB,GAAG,MAAM,IAAI,CAM9E"}
|