@statewalker/webrun-http-browser 0.9.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +155 -10
  2. package/dist/core/index.d.ts +1 -0
  3. package/dist/core/index.d.ts.map +1 -1
  4. package/dist/core/service-worker-control.d.ts +2 -1
  5. package/dist/core/service-worker-control.d.ts.map +1 -1
  6. package/dist/core/sw-scope.d.ts +31 -0
  7. package/dist/core/sw-scope.d.ts.map +1 -0
  8. package/dist/index.js +908 -221
  9. package/dist/link/http-link.d.ts +16 -3
  10. package/dist/link/http-link.d.ts.map +1 -1
  11. package/dist/relay/index-sw.d.ts +8 -2
  12. package/dist/relay/index-sw.d.ts.map +1 -1
  13. package/dist/relay/index.d.ts +1 -1
  14. package/dist/relay/index.d.ts.map +1 -1
  15. package/dist/relay-sw.js +470 -81
  16. package/dist/relay-worker.d.ts +1 -0
  17. package/dist/relay-worker.d.ts.map +1 -1
  18. package/dist/relay-worker.js +470 -81
  19. package/dist/sw/handler-endpoint.d.ts +5 -2
  20. package/dist/sw/handler-endpoint.d.ts.map +1 -1
  21. package/dist/sw/http-sw-dispatcher.d.ts +114 -7
  22. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  23. package/dist/sw/index.d.ts +1 -0
  24. package/dist/sw/index.d.ts.map +1 -1
  25. package/dist/sw/protocol.d.ts +3 -0
  26. package/dist/sw/protocol.d.ts.map +1 -1
  27. package/dist/sw/sw-dispatcher.d.ts +5 -2
  28. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  29. package/dist/sw/sw-http-adapter.d.ts +19 -4
  30. package/dist/sw/sw-http-adapter.d.ts.map +1 -1
  31. package/dist/sw/worker-service.d.ts +5 -2
  32. package/dist/sw/worker-service.d.ts.map +1 -1
  33. package/dist/sw-worker.js +681 -141
  34. package/dist/sw.js +896 -218
  35. package/dist/worker.js +689 -115
  36. package/dist/ws/server.d.ts +50 -2
  37. package/dist/ws/server.d.ts.map +1 -1
  38. package/dist/ws/socket-core.d.ts +5 -2
  39. package/dist/ws/socket-core.d.ts.map +1 -1
  40. package/dist/ws/upgrade.d.ts +12 -1
  41. package/dist/ws/upgrade.d.ts.map +1 -1
  42. package/package.json +4 -3
  43. package/src/core/index.ts +1 -0
  44. package/src/core/service-worker-control.ts +5 -3
  45. package/src/core/sw-scope.ts +27 -0
  46. package/src/link/http-link.ts +20 -4
  47. package/src/relay/index-sw.ts +15 -2
  48. package/src/relay/index.ts +2 -2
  49. package/src/relay-worker.ts +1 -0
  50. package/src/sw/handler-endpoint.ts +17 -4
  51. package/src/sw/http-sw-dispatcher.ts +267 -67
  52. package/src/sw/index.ts +5 -0
  53. package/src/sw/protocol.ts +11 -1
  54. package/src/sw/sw-dispatcher.ts +17 -11
  55. package/src/sw/sw-http-adapter.ts +36 -10
  56. package/src/sw/worker-service.ts +11 -4
  57. package/src/ws/server.ts +166 -11
  58. package/src/ws/socket-core.ts +7 -2
  59. 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: ServiceWorkerGlobalScope;
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`. Of those, the
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: ServiceWorkerGlobalScope;
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
- return `${new URL("./", this.self.location.href)}`;
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: its first path segment under the scope. */
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
- const href = `${url}`;
165
- const scope = this.scope;
166
- if (!href.startsWith(scope)) return undefined;
167
- const key = href
168
- .substring(scope.length)
169
- .replace(/[?#].*$/, "")
170
- .split("/")[0];
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.self;
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 { url: string; protocols?: string[] };
216
- const connection = await this.connectWebSocket(url, protocols ?? []);
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._onFetch(event);
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 outside the scope, or of a key this
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 new Response(null, { status: 404, statusText: "Not Found (no active handler)" });
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 new Response(errorMessage(error), {
292
- status: 502,
293
- statusText: "Bad Gateway",
294
- headers: { "content-type": "text/plain; charset=utf-8" },
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} is outside this ServiceWorker's scope`);
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
- private _onFetch(event: FetchEvent): void {
334
- const request = event.request;
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
- // Outside the scope: not ours. No `respondWith`, so the browser does the
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
- } else if (request.mode === "navigate") {
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(fetch(request));
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 (!this.serves(key)) return await fetch(request);
358
- return await this.dispatch(request);
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.streamTimeout;
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 > 0) {
509
- // A hand-over: every handler of the key drains, or `route` declined
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
- const attached = await this.table.waitFor(key, vacantUntil - Date.now());
522
- return attached ? ((await this._pick(request, key)) ?? TIMED_OUT) : TIMED_OUT;
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.self.clients.get(entry.clientId))) {
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.self.clients.matchAll({ type: "window", includeUncontrolled: true });
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(message.url, message.protocols).then(
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.self.location.origin;
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
@@ -1,3 +1,8 @@
1
+ export type {
2
+ FetchEventLike,
3
+ ServiceWorkerClientLike,
4
+ ServiceWorkerScope,
5
+ } from "../core/sw-scope.js";
1
6
  export * from "./handler-endpoint.js";
2
7
  export * from "./handler-table.js";
3
8
  export * from "./http-sw-dispatcher.js";
@@ -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
+ }
@@ -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: ServiceWorkerGlobalScope;
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: ServiceWorkerGlobalScope;
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
- return `${new URL("./", this.self.location.href)}`;
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.self.clients.get(clientId);
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.self);
263
+ const stopClaims = handleClaimRequests(this._sw);
258
264
  const stopPortUpdates = handleChannelCalls(
259
- this.self,
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.self.addEventListener("install", (event) => {
280
+ this._sw.addEventListener("install", (event) => {
275
281
  this.log("Skip waiting on install.", event);
276
- this.self.skipWaiting();
282
+ this._sw.skipWaiting();
277
283
  });
278
284
 
279
- this.self.addEventListener("activate", (event) => {
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.self.clients.claim();
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.self.clients.get(clientId);
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
  }