@statewalker/webrun-http-browser 0.10.0 → 0.12.2
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 +155 -10
- 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 +2 -1
- package/dist/core/service-worker-control.d.ts.map +1 -1
- package/dist/core/sw-scope.d.ts +31 -0
- package/dist/core/sw-scope.d.ts.map +1 -0
- package/dist/index.js +834 -225
- package/dist/link/http-link.d.ts +16 -3
- package/dist/link/http-link.d.ts.map +1 -1
- package/dist/relay/index-sw.d.ts +8 -2
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +1 -1
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +433 -83
- package/dist/relay-worker.d.ts +1 -0
- package/dist/relay-worker.d.ts.map +1 -1
- package/dist/relay-worker.js +433 -83
- package/dist/sw/handler-endpoint.d.ts +5 -2
- package/dist/sw/handler-endpoint.d.ts.map +1 -1
- package/dist/sw/http-sw-dispatcher.d.ts +114 -7
- package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/index.d.ts +1 -0
- package/dist/sw/index.d.ts.map +1 -1
- package/dist/sw/protocol.d.ts +3 -0
- package/dist/sw/protocol.d.ts.map +1 -1
- package/dist/sw/sw-dispatcher.d.ts +5 -2
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +19 -4
- package/dist/sw/sw-http-adapter.d.ts.map +1 -1
- package/dist/sw/worker-service.d.ts +5 -2
- package/dist/sw/worker-service.d.ts.map +1 -1
- package/dist/sw-worker.js +596 -132
- package/dist/sw.js +829 -223
- package/dist/worker.js +622 -120
- package/dist/ws/frames.d.ts +6 -1
- package/dist/ws/frames.d.ts.map +1 -1
- package/dist/ws/server.d.ts +50 -2
- package/dist/ws/server.d.ts.map +1 -1
- package/dist/ws/socket-core.d.ts +5 -2
- package/dist/ws/socket-core.d.ts.map +1 -1
- package/dist/ws/upgrade.d.ts +12 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/core/index.ts +1 -0
- package/src/core/service-worker-control.ts +5 -3
- package/src/core/sw-scope.ts +27 -0
- package/src/link/http-link.ts +20 -4
- package/src/relay/index-sw.ts +15 -2
- package/src/relay/index.ts +2 -2
- package/src/relay-worker.ts +1 -0
- package/src/sw/handler-endpoint.ts +17 -4
- package/src/sw/http-sw-dispatcher.ts +267 -67
- package/src/sw/index.ts +5 -0
- package/src/sw/protocol.ts +11 -1
- package/src/sw/sw-dispatcher.ts +17 -11
- package/src/sw/sw-http-adapter.ts +36 -10
- package/src/sw/worker-service.ts +11 -4
- package/src/ws/frames.ts +18 -12
- package/src/ws/server.ts +166 -11
- package/src/ws/socket-core.ts +7 -2
- package/src/ws/upgrade.ts +22 -4
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { get, set } from "idb-keyval";
|
|
4
4
|
import { callChannel, handleChannelCalls, withTransfer } from "../core/data-calls.js";
|
|
5
5
|
import { handleClaimRequests } from "../core/service-worker-control.js";
|
|
6
|
+
import type { FetchEventLike, ServiceWorkerScope } from "../core/sw-scope.js";
|
|
6
7
|
import { sendHttpRequest } from "../http/http-send-recieve.js";
|
|
7
8
|
import { errorMessage } from "../link/errors.js";
|
|
8
9
|
import {
|
|
@@ -38,9 +39,100 @@ import {
|
|
|
38
39
|
WS_CONNECT_CALL,
|
|
39
40
|
} from "./protocol.js";
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The key a request's url belongs to, or `undefined` when it is none of this dispatcher's (the
|
|
44
|
+
* request then goes to the network). `scope` is the worker's scope, with its trailing slash.
|
|
45
|
+
*/
|
|
46
|
+
export type KeyFunction = (url: URL, scope: string) => string | undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The default key: the url's first path segment under `scope`, decoded; none outside it, at the
|
|
50
|
+
* scope itself, or for a segment that does not decode.
|
|
51
|
+
*/
|
|
52
|
+
export function scopeKey(url: URL, scope: string): string | undefined {
|
|
53
|
+
const href = `${url}`;
|
|
54
|
+
if (!href.startsWith(scope)) return undefined;
|
|
55
|
+
const key = href
|
|
56
|
+
.substring(scope.length)
|
|
57
|
+
.replace(/[?#].*$/, "")
|
|
58
|
+
.split("/")[0];
|
|
59
|
+
if (!key) return undefined;
|
|
60
|
+
try {
|
|
61
|
+
return decodeURIComponent(key);
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined; // a malformed escape (`%E0`): no key, so the request goes to the network
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What went wrong with a request the dispatcher took, for the `failure` option. */
|
|
68
|
+
export interface DispatchFailure {
|
|
69
|
+
/**
|
|
70
|
+
* - `"timeout"`: no handler `route` accepts attached within `attachTimeoutMs` (a hand-over,
|
|
71
|
+
* or `route` waiting for one handler in particular);
|
|
72
|
+
* - `"gone"`: the key was served before, and no handler is left (its tab closed);
|
|
73
|
+
* - `"lost"`: sending to the handler failed: lost while the request was in flight
|
|
74
|
+
* (`error` is then a `LinkLostError`), or the stream failed otherwise (a `streamTimeout`, a
|
|
75
|
+
* request body that errored). A handler lost once the response head is out fails the body
|
|
76
|
+
* instead, and never reaches `failure`.
|
|
77
|
+
*/
|
|
78
|
+
kind: "timeout" | "gone" | "lost";
|
|
79
|
+
error?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
41
82
|
export interface SwHttpDispatcherOptions {
|
|
42
|
-
self:
|
|
83
|
+
self: ServiceWorkerScope;
|
|
43
84
|
log?: (...args: unknown[]) => void;
|
|
85
|
+
/**
|
|
86
|
+
* Which key a url belongs to. Default `scopeKey`: the first path segment under the scope, and
|
|
87
|
+
* none outside it. A host with another layout gives its own: a project at the origin's root,
|
|
88
|
+
* projects under a base path. It decides for `dispatch()` (and so a handler's `service.fetch`),
|
|
89
|
+
* for sockets, and for `fetch` events of this origin's urls.
|
|
90
|
+
*
|
|
91
|
+
* A key it gives a url of another origin serves handlers' `service.fetch` (through
|
|
92
|
+
* `dispatch()`) and sockets, which this worker brokers whoever opens them (pages of this origin
|
|
93
|
+
* included). A page's own `fetch` to that origin stays the browser's: `fetch` events for other
|
|
94
|
+
* origins are left alone, so it goes to the network as it would without the worker.
|
|
95
|
+
*
|
|
96
|
+
* It runs synchronously, in the `fetch` listener; it must not throw. A handler registered in a
|
|
97
|
+
* page (`SwHttpAdapter.register`) serves only urls under its own `baseUrl` (prefix `""` makes
|
|
98
|
+
* that the scope): a url the function maps to its key from elsewhere reaches the page and gets
|
|
99
|
+
* a 404 there, where a handler in a worker (`serveHttpInWorker`) takes any url.
|
|
100
|
+
*/
|
|
101
|
+
key?: KeyFunction;
|
|
102
|
+
/**
|
|
103
|
+
* Whether the dispatcher listens to `fetch` events itself. Default `true`. With `false` the
|
|
104
|
+
* host's own `fetch` listener runs instead: it answers its own routes and hands the rest to
|
|
105
|
+
* `dispatcher.handleFetch(event)`. For a dispatcher the host constructs (`new
|
|
106
|
+
* SwHttpDispatcher`): `startHttpDispatcher` refuses it, as nothing could call `handleFetch`
|
|
107
|
+
* then. The `install`/`activate` handlers (`skipWaiting`, `clients.claim`) stay on either way.
|
|
108
|
+
*/
|
|
109
|
+
handleFetch?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The answer to a request the dispatcher took and could not deliver (`DispatchFailure`).
|
|
112
|
+
* Default: 503 for `"timeout"`, 404 for `"gone"`, a plain-text 502 with the error for
|
|
113
|
+
* `"lost"`. A host whose callers expect another shape (an error its RPC layer rethrows, say)
|
|
114
|
+
* answers in it here. If it throws, the default answers.
|
|
115
|
+
*/
|
|
116
|
+
failure?: (request: Request, failure: DispatchFailure) => Response | Promise<Response>;
|
|
117
|
+
/**
|
|
118
|
+
* The headers a socket's upgrade carries to `route` and to its handler, by who asked for it:
|
|
119
|
+
* `"page"` (a page's `connectWebSocket`, `serviceWorkerWebSocketClass`) or `"handler"` (a
|
|
120
|
+
* handler's `service.connectWebSocket`). Default: as given. A host whose handlers trust some
|
|
121
|
+
* headers (routing between its own workers) drops them from pages' sockets here, as it would
|
|
122
|
+
* from pages' fetches. `from` says which path a socket came by, not whom to trust: any page of
|
|
123
|
+
* this origin can attach a handler and ask as one.
|
|
124
|
+
*/
|
|
125
|
+
socketHeaders?: (
|
|
126
|
+
headers: [string, string][] | undefined,
|
|
127
|
+
from: "page" | "handler",
|
|
128
|
+
) => [string, string][] | undefined;
|
|
129
|
+
/**
|
|
130
|
+
* The answer to a navigation under the scope whose key nobody serves (also one taken before
|
|
131
|
+
* the stored keys are read, after a restart). Default `fetch(request)`, the network. A host
|
|
132
|
+
* with a page of its own for such urls answers with it here. Other requests nobody serves
|
|
133
|
+
* always go to the network.
|
|
134
|
+
*/
|
|
135
|
+
unserved?: (request: Request) => Response | Promise<Response>;
|
|
44
136
|
/**
|
|
45
137
|
* Picks the handler of a request among the live handlers of its key.
|
|
46
138
|
* Default `leastInflightRoute`: the fewest requests in flight, the oldest on
|
|
@@ -65,6 +157,11 @@ export interface SwHttpDispatcherOptions {
|
|
|
65
157
|
wsConnectTimeoutMs?: number;
|
|
66
158
|
/** Per-stream inactivity timeout of the links (webrun-rpc's). Default none. */
|
|
67
159
|
streamTimeout?: number;
|
|
160
|
+
/**
|
|
161
|
+
* The window, in bytes, of the links to the handlers: `HttpLinkOptions.window`.
|
|
162
|
+
* Default 256 KiB; 0 turns it off.
|
|
163
|
+
*/
|
|
164
|
+
window?: number;
|
|
68
165
|
/**
|
|
69
166
|
* Period of the backup liveness check, in ms: handlers whose owner lock is
|
|
70
167
|
* no longer held are dropped. Default 5 s; 0 turns it off.
|
|
@@ -109,7 +206,8 @@ const TIMED_OUT = Symbol("timed-out");
|
|
|
109
206
|
* (`SwHttpAdapter.register`) or in Dedicated Workers
|
|
110
207
|
* (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`).
|
|
111
208
|
*
|
|
112
|
-
* A url `<scope>/<key>/...` belongs to the handlers of `key
|
|
209
|
+
* A url `<scope>/<key>/...` belongs to the handlers of `key` (the `key`
|
|
210
|
+
* option maps urls to keys some other way). Of those, the
|
|
113
211
|
* `route` option picks one per request. Requests and responses stream with
|
|
114
212
|
* backpressure in both directions, and an aborted fetch reaches the handler
|
|
115
213
|
* as a cancellation (webrun-rpc + webrun-http-streams).
|
|
@@ -127,7 +225,9 @@ const TIMED_OUT = Symbol("timed-out");
|
|
|
127
225
|
* This worker holds a router lock that handlers watch the same way.
|
|
128
226
|
*/
|
|
129
227
|
export class SwHttpDispatcher {
|
|
130
|
-
readonly self:
|
|
228
|
+
readonly self: ServiceWorkerScope;
|
|
229
|
+
/** `self`, with the WebWorker lib's types this module compiles with. */
|
|
230
|
+
private readonly _sw: ServiceWorkerGlobalScope;
|
|
131
231
|
readonly log: (...args: unknown[]) => void;
|
|
132
232
|
readonly options: SwHttpDispatcherOptions;
|
|
133
233
|
readonly table = new HandlerTable<HandlerEntry>();
|
|
@@ -151,24 +251,26 @@ export class SwHttpDispatcher {
|
|
|
151
251
|
constructor(options: SwHttpDispatcherOptions) {
|
|
152
252
|
this.options = options;
|
|
153
253
|
this.self = options.self;
|
|
254
|
+
this._sw = options.self as unknown as ServiceWorkerGlobalScope;
|
|
154
255
|
this.log = options.log ?? (() => {});
|
|
155
256
|
if (locksAvailable()) this.routerLock = newLockName("webrun-http-router");
|
|
156
257
|
}
|
|
157
258
|
|
|
158
259
|
get scope(): string {
|
|
159
|
-
|
|
260
|
+
// The registration's scope, which the page may have set to another folder than the script's
|
|
261
|
+
// (`register(url, { scope })`); the script's folder where there is no registration to ask.
|
|
262
|
+
return this._sw.registration?.scope ?? `${new URL("./", this._sw.location.href)}`;
|
|
160
263
|
}
|
|
161
264
|
|
|
162
|
-
/** The key `url` belongs to
|
|
265
|
+
/** The key `url` belongs to (the `key` option; by default its first path segment under the scope). */
|
|
163
266
|
keyOf(url: string | URL): string | undefined {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return key ? decodeURIComponent(key) : undefined;
|
|
267
|
+
let parsed: URL;
|
|
268
|
+
try {
|
|
269
|
+
parsed = new URL(`${url}`);
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined; // not an absolute url: nobody's
|
|
272
|
+
}
|
|
273
|
+
return (this.options.key ?? scopeKey)(parsed, this.scope);
|
|
172
274
|
}
|
|
173
275
|
|
|
174
276
|
/** Whether `key` has ever been served on this worker. */
|
|
@@ -182,7 +284,7 @@ export class SwHttpDispatcher {
|
|
|
182
284
|
}
|
|
183
285
|
|
|
184
286
|
start(): void {
|
|
185
|
-
const self = this.
|
|
287
|
+
const self = this._sw;
|
|
186
288
|
if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {});
|
|
187
289
|
this._restored = this._restoreClaimedKeys();
|
|
188
290
|
const latch = () => {
|
|
@@ -212,8 +314,16 @@ export class SwHttpDispatcher {
|
|
|
212
314
|
}),
|
|
213
315
|
handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => {
|
|
214
316
|
if (!this._isOwnClient(event)) throw new Error("Only this origin's pages may open sockets");
|
|
215
|
-
const { url, protocols } = params as {
|
|
216
|
-
|
|
317
|
+
const { url, protocols, headers } = params as {
|
|
318
|
+
url: string;
|
|
319
|
+
protocols?: string[];
|
|
320
|
+
headers?: [string, string][];
|
|
321
|
+
};
|
|
322
|
+
const connection = await this.connectWebSocket(
|
|
323
|
+
url,
|
|
324
|
+
protocols ?? [],
|
|
325
|
+
this._socketHeaders(headers, "page"),
|
|
326
|
+
);
|
|
217
327
|
return withTransfer(connection, [connection.port]);
|
|
218
328
|
}),
|
|
219
329
|
);
|
|
@@ -240,10 +350,10 @@ export class SwHttpDispatcher {
|
|
|
240
350
|
this.log("Claim control over all clients.");
|
|
241
351
|
event.waitUntil(self.clients.claim());
|
|
242
352
|
};
|
|
243
|
-
const onFetch = (event: FetchEvent) => this.
|
|
353
|
+
const onFetch = (event: FetchEvent) => void this.handleFetch(event);
|
|
244
354
|
self.addEventListener("install", onInstall);
|
|
245
355
|
self.addEventListener("activate", onActivate);
|
|
246
|
-
self.addEventListener("fetch", onFetch);
|
|
356
|
+
if (this.options.handleFetch !== false) self.addEventListener("fetch", onFetch);
|
|
247
357
|
this._cleanup.push(() => {
|
|
248
358
|
self.removeEventListener("install", onInstall);
|
|
249
359
|
self.removeEventListener("activate", onActivate);
|
|
@@ -262,38 +372,71 @@ export class SwHttpDispatcher {
|
|
|
262
372
|
|
|
263
373
|
/**
|
|
264
374
|
* Answers `request` as a fetch under the scope would be: through the
|
|
265
|
-
* handler its key routes to. A url
|
|
266
|
-
* worker never served, goes to the network.
|
|
375
|
+
* handler its key routes to. A url with no key (by default, one outside the
|
|
376
|
+
* scope), or of a key this worker never served, goes to the network.
|
|
267
377
|
*/
|
|
268
378
|
async dispatch(request: Request): Promise<Response> {
|
|
269
379
|
const key = this.keyOf(request.url);
|
|
270
380
|
if (key === undefined) return await fetch(request);
|
|
271
381
|
await this._restored.catch(() => {});
|
|
272
382
|
const entry = await this._acquire(request, key);
|
|
273
|
-
if (entry === TIMED_OUT) {
|
|
274
|
-
return new Response(null, {
|
|
275
|
-
status: 503,
|
|
276
|
-
statusText: "Service Unavailable (no handler attached in time)",
|
|
277
|
-
});
|
|
278
|
-
}
|
|
383
|
+
if (entry === TIMED_OUT) return await this._failed(request, { kind: "timeout" });
|
|
279
384
|
if (!entry) {
|
|
280
385
|
if (!this.serves(key)) return await fetch(request);
|
|
281
386
|
// Key was served at some point but has no live handler now (its tab
|
|
282
387
|
// closed). 404 rather than the network: a dev server's SPA fallback
|
|
283
388
|
// would serve the app shell for every site url and stale tabs would
|
|
284
389
|
// keep resurrecting registrations.
|
|
285
|
-
return
|
|
390
|
+
return await this._failed(request, { kind: "gone" });
|
|
286
391
|
}
|
|
287
392
|
try {
|
|
288
393
|
return await entry.link.send(request);
|
|
289
394
|
} catch (error) {
|
|
290
395
|
this.log("[dispatch] failed", key, error);
|
|
291
|
-
return
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
396
|
+
return await this._failed(request, { kind: "lost", error });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* After `start()`: settles once the keys this worker served before a restart are read back
|
|
402
|
+
* (whatever the outcome). Until then `serves()` may answer `false` for a key it will serve.
|
|
403
|
+
*/
|
|
404
|
+
get ready(): Promise<void> {
|
|
405
|
+
return this._restored.then(
|
|
406
|
+
() => {},
|
|
407
|
+
() => {},
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private _socketHeaders(
|
|
412
|
+
headers: [string, string][] | undefined,
|
|
413
|
+
from: "page" | "handler",
|
|
414
|
+
): [string, string][] | undefined {
|
|
415
|
+
return this.options.socketHeaders ? this.options.socketHeaders(headers, from) : headers;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async _failed(request: Request, failure: DispatchFailure): Promise<Response> {
|
|
419
|
+
if (this.options.failure) {
|
|
420
|
+
try {
|
|
421
|
+
return await this.options.failure(request, failure);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
this.log("[dispatch] the failure option threw; answering by default", error);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (failure.kind === "timeout") {
|
|
427
|
+
return new Response(null, {
|
|
428
|
+
status: 503,
|
|
429
|
+
statusText: "Service Unavailable (no handler attached in time)",
|
|
295
430
|
});
|
|
296
431
|
}
|
|
432
|
+
if (failure.kind === "gone") {
|
|
433
|
+
return new Response(null, { status: 404, statusText: "Not Found (no active handler)" });
|
|
434
|
+
}
|
|
435
|
+
return new Response(errorMessage(failure.error), {
|
|
436
|
+
status: 502,
|
|
437
|
+
statusText: "Bad Gateway",
|
|
438
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
439
|
+
});
|
|
297
440
|
}
|
|
298
441
|
|
|
299
442
|
/**
|
|
@@ -309,7 +452,7 @@ export class SwHttpDispatcher {
|
|
|
309
452
|
): Promise<WebSocketConnection> {
|
|
310
453
|
const request = newWebSocketUpgradeRequest(url, { protocols, headers });
|
|
311
454
|
const key = this.keyOf(request.url);
|
|
312
|
-
if (key === undefined) throw new Error(`${url}
|
|
455
|
+
if (key === undefined) throw new Error(`${url} belongs to no key of this ServiceWorker`);
|
|
313
456
|
await this._restored.catch(() => {});
|
|
314
457
|
const entry = await this._acquire(request, key);
|
|
315
458
|
if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`);
|
|
@@ -330,23 +473,46 @@ export class SwHttpDispatcher {
|
|
|
330
473
|
return await answer;
|
|
331
474
|
}
|
|
332
475
|
|
|
333
|
-
|
|
334
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Answers `event` if it is the dispatcher's, with `request` (default `event.request`; a host
|
|
478
|
+
* may pass a rewritten one, with headers it adds). Returns whether it called `respondWith`;
|
|
479
|
+
* when it did not, the event is the caller's to answer or to leave to the network. For a host
|
|
480
|
+
* that listens to `fetch` itself (`handleFetch: false`); call it synchronously in the event's
|
|
481
|
+
* listener, as `respondWith` must be.
|
|
482
|
+
*
|
|
483
|
+
* It takes a same-origin request whose key (the `key` option) a handler serves, and also:
|
|
484
|
+
* - a navigation under the scope whose key nobody serves (answered by `unserved`);
|
|
485
|
+
* - before the stored keys are read (the first requests after a restart), every request with
|
|
486
|
+
* a key, since only then is it known whose it is: one nobody serves gets what it would have
|
|
487
|
+
* got after, `unserved` for a navigation and the network for anything else.
|
|
488
|
+
* So a host with a page of its own for urls nobody serves gives it as `unserved`, rather than
|
|
489
|
+
* deciding before calling this.
|
|
490
|
+
*/
|
|
491
|
+
handleFetch(event: FetchEventLike, request: Request = event.request): boolean {
|
|
492
|
+
// A navigation is told by the event's own request: a rewritten copy is no longer one (`new
|
|
493
|
+
// Request(navigation, init)` makes its mode "same-origin").
|
|
494
|
+
const navigation = event.request.mode === "navigate";
|
|
495
|
+
// Another origin's url (a page's request to a CDN, an API) stays the
|
|
496
|
+
// browser's even when the key function maps it: such keys serve handlers'
|
|
497
|
+
// calls and sockets. No key: not ours either. No `respondWith` for those,
|
|
498
|
+
// so the browser does the request exactly as it would without a worker.
|
|
499
|
+
if (new URL(request.url).origin !== this._sw.location.origin) return false;
|
|
335
500
|
const key = this.keyOf(request.url);
|
|
336
|
-
|
|
337
|
-
// request exactly as it would without a worker.
|
|
338
|
-
if (key === undefined) return;
|
|
501
|
+
if (key === undefined) return false;
|
|
339
502
|
if (this._restoreSettled) {
|
|
340
503
|
if (this.serves(key)) {
|
|
341
504
|
event.respondWith(this.dispatch(request));
|
|
342
|
-
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
if (navigation) {
|
|
343
508
|
// A navigation under the scope is answered even when it is nobody's,
|
|
344
509
|
// by passing it through: Firefox can leave a page uncontrolled when
|
|
345
510
|
// its worker did not answer the navigation, and an uncontrolled page's
|
|
346
511
|
// `fetch()` never reaches this worker. Everything else is left alone.
|
|
347
|
-
event.respondWith(
|
|
512
|
+
event.respondWith(this._unserved(request));
|
|
513
|
+
return true;
|
|
348
514
|
}
|
|
349
|
-
return;
|
|
515
|
+
return false;
|
|
350
516
|
}
|
|
351
517
|
// Before the stored keys are read there is nothing to decide on, and
|
|
352
518
|
// `respondWith` cannot be called after an await: claim the request, and
|
|
@@ -354,10 +520,18 @@ export class SwHttpDispatcher {
|
|
|
354
520
|
event.respondWith(
|
|
355
521
|
(async () => {
|
|
356
522
|
await this._restored.catch(() => {});
|
|
357
|
-
if (
|
|
358
|
-
|
|
523
|
+
if (this.serves(key)) return await this.dispatch(request);
|
|
524
|
+
// Nobody's: what it would have got once the keys were read, a navigation the
|
|
525
|
+
// `unserved` answer, anything else the network.
|
|
526
|
+
return navigation ? await this._unserved(request) : await fetch(request);
|
|
359
527
|
})(),
|
|
360
528
|
);
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** What a request the dispatcher took, and no handler serves, gets: `unserved`, or the network. */
|
|
533
|
+
private async _unserved(request: Request): Promise<Response> {
|
|
534
|
+
return await (this.options.unserved ?? fetch)(request);
|
|
361
535
|
}
|
|
362
536
|
|
|
363
537
|
private async _attach(
|
|
@@ -395,8 +569,8 @@ export class SwHttpDispatcher {
|
|
|
395
569
|
|
|
396
570
|
const ctl = params.ctl ? rest.shift() : undefined;
|
|
397
571
|
const up = params.up ? rest.shift() : undefined;
|
|
398
|
-
const streamTimeout = this.options
|
|
399
|
-
const link = await connectHttpLink(down, { streamTimeout });
|
|
572
|
+
const { streamTimeout, window } = this.options;
|
|
573
|
+
const link = await connectHttpLink(down, { streamTimeout, window });
|
|
400
574
|
const entry = new HandlerEntry(
|
|
401
575
|
params.key,
|
|
402
576
|
params.id ?? clientId ?? crypto.randomUUID(),
|
|
@@ -503,29 +677,45 @@ export class SwHttpDispatcher {
|
|
|
503
677
|
request: Request,
|
|
504
678
|
key: string,
|
|
505
679
|
): Promise<HandlerEntry | typeof TIMED_OUT | undefined> {
|
|
680
|
+
const deadline = Date.now() + (this.options.attachTimeoutMs ?? 15_000);
|
|
506
681
|
const picked = await this._pick(request, key);
|
|
507
682
|
if (picked) return picked;
|
|
508
|
-
if (this.table.all(key).length
|
|
509
|
-
|
|
510
|
-
// them all. Wait for the next owner.
|
|
511
|
-
const attached = await this.table.waitFor(
|
|
512
|
-
key,
|
|
513
|
-
this.options.attachTimeoutMs ?? 15_000,
|
|
514
|
-
this.table.live(key).length > 0,
|
|
515
|
-
);
|
|
516
|
-
return attached ? ((await this._pick(request, key)) ?? TIMED_OUT) : TIMED_OUT;
|
|
517
|
-
}
|
|
518
|
-
const vacantUntil = this._vacant.get(key) ?? 0;
|
|
519
|
-
if (vacantUntil > Date.now()) {
|
|
683
|
+
if (this.table.all(key).length === 0) {
|
|
684
|
+
const vacantUntil = this._vacant.get(key) ?? 0;
|
|
520
685
|
// The last handler was drained: wait for the next owner.
|
|
521
|
-
|
|
522
|
-
|
|
686
|
+
if (vacantUntil > Date.now()) return await this._waitToPick(request, key, vacantUntil);
|
|
687
|
+
if (!this.serves(key)) return undefined;
|
|
688
|
+
// Nothing attached for a key this worker served: it may have restarted
|
|
689
|
+
// and lost its links. Ask the pages for them.
|
|
690
|
+
await this._askForLinks(key);
|
|
691
|
+
const relinked = await this._pick(request, key);
|
|
692
|
+
if (relinked) return relinked;
|
|
693
|
+
// Nobody came back: the key's handlers are gone.
|
|
694
|
+
if (this.table.all(key).length === 0) return undefined;
|
|
695
|
+
}
|
|
696
|
+
// A hand-over (every handler of the key drains), or `route` declined the
|
|
697
|
+
// ones there are (it waits for one in particular): wait until a handler
|
|
698
|
+
// it accepts attaches, not just the next one.
|
|
699
|
+
return await this._waitToPick(request, key, deadline);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Waits for attaches until `route` accepts a handler, or `until` passes. */
|
|
703
|
+
private async _waitToPick(
|
|
704
|
+
request: Request,
|
|
705
|
+
key: string,
|
|
706
|
+
until: number,
|
|
707
|
+
): Promise<HandlerEntry | typeof TIMED_OUT> {
|
|
708
|
+
for (;;) {
|
|
709
|
+
const left = until - Date.now();
|
|
710
|
+
if (left <= 0) return TIMED_OUT;
|
|
711
|
+
// In steps of at most a second: an attach between the last pick and
|
|
712
|
+
// this wait is then noticed at the next step rather than missed.
|
|
713
|
+
// A handler that attached since the last pick counts at once; when route
|
|
714
|
+
// declined the live ones, only a new attach can change its answer.
|
|
715
|
+
await this.table.waitFor(key, Math.min(left, 1_000), this.table.live(key).length > 0);
|
|
716
|
+
const picked = await this._pick(request, key);
|
|
717
|
+
if (picked) return picked;
|
|
523
718
|
}
|
|
524
|
-
if (!this.serves(key)) return undefined;
|
|
525
|
-
// Nothing attached for a key this worker served: it may have restarted
|
|
526
|
-
// and lost its links. Ask the pages for them.
|
|
527
|
-
await this._askForLinks(key);
|
|
528
|
-
return await this._pick(request, key);
|
|
529
719
|
}
|
|
530
720
|
|
|
531
721
|
private async _pick(request: Request, key: string): Promise<HandlerEntry | undefined> {
|
|
@@ -533,7 +723,7 @@ export class SwHttpDispatcher {
|
|
|
533
723
|
// Without Web Locks there is no owner lock: check the page is still there.
|
|
534
724
|
for (const entry of live) {
|
|
535
725
|
if (entry.ownerLock || !entry.clientId) continue;
|
|
536
|
-
if (!(await this.
|
|
726
|
+
if (!(await this._sw.clients.get(entry.clientId))) {
|
|
537
727
|
this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
|
|
538
728
|
}
|
|
539
729
|
}
|
|
@@ -561,7 +751,7 @@ export class SwHttpDispatcher {
|
|
|
561
751
|
|
|
562
752
|
private async _linkRound(key: string): Promise<void> {
|
|
563
753
|
const timeout = this.options.relinkTimeoutMs ?? 3_000;
|
|
564
|
-
const windows = await this.
|
|
754
|
+
const windows = await this._sw.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
565
755
|
this.log("[need-links]", key, windows.length);
|
|
566
756
|
const params: NeedLinksParams = { routerLock: this.routerLock };
|
|
567
757
|
const pending = new Set(windows);
|
|
@@ -625,7 +815,11 @@ export class SwHttpDispatcher {
|
|
|
625
815
|
return;
|
|
626
816
|
case "ws-open":
|
|
627
817
|
// A handler opens a socket to another handler, through this router.
|
|
628
|
-
this.connectWebSocket(
|
|
818
|
+
this.connectWebSocket(
|
|
819
|
+
message.url,
|
|
820
|
+
message.protocols,
|
|
821
|
+
this._socketHeaders(message.headers, "handler"),
|
|
822
|
+
).then(
|
|
629
823
|
({ port, protocol }) =>
|
|
630
824
|
entry.ctl?.postMessage(
|
|
631
825
|
{ t: "ws-opened", id: message.id, protocol, port } satisfies ControlMessage,
|
|
@@ -646,7 +840,7 @@ export class SwHttpDispatcher {
|
|
|
646
840
|
const source = event.source as Client | null;
|
|
647
841
|
if (!source?.url) return false;
|
|
648
842
|
try {
|
|
649
|
-
return new URL(source.url).origin === this.
|
|
843
|
+
return new URL(source.url).origin === this._sw.location.origin;
|
|
650
844
|
} catch {
|
|
651
845
|
return false;
|
|
652
846
|
}
|
|
@@ -673,6 +867,12 @@ export class SwHttpDispatcher {
|
|
|
673
867
|
* that stops it.
|
|
674
868
|
*/
|
|
675
869
|
export function startHttpDispatcher(options: SwHttpDispatcherOptions): () => void {
|
|
870
|
+
if (options.handleFetch === false) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
"startHttpDispatcher: handleFetch: false needs a dispatcher to call handleFetch on; " +
|
|
873
|
+
"construct it instead: new SwHttpDispatcher({ self, handleFetch: false }).start()",
|
|
874
|
+
);
|
|
875
|
+
}
|
|
676
876
|
const dispatcher = new SwHttpDispatcher(options);
|
|
677
877
|
dispatcher.start();
|
|
678
878
|
return () => {
|
package/src/sw/index.ts
CHANGED
package/src/sw/protocol.ts
CHANGED
|
@@ -83,7 +83,7 @@ export type ControlMessage =
|
|
|
83
83
|
| { t: "ws-accept"; id: string; protocol: string; port: MessagePort }
|
|
84
84
|
| { t: "ws-refuse"; id: string; status: number; message: string }
|
|
85
85
|
// handler → ServiceWorker: the handler itself asks for a socket to another handler.
|
|
86
|
-
| { t: "ws-open"; id: string; url: string; protocols: string[] }
|
|
86
|
+
| { t: "ws-open"; id: string; url: string; protocols: string[]; headers?: [string, string][] }
|
|
87
87
|
| { t: "ws-opened"; id: string; protocol: string; port: MessagePort }
|
|
88
88
|
| { t: "ws-failed"; id: string; message: string };
|
|
89
89
|
|
|
@@ -102,3 +102,13 @@ export type WorkerControlMessage =
|
|
|
102
102
|
| { type: "relink"; seen?: number }
|
|
103
103
|
// worker → page: a call is in flight; keep the ServiceWorker awake.
|
|
104
104
|
| { type: "keepalive" };
|
|
105
|
+
|
|
106
|
+
/** `headers` as pairs a `postMessage` can carry (a `Headers` object cannot be cloned). */
|
|
107
|
+
export function headerPairs(headers: HeadersInit | undefined): [string, string][] | undefined {
|
|
108
|
+
if (headers === undefined) return undefined;
|
|
109
|
+
const pairs: [string, string][] = [];
|
|
110
|
+
new Headers(headers).forEach((value, name) => {
|
|
111
|
+
pairs.push([name, value]);
|
|
112
|
+
});
|
|
113
|
+
return pairs;
|
|
114
|
+
}
|
package/src/sw/sw-dispatcher.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
handleClaimRequests,
|
|
9
9
|
ServiceWorkerControlError,
|
|
10
10
|
} from "../core/service-worker-control.js";
|
|
11
|
+
import type { ServiceWorkerScope } from "../core/sw-scope.js";
|
|
11
12
|
import { resolveWorkerUrl } from "./worker-url.js";
|
|
12
13
|
|
|
13
14
|
export interface SwPortHandlerOptions {
|
|
@@ -194,7 +195,7 @@ export class SwPortHandler {
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
export interface SwPortDispatcherOptions {
|
|
197
|
-
self:
|
|
198
|
+
self: ServiceWorkerScope;
|
|
198
199
|
log?: (...args: unknown[]) => void;
|
|
199
200
|
}
|
|
200
201
|
|
|
@@ -203,7 +204,9 @@ export interface SwPortDispatcherOptions {
|
|
|
203
204
|
* clientId, persisted in IndexedDB so it can reclaim ports across SW restarts.
|
|
204
205
|
*/
|
|
205
206
|
export class SwPortDispatcher {
|
|
206
|
-
readonly self:
|
|
207
|
+
readonly self: ServiceWorkerScope;
|
|
208
|
+
/** `self`, with the WebWorker lib's types this module compiles with. */
|
|
209
|
+
private readonly _sw: ServiceWorkerGlobalScope;
|
|
207
210
|
readonly log: (...args: unknown[]) => void;
|
|
208
211
|
readonly handlersIndex = new Map<string, ChannelInfo>();
|
|
209
212
|
/**
|
|
@@ -218,11 +221,14 @@ export class SwPortDispatcher {
|
|
|
218
221
|
|
|
219
222
|
constructor({ self, log = () => {} }: SwPortDispatcherOptions) {
|
|
220
223
|
this.self = self;
|
|
224
|
+
this._sw = self as unknown as ServiceWorkerGlobalScope;
|
|
221
225
|
this.log = log;
|
|
222
226
|
}
|
|
223
227
|
|
|
224
228
|
get scope(): string {
|
|
225
|
-
|
|
229
|
+
// The registration's scope, which the page may have set to another folder than the script's
|
|
230
|
+
// (`register(url, { scope })`); the script's folder where there is no registration to ask.
|
|
231
|
+
return this._sw.registration?.scope ?? `${new URL("./", this._sw.location.href)}`;
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
/**
|
|
@@ -236,7 +242,7 @@ export class SwPortDispatcher {
|
|
|
236
242
|
if (channelInfo.key !== key) continue;
|
|
237
243
|
const clientId = channelInfo.clientId;
|
|
238
244
|
if (!clientId) continue;
|
|
239
|
-
const client = await this.
|
|
245
|
+
const client = await this._sw.clients.get(clientId);
|
|
240
246
|
if (!client) {
|
|
241
247
|
// Owner tab closed: drop the stale entry and keep scanning for
|
|
242
248
|
// another client that still claims this key.
|
|
@@ -254,9 +260,9 @@ export class SwPortDispatcher {
|
|
|
254
260
|
}
|
|
255
261
|
|
|
256
262
|
start(): void {
|
|
257
|
-
const stopClaims = handleClaimRequests(this.
|
|
263
|
+
const stopClaims = handleClaimRequests(this._sw);
|
|
258
264
|
const stopPortUpdates = handleChannelCalls(
|
|
259
|
-
this.
|
|
265
|
+
this._sw,
|
|
260
266
|
"UPDATE_COMMUNICATION_PORT",
|
|
261
267
|
async (event, channelInfo, port: MessagePort) => {
|
|
262
268
|
const client = event.source as Client | null;
|
|
@@ -271,16 +277,16 @@ export class SwPortDispatcher {
|
|
|
271
277
|
stopPortUpdates();
|
|
272
278
|
};
|
|
273
279
|
|
|
274
|
-
this.
|
|
280
|
+
this._sw.addEventListener("install", (event) => {
|
|
275
281
|
this.log("Skip waiting on install.", event);
|
|
276
|
-
this.
|
|
282
|
+
this._sw.skipWaiting();
|
|
277
283
|
});
|
|
278
284
|
|
|
279
|
-
this.
|
|
285
|
+
this._sw.addEventListener("activate", (event) => {
|
|
280
286
|
this.log("Claim control over all clients.", event);
|
|
281
287
|
event.waitUntil(
|
|
282
288
|
(async () => {
|
|
283
|
-
await this.
|
|
289
|
+
await this._sw.clients.claim();
|
|
284
290
|
await this._checkActivation();
|
|
285
291
|
})(),
|
|
286
292
|
);
|
|
@@ -340,7 +346,7 @@ export class SwPortDispatcher {
|
|
|
340
346
|
for (const clientId of this.handlersIndex.keys()) ids.add(clientId);
|
|
341
347
|
const index = new Map<string, Client>();
|
|
342
348
|
for (const clientId of ids) {
|
|
343
|
-
const client = await this.
|
|
349
|
+
const client = await this._sw.clients.get(clientId);
|
|
344
350
|
if (client) index.set(clientId, client);
|
|
345
351
|
else this.handlersIndex.delete(clientId);
|
|
346
352
|
}
|