@statewalker/webrun-http-browser 0.6.5 → 0.7.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 (95) hide show
  1. package/README.md +302 -52
  2. package/dist/core/data-calls.d.ts +17 -0
  3. package/dist/core/data-calls.d.ts.map +1 -1
  4. package/dist/core/service-worker-control.d.ts +3 -1
  5. package/dist/core/service-worker-control.d.ts.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +4218 -301
  9. package/dist/link/async-dispose.d.ts +10 -0
  10. package/dist/link/async-dispose.d.ts.map +1 -0
  11. package/dist/link/errors.d.ts +2 -0
  12. package/dist/link/errors.d.ts.map +1 -0
  13. package/dist/link/http-link.d.ts +72 -0
  14. package/dist/link/http-link.d.ts.map +1 -0
  15. package/dist/link/index.d.ts +4 -0
  16. package/dist/link/index.d.ts.map +1 -0
  17. package/dist/link/locks.d.ts +39 -0
  18. package/dist/link/locks.d.ts.map +1 -0
  19. package/dist/link/rendezvous.d.ts +21 -0
  20. package/dist/link/rendezvous.d.ts.map +1 -0
  21. package/dist/relay/index-sw.d.ts +12 -2
  22. package/dist/relay/index-sw.d.ts.map +1 -1
  23. package/dist/relay/index.d.ts +66 -8
  24. package/dist/relay/index.d.ts.map +1 -1
  25. package/dist/relay-sw.js +1328 -64
  26. package/dist/relay-worker.js +1329 -64
  27. package/dist/sw/handler-endpoint.d.ts +81 -0
  28. package/dist/sw/handler-endpoint.d.ts.map +1 -0
  29. package/dist/sw/handler-table.d.ts +53 -0
  30. package/dist/sw/handler-table.d.ts.map +1 -0
  31. package/dist/sw/http-sw-dispatcher.d.ts +162 -21
  32. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  33. package/dist/sw/index.d.ts +5 -0
  34. package/dist/sw/index.d.ts.map +1 -1
  35. package/dist/sw/protocol.d.ts +115 -0
  36. package/dist/sw/protocol.d.ts.map +1 -0
  37. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  38. package/dist/sw/sw-http-adapter.d.ts +199 -0
  39. package/dist/sw/sw-http-adapter.d.ts.map +1 -0
  40. package/dist/sw/sw-websocket.d.ts +16 -0
  41. package/dist/sw/sw-websocket.d.ts.map +1 -0
  42. package/dist/sw/worker-service.d.ts +45 -0
  43. package/dist/sw/worker-service.d.ts.map +1 -0
  44. package/dist/sw/worker-url.d.ts +9 -0
  45. package/dist/sw/worker-url.d.ts.map +1 -0
  46. package/dist/sw-worker.js +2403 -265
  47. package/dist/sw.d.ts +2 -0
  48. package/dist/sw.d.ts.map +1 -1
  49. package/dist/sw.js +4189 -546
  50. package/dist/worker.d.ts +10 -0
  51. package/dist/worker.d.ts.map +1 -0
  52. package/dist/worker.js +3780 -0
  53. package/dist/ws/client.d.ts +73 -0
  54. package/dist/ws/client.d.ts.map +1 -0
  55. package/dist/ws/frames.d.ts +68 -0
  56. package/dist/ws/frames.d.ts.map +1 -0
  57. package/dist/ws/index.d.ts +6 -0
  58. package/dist/ws/index.d.ts.map +1 -0
  59. package/dist/ws/server.d.ts +53 -0
  60. package/dist/ws/server.d.ts.map +1 -0
  61. package/dist/ws/socket-core.d.ts +91 -0
  62. package/dist/ws/socket-core.d.ts.map +1 -0
  63. package/dist/ws/upgrade.d.ts +93 -0
  64. package/dist/ws/upgrade.d.ts.map +1 -0
  65. package/package.json +10 -5
  66. package/src/core/data-calls.ts +52 -2
  67. package/src/core/service-worker-control.ts +7 -1
  68. package/src/index.ts +3 -0
  69. package/src/link/async-dispose.ts +22 -0
  70. package/src/link/errors.ts +3 -0
  71. package/src/link/http-link.ts +238 -0
  72. package/src/link/index.ts +3 -0
  73. package/src/link/locks.ts +97 -0
  74. package/src/link/rendezvous.ts +60 -0
  75. package/src/relay/index-sw.ts +84 -12
  76. package/src/relay/index.ts +203 -27
  77. package/src/sw/handler-endpoint.ts +278 -0
  78. package/src/sw/handler-table.ts +130 -0
  79. package/src/sw/http-sw-dispatcher.ts +649 -90
  80. package/src/sw/index.ts +5 -0
  81. package/src/sw/protocol.ts +103 -0
  82. package/src/sw/sw-dispatcher.ts +1 -20
  83. package/src/sw/sw-http-adapter.ts +613 -0
  84. package/src/sw/sw-websocket.ts +43 -0
  85. package/src/sw/worker-service.ts +133 -0
  86. package/src/sw/worker-url.ts +19 -0
  87. package/src/sw-worker.ts +8 -3
  88. package/src/sw.ts +2 -0
  89. package/src/worker.ts +9 -0
  90. package/src/ws/client.ts +346 -0
  91. package/src/ws/frames.ts +119 -0
  92. package/src/ws/index.ts +11 -0
  93. package/src/ws/server.ts +132 -0
  94. package/src/ws/socket-core.ts +251 -0
  95. package/src/ws/upgrade.ts +200 -0
@@ -1,119 +1,678 @@
1
- import type { HttpHandler } from "@statewalker/webrun-http-streams";
2
- import { handleHttpRequests, sendHttpRequest } from "../http/http-send-recieve.js";
1
+ /// <reference lib="webworker" />
2
+
3
+ import { get, set } from "idb-keyval";
4
+ import { callChannel, handleChannelCalls, withTransfer } from "../core/data-calls.js";
5
+ import { handleClaimRequests } from "../core/service-worker-control.js";
6
+ import { sendHttpRequest } from "../http/http-send-recieve.js";
7
+ import { errorMessage } from "../link/errors.js";
8
+ import {
9
+ connectHttpLink,
10
+ HTTP_LINK_PROTOCOL,
11
+ type HttpLink,
12
+ LinkLostError,
13
+ serveHttpLink,
14
+ trackedLink,
15
+ } from "../link/http-link.js";
16
+ import { heldLockNames, holdLock, locksAvailable, newLockName, watchLock } from "../link/locks.js";
17
+ import { PortRendezvous } from "../link/rendezvous.js";
18
+ import type { WebSocketConnection } from "../ws/client.js";
19
+ import { newWebSocketUpgradeRequest } from "../ws/upgrade.js";
3
20
  import {
4
- SwPortDispatcher,
5
- type SwPortDispatcherOptions,
6
- SwPortHandler,
7
- type SwPortHandlerOptions,
8
- } from "./sw-dispatcher.js";
9
-
10
- export interface SwHttpAdapterOptions extends Omit<SwPortHandlerOptions, "bindPort"> {
11
- bindPort?: SwPortHandlerOptions["bindPort"];
21
+ type HandlerInfo,
22
+ HandlerTable,
23
+ leastInflightRoute,
24
+ type RouteFunction,
25
+ } from "./handler-table.js";
26
+ import {
27
+ ATTACH_CALL,
28
+ type AttachParams,
29
+ type AttachReply,
30
+ type ControlMessage,
31
+ DETACH_CALL,
32
+ DRAIN_CALL,
33
+ type DrainParams,
34
+ KEEPALIVE_MESSAGE,
35
+ NEED_LINKS_CALL,
36
+ type NeedLinksParams,
37
+ WAKE_MESSAGE,
38
+ WS_CONNECT_CALL,
39
+ } from "./protocol.js";
40
+
41
+ export interface SwHttpDispatcherOptions {
42
+ self: ServiceWorkerGlobalScope;
43
+ log?: (...args: unknown[]) => void;
44
+ /**
45
+ * Picks the handler of a request among the live handlers of its key.
46
+ * Default `leastInflightRoute`: the fewest requests in flight, the oldest on
47
+ * a tie — with one tab per key, the first live handler, as before.
48
+ */
49
+ route?: RouteFunction;
50
+ /**
51
+ * How long a request waits for a handler while its key is handed over (the
52
+ * handlers it has are all draining, or `route` picked none), in ms. Past
53
+ * it, 503. Default 15 s.
54
+ */
55
+ attachTimeoutMs?: number;
56
+ /**
57
+ * How long a request for a key with no handler waits for the pages to
58
+ * attach their links again (after a ServiceWorker restart), in ms. Past it,
59
+ * 404 for a key this worker has served before. Default 3 s.
60
+ */
61
+ relinkTimeoutMs?: number;
62
+ /** How long a replaced link may finish its requests, in ms. Default 5 s. */
63
+ handoverMs?: number;
64
+ /** How long a socket waits for its handler's answer, in ms. Default 15 s. */
65
+ wsConnectTimeoutMs?: number;
66
+ /** Per-stream inactivity timeout of the links (webrun-rpc's). Default none. */
67
+ streamTimeout?: number;
68
+ /**
69
+ * Period of the backup liveness check, in ms: handlers whose owner lock is
70
+ * no longer held are dropped. Default 5 s; 0 turns it off.
71
+ */
72
+ livenessCheckMs?: number;
12
73
  }
13
74
 
14
- export interface SwHttpRegistration {
15
- baseUrl: string;
16
- prefix: string;
17
- remove(): Promise<void>;
75
+ /** One attached handler link, as the ServiceWorker holds it. */
76
+ class HandlerEntry implements HandlerInfo {
77
+ draining = false;
78
+ readonly attachedAt = Date.now();
79
+ readonly sockets: PortRendezvous<WebSocketConnection>;
80
+ stopWatch: () => void = () => {};
81
+ closeUp?: () => Promise<void>;
82
+
83
+ constructor(
84
+ readonly key: string,
85
+ readonly id: string,
86
+ readonly owner: string,
87
+ readonly meta: unknown,
88
+ readonly link: HttpLink,
89
+ readonly ctl: MessagePort | undefined,
90
+ readonly clientId: string | undefined,
91
+ readonly ownerLock: string | undefined,
92
+ ) {
93
+ this.sockets = new PortRendezvous(`s:${id}`);
94
+ }
95
+
96
+ get inflight(): number {
97
+ return this.link.inflight;
98
+ }
18
99
  }
19
100
 
20
- export class SwHttpAdapter extends SwPortHandler {
21
- private readonly _handlers = new Map<string, HttpHandler>();
22
- private _cleanupRequestChannel?: () => void;
101
+ /** The reason requests fail with when their handler dies under them. */
102
+ export const HANDLER_LOST_MESSAGE = "The handler was lost while this request was in flight";
23
103
 
24
- constructor(options: SwHttpAdapterOptions) {
25
- super({
26
- bindPort: () => {},
27
- ...options,
28
- });
104
+ const TIMED_OUT = Symbol("timed-out");
105
+
106
+ /**
107
+ * The same-origin ServiceWorker: routes `fetch` events under its scope to
108
+ * handler links that pages attach — handlers in the pages themselves
109
+ * (`SwHttpAdapter.register`) or in Dedicated Workers
110
+ * (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`).
111
+ *
112
+ * A url `<scope>/<key>/...` belongs to the handlers of `key`. Of those, the
113
+ * `route` option picks one per request. Requests and responses stream with
114
+ * backpressure in both directions, and an aborted fetch reaches the handler
115
+ * as a cancellation (webrun-rpc + webrun-http-streams).
116
+ *
117
+ * Urls of keys this worker never served go to the network untouched: the
118
+ * worker does not call `respondWith` for them.
119
+ *
120
+ * RESTARTS. The browser stops an idle ServiceWorker, and every port it held
121
+ * dies with it. Nothing is persisted but the set of keys ever served: the
122
+ * first request after a restart asks every window to attach its links again
123
+ * (`NEED_LINKS`) and waits for them.
124
+ *
125
+ * LIVENESS. Each handler holds an owner lock; when it is granted to this
126
+ * worker, the handler is gone and its requests in flight fail at once (502).
127
+ * This worker holds a router lock that handlers watch the same way.
128
+ */
129
+ export class SwHttpDispatcher {
130
+ readonly self: ServiceWorkerGlobalScope;
131
+ readonly log: (...args: unknown[]) => void;
132
+ readonly options: SwHttpDispatcherOptions;
133
+ readonly table = new HandlerTable<HandlerEntry>();
134
+ /**
135
+ * Keys that were served at some point on this worker (sticky, kept in
136
+ * IndexedDB). Lets a fetch tell "a site url whose handler is gone → 404"
137
+ * from "an unknown url under the scope → the network".
138
+ */
139
+ readonly claimedKeys = new Set<string>();
140
+ /** The Web Lock this worker holds while it lives; handlers watch it. */
141
+ readonly routerLock?: string;
142
+ private _routerHeld: Promise<void> = Promise.resolve();
143
+ private _restored: Promise<void> = Promise.resolve();
144
+ private _restoreSettled = false;
145
+ private _cleanup: Array<() => void> = [];
146
+ private _livenessTimer?: ReturnType<typeof setInterval>;
147
+ private readonly _relinkRounds = new Map<string, { at: number; done: Promise<void> }>();
148
+ /** Keys whose last handler was drained: until the time stored, requests wait for the next. */
149
+ private readonly _vacant = new Map<string, number>();
150
+
151
+ constructor(options: SwHttpDispatcherOptions) {
152
+ this.options = options;
153
+ this.self = options.self;
154
+ this.log = options.log ?? (() => {});
155
+ if (locksAvailable()) this.routerLock = newLockName("webrun-http-router");
156
+ }
157
+
158
+ get scope(): string {
159
+ return `${new URL("./", this.self.location.href)}`;
160
+ }
161
+
162
+ /** The key `url` belongs to: its first path segment under the scope. */
163
+ 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;
29
172
  }
30
173
 
31
- protected override async _setCommunicationPort(port: MessagePort): Promise<void> {
32
- this._cleanupRequestChannel?.();
33
- this._cleanupRequestChannel = handleHttpRequests(port, this._handleHttpRequest.bind(this));
174
+ /** Whether `key` has ever been served on this worker. */
175
+ isClaimedKey(key: string): boolean {
176
+ return this.claimedKeys.has(key);
34
177
  }
35
178
 
36
- private async _handleHttpRequest(request: Request): Promise<Response> {
37
- const requestUrl = request.url;
38
- for (const [urlPrefix, handler] of this._handlers) {
39
- if (requestUrl.indexOf(urlPrefix) === 0) {
40
- return handler(request);
179
+ /** Whether requests for `key` are this worker's to answer. */
180
+ serves(key: string): boolean {
181
+ return this.claimedKeys.has(key) || this.table.all(key).length > 0;
182
+ }
183
+
184
+ start(): void {
185
+ const self = this.self;
186
+ if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {});
187
+ this._restored = this._restoreClaimedKeys();
188
+ const latch = () => {
189
+ this._restoreSettled = true;
190
+ };
191
+ this._restored.then(latch, latch);
192
+
193
+ this._cleanup.push(
194
+ handleClaimRequests(self),
195
+ handleChannelCalls(self, ATTACH_CALL, (event, params, ...ports) =>
196
+ this._attach(event, params as AttachParams, ports),
197
+ ),
198
+ handleChannelCalls(self, DETACH_CALL, (_event, params) => {
199
+ const entry = this._find(params as DrainParams);
200
+ if (!entry) return false;
201
+ this._close(entry, new LinkLostError("The handler was detached"));
202
+ return true;
203
+ }),
204
+ handleChannelCalls(self, DRAIN_CALL, async (_event, params) => {
205
+ const entry = this._find(params as DrainParams);
206
+ if (!entry) return false;
207
+ // Requests for the key wait for its next owner, even once this one
208
+ // is gone, for up to `attachTimeoutMs`.
209
+ this._vacant.set(entry.key, Date.now() + (this.options.attachTimeoutMs ?? 15_000));
210
+ await this._retire(entry, (params as DrainParams).ms ?? this.options.handoverMs ?? 5_000);
211
+ return true;
212
+ }),
213
+ handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => {
214
+ 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 ?? []);
217
+ return withTransfer(connection, [connection.port]);
218
+ }),
219
+ );
220
+
221
+ const onMessage = (event: ExtendableMessageEvent) => {
222
+ const type = (event.data as { type?: unknown } | null)?.type;
223
+ if (type === WAKE_MESSAGE) {
224
+ // Started by this very message if it was stopped: hold it up until the
225
+ // pages had a chance to link again.
226
+ event.waitUntil(this._askForLinks());
227
+ } else if (type === KEEPALIVE_MESSAGE) {
228
+ // Receiving the message is what resets the idle timer.
229
+ event.waitUntil(Promise.resolve());
41
230
  }
231
+ };
232
+ self.addEventListener("message", onMessage);
233
+ this._cleanup.push(() => self.removeEventListener("message", onMessage));
234
+
235
+ const onInstall = (event: ExtendableEvent) => {
236
+ this.log("Skip waiting on install.");
237
+ event.waitUntil(self.skipWaiting());
238
+ };
239
+ const onActivate = (event: ExtendableEvent) => {
240
+ this.log("Claim control over all clients.");
241
+ event.waitUntil(self.clients.claim());
242
+ };
243
+ const onFetch = (event: FetchEvent) => this._onFetch(event);
244
+ self.addEventListener("install", onInstall);
245
+ self.addEventListener("activate", onActivate);
246
+ self.addEventListener("fetch", onFetch);
247
+ this._cleanup.push(() => {
248
+ self.removeEventListener("install", onInstall);
249
+ self.removeEventListener("activate", onActivate);
250
+ self.removeEventListener("fetch", onFetch);
251
+ });
252
+ }
253
+
254
+ async stop(): Promise<void> {
255
+ for (const cleanup of this._cleanup.splice(0)) cleanup();
256
+ if (this._livenessTimer) clearInterval(this._livenessTimer);
257
+ this._livenessTimer = undefined;
258
+ for (const entry of this.table.entries()) {
259
+ this._close(entry, new LinkLostError("The ServiceWorker dispatcher stopped"));
42
260
  }
43
- return new Response(null, { status: 404, statusText: "Error 404: Not found" });
44
261
  }
45
262
 
46
263
  /**
47
- * Registers an HTTP handler on a prefix. Returns the resulting base URL and a
48
- * disposer.
264
+ * 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.
49
267
  */
50
- async register(prefix: string, handler: HttpHandler): Promise<SwHttpRegistration> {
51
- const cleanPrefix = `./${(prefix ?? "").replace(/^[./]+/, "")}`;
52
- const baseUrl = `${new URL(cleanPrefix, this.rootUrl)}`;
53
- this._handlers.set(baseUrl, handler);
54
- const handlers = this._handlers;
55
- return {
56
- baseUrl,
57
- prefix: cleanPrefix,
58
- async remove() {
59
- handlers.delete(baseUrl);
60
- },
61
- };
268
+ async dispatch(request: Request): Promise<Response> {
269
+ const key = this.keyOf(request.url);
270
+ if (key === undefined) return await fetch(request);
271
+ await this._restored.catch(() => {});
272
+ 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
+ }
279
+ if (!entry) {
280
+ if (!this.serves(key)) return await fetch(request);
281
+ // Key was served at some point but has no live handler now (its tab
282
+ // closed). 404 rather than the network: a dev server's SPA fallback
283
+ // would serve the app shell for every site url and stale tabs would
284
+ // keep resurrecting registrations.
285
+ return new Response(null, { status: 404, statusText: "Not Found (no active handler)" });
286
+ }
287
+ try {
288
+ return await entry.link.send(request);
289
+ } catch (error) {
290
+ 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" },
295
+ });
296
+ }
62
297
  }
63
- }
64
298
 
65
- export class SwHttpDispatcher extends SwPortDispatcher {
66
- start(): void {
67
- super.start();
68
- this.self.addEventListener("fetch", this._handleFetchEvent.bind(this));
299
+ /**
300
+ * Opens a socket to the handler `url` routes to: the handler accepts or
301
+ * refuses it like an HTTP upgrade, and the socket's port comes back. After
302
+ * that the socket runs between the caller and the handler directly; this
303
+ * worker is not on its data path.
304
+ */
305
+ async connectWebSocket(
306
+ url: string,
307
+ protocols: string[] = [],
308
+ headers?: [string, string][],
309
+ ): Promise<WebSocketConnection> {
310
+ const request = newWebSocketUpgradeRequest(url, { protocols, headers });
311
+ const key = this.keyOf(request.url);
312
+ if (key === undefined) throw new Error(`${url} is outside this ServiceWorker's scope`);
313
+ await this._restored.catch(() => {});
314
+ const entry = await this._acquire(request, key);
315
+ if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`);
316
+ if (!entry.ctl) throw new Error(`The handler of ${url} does not accept sockets`);
317
+ const id = entry.sockets.newId();
318
+ const answer = entry.sockets.wait(
319
+ id,
320
+ this.options.wsConnectTimeoutMs ?? 15_000,
321
+ `the socket request for ${url}`,
322
+ );
323
+ entry.ctl.postMessage({
324
+ t: "ws-connect",
325
+ id,
326
+ url: request.url,
327
+ protocols,
328
+ headers,
329
+ } satisfies ControlMessage);
330
+ return await answer;
69
331
  }
70
332
 
71
- private _handleFetchEvent(event: FetchEvent): void {
333
+ private _onFetch(event: FetchEvent): void {
334
+ const request = event.request;
335
+ 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;
339
+ if (this._restoreSettled) {
340
+ if (this.serves(key)) {
341
+ event.respondWith(this.dispatch(request));
342
+ } else if (request.mode === "navigate") {
343
+ // A navigation under the scope is answered even when it is nobody's,
344
+ // by passing it through: Firefox can leave a page uncontrolled when
345
+ // its worker did not answer the navigation, and an uncontrolled page's
346
+ // `fetch()` never reaches this worker. Everything else is left alone.
347
+ event.respondWith(fetch(request));
348
+ }
349
+ return;
350
+ }
351
+ // Before the stored keys are read there is nothing to decide on, and
352
+ // `respondWith` cannot be called after an await: claim the request, and
353
+ // re-issue it if it turns out to be nobody's.
72
354
  event.respondWith(
73
- (async (): Promise<Response> => {
74
- const request = event.request;
75
- try {
76
- const requestUrl = request.url;
77
- const rootUrl = this.scope;
78
- if (requestUrl.indexOf(rootUrl) === 0) {
79
- const key = requestUrl.substring(rootUrl.length).replace(/^\/?([^/]+).*$/, "$1");
80
- const channelInfo = await this.loadChannelInfo(key);
81
- if (channelInfo?.port) {
82
- return await sendHttpRequest(channelInfo.port, request);
83
- }
84
- // Key was registered at some point on this SW but has no live
85
- // handler right now (owner tab closed). Return 404 instead of
86
- // falling through to the network — the alternative (a dev
87
- // server's SPA fallback) serves the app shell for every site
88
- // URL and creates a confusing refresh-loop where stale tabs
89
- // keep resurrecting registrations.
90
- if (this.isClaimedKey(key)) {
91
- return new Response(null, {
92
- status: 404,
93
- statusText: "Not Found (no active handler)",
94
- });
95
- }
96
- }
97
- // URL is outside scope, or the first segment is unrelated to any
98
- // registered site (Vite's /assets/*, /favicon.ico, etc.) — pass
99
- // the event's Request straight through. Reconstructing it drops
100
- // `mode`, which makes the constructor throw
101
- // `'only-if-cached' can be set only with 'same-origin' mode` for
102
- // navigation-style requests Chrome issues with that cache mode.
103
- return await fetch(request);
104
- } catch (error) {
105
- console.error(error);
106
- return new Response(null, {
107
- status: 500,
108
- statusText: "Error 500: Internal error",
109
- });
110
- }
355
+ (async () => {
356
+ await this._restored.catch(() => {});
357
+ if (!this.serves(key)) return await fetch(request);
358
+ return await this.dispatch(request);
111
359
  })(),
112
360
  );
113
361
  }
362
+
363
+ private async _attach(
364
+ event: MessageEvent,
365
+ params: AttachParams,
366
+ ports: MessagePort[],
367
+ ): Promise<AttachReply | AttachParams> {
368
+ const clientId = (event.source as Client | null)?.id;
369
+ const [down, ...rest] = ports;
370
+ if (!down) throw new Error("An attach carries at least one port");
371
+ this.log("[attach]", clientId, params);
372
+
373
+ if (params.wire === undefined) {
374
+ // A page of version 1 (before #32): its own stream transport, no
375
+ // backpressure. Served as it was, until the page reloads; after a
376
+ // restart it is asked for a port the way version 1 asked
377
+ // (`_askLegacyPage`).
378
+ const link = trackedLink(
379
+ (request) => sendHttpRequest(down, request),
380
+ () => down.close(),
381
+ );
382
+ const id = clientId ?? crypto.randomUUID();
383
+ this._add(
384
+ new HandlerEntry(params.key, id, id, undefined, link, undefined, clientId, undefined),
385
+ );
386
+ return { ...params };
387
+ }
388
+ if (params.wire !== HTTP_LINK_PROTOCOL) {
389
+ for (const port of ports) port.close();
390
+ throw new Error(
391
+ `This ServiceWorker speaks ${HTTP_LINK_PROTOCOL}, the page ${params.wire}. ` +
392
+ "Reload the page so that both run the same version of @statewalker/webrun-http-browser.",
393
+ );
394
+ }
395
+
396
+ const ctl = params.ctl ? rest.shift() : undefined;
397
+ const up = params.up ? rest.shift() : undefined;
398
+ const streamTimeout = this.options.streamTimeout;
399
+ const link = await connectHttpLink(down, { streamTimeout });
400
+ const entry = new HandlerEntry(
401
+ params.key,
402
+ params.id ?? clientId ?? crypto.randomUUID(),
403
+ params.owner ?? crypto.randomUUID(),
404
+ params.meta,
405
+ link,
406
+ ctl,
407
+ clientId,
408
+ params.ownerLock,
409
+ );
410
+ if (up) {
411
+ entry.closeUp = await serveHttpLink(up, (request) => this.dispatch(request), {
412
+ streamTimeout,
413
+ });
414
+ }
415
+ if (ctl) ctl.onmessage = (message) => this._onControl(entry, message.data as ControlMessage);
416
+ if (params.ownerLock) {
417
+ entry.stopWatch = watchLock(params.ownerLock, () => {
418
+ this.log("[owner lost]", entry.key, entry.id);
419
+ this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
420
+ });
421
+ }
422
+ this._add(entry);
423
+ await this._routerHeld;
424
+ return { accepted: HTTP_LINK_PROTOCOL, routerLock: this.routerLock };
425
+ }
426
+
427
+ private _add(entry: HandlerEntry): void {
428
+ this._vacant.delete(entry.key);
429
+ const replaced = this.table.add(entry);
430
+ if (replaced && replaced !== entry) {
431
+ void this._retire(replaced, this.options.handoverMs ?? 5_000, false);
432
+ }
433
+ void this._claim(entry.key);
434
+ if (entry.ownerLock) this._watchLiveness();
435
+ }
436
+
437
+ /**
438
+ * The backup of the owner-lock watches: every `livenessCheckMs`, drops the
439
+ * handlers whose owner lock is no longer held. Runs only while some handler
440
+ * has an owner lock, and keeps nothing awake: a timer is not an event.
441
+ */
442
+ private _watchLiveness(): void {
443
+ const period = this.options.livenessCheckMs ?? 5_000;
444
+ if (this._livenessTimer || period <= 0) return;
445
+ this._livenessTimer = setInterval(async () => {
446
+ const watched = this.table.entries().filter((entry) => entry.ownerLock);
447
+ if (watched.length === 0) {
448
+ clearInterval(this._livenessTimer);
449
+ this._livenessTimer = undefined;
450
+ return;
451
+ }
452
+ const held = await heldLockNames();
453
+ if (!held) return;
454
+ for (const entry of watched) {
455
+ if (entry.ownerLock && !held.has(entry.ownerLock) && this.table.remove(entry)) {
456
+ this.log("[owner gone]", entry.key, entry.id);
457
+ this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE), false);
458
+ }
459
+ }
460
+ }, period);
461
+ }
462
+
463
+ /** The entry a drain or detach names, if its owner token still matches. */
464
+ private _find({ key, id, owner }: DrainParams): HandlerEntry | undefined {
465
+ const entry = this.table.get(key, id);
466
+ return entry && entry.owner === owner ? entry : undefined;
467
+ }
468
+
469
+ /**
470
+ * Stops routing to `entry`, lets its requests in flight run for up to `ms`,
471
+ * then closes it. Requests that arrive meanwhile wait for the next owner.
472
+ */
473
+ private async _retire(entry: HandlerEntry, ms: number, inTable = true): Promise<void> {
474
+ entry.draining = true;
475
+ let timer: ReturnType<typeof setTimeout> | undefined;
476
+ await Promise.race([
477
+ entry.link.whenIdle(),
478
+ new Promise<void>((resolve) => {
479
+ timer = setTimeout(resolve, ms);
480
+ }),
481
+ ]);
482
+ clearTimeout(timer);
483
+ this._close(entry, new LinkLostError("The handler was drained"), inTable);
484
+ }
485
+
486
+ private _close(entry: HandlerEntry, reason: Error, inTable = true): void {
487
+ if (inTable) this.table.remove(entry);
488
+ entry.stopWatch();
489
+ entry.sockets.failAll(reason);
490
+ void entry.link.close(reason);
491
+ void entry.closeUp?.().catch(() => {});
492
+ if (entry.ctl) {
493
+ entry.ctl.onmessage = null;
494
+ entry.ctl.close();
495
+ }
496
+ }
497
+
498
+ /**
499
+ * The handler for `request`: picked by `route` among the live handlers of
500
+ * its key, waiting for one when there is none.
501
+ */
502
+ private async _acquire(
503
+ request: Request,
504
+ key: string,
505
+ ): Promise<HandlerEntry | typeof TIMED_OUT | undefined> {
506
+ const picked = await this._pick(request, key);
507
+ 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()) {
520
+ // 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;
523
+ }
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
+ }
530
+
531
+ private async _pick(request: Request, key: string): Promise<HandlerEntry | undefined> {
532
+ let live = this.table.live(key);
533
+ // Without Web Locks there is no owner lock: check the page is still there.
534
+ for (const entry of live) {
535
+ if (entry.ownerLock || !entry.clientId) continue;
536
+ if (!(await this.self.clients.get(entry.clientId))) {
537
+ this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
538
+ }
539
+ }
540
+ live = this.table.live(key);
541
+ if (live.length === 0) return undefined;
542
+ const route = this.options.route ?? leastInflightRoute;
543
+ return route(request, key, live);
544
+ }
545
+
546
+ /**
547
+ * Asks every window to attach its links again, and waits until a handler
548
+ * for `key` is back, every window answered, or `relinkTimeoutMs` passed.
549
+ * At most one round per key every 10 s: a key whose tab is simply gone
550
+ * should not cost every request the wait.
551
+ */
552
+ private _askForLinks(key = ""): Promise<void> {
553
+ const round = this._relinkRounds.get(key);
554
+ // Requests arriving during a round wait for the same round; after it, a
555
+ // key whose tab is simply gone does not cost every request the wait.
556
+ if (round && Date.now() - round.at < 10_000) return round.done;
557
+ const done = this._linkRound(key);
558
+ this._relinkRounds.set(key, { at: Date.now(), done });
559
+ return done;
560
+ }
561
+
562
+ private async _linkRound(key: string): Promise<void> {
563
+ const timeout = this.options.relinkTimeoutMs ?? 3_000;
564
+ const windows = await this.self.clients.matchAll({ type: "window", includeUncontrolled: true });
565
+ this.log("[need-links]", key, windows.length);
566
+ const params: NeedLinksParams = { routerLock: this.routerLock };
567
+ const pending = new Set(windows);
568
+ const answered = Promise.allSettled(
569
+ windows.map((client) =>
570
+ callChannel(client, NEED_LINKS_CALL, params).then(() => pending.delete(client)),
571
+ ),
572
+ );
573
+ let timer: ReturnType<typeof setTimeout> | undefined;
574
+ const timedOut = new Promise<void>((resolve) => {
575
+ timer = setTimeout(resolve, timeout);
576
+ });
577
+ // Every window answering is the end of the round; a window without this
578
+ // package never answers, which is what the timeout is for.
579
+ await Promise.race([answered, timedOut]);
580
+ clearTimeout(timer);
581
+ // The windows that did not answer may run version 1 of this package,
582
+ // which re-links when asked the way version 1's worker asked.
583
+ for (const client of pending) this._askLegacyPage(client);
584
+ }
585
+
586
+ /** Asks a version-1 page for a fresh port, as version 1's worker did. */
587
+ private _askLegacyPage(client: Client): void {
588
+ const channel = new MessageChannel();
589
+ callChannel<AttachParams>(client, ATTACH_CALL, {}, channel.port2).then(
590
+ (info) => {
591
+ if (!info?.key || info.wire !== undefined) return;
592
+ const link = trackedLink(
593
+ (request) => sendHttpRequest(channel.port1, request),
594
+ () => channel.port1.close(),
595
+ );
596
+ this._add(
597
+ new HandlerEntry(
598
+ info.key,
599
+ client.id,
600
+ client.id,
601
+ undefined,
602
+ link,
603
+ undefined,
604
+ client.id,
605
+ undefined,
606
+ ),
607
+ );
608
+ },
609
+ () => channel.port1.close(),
610
+ );
611
+ }
612
+
613
+ private _onControl(entry: HandlerEntry, message: ControlMessage): void {
614
+ if (!message || typeof message !== "object") return;
615
+ switch (message.t) {
616
+ case "ws-accept":
617
+ // Too late (the caller gave up): close the port, or the handler's
618
+ // socket lingers until its heartbeat notices.
619
+ if (!entry.sockets.settle(message.id, { port: message.port, protocol: message.protocol })) {
620
+ message.port.close();
621
+ }
622
+ return;
623
+ case "ws-refuse":
624
+ entry.sockets.fail(message.id, new Error(message.message));
625
+ return;
626
+ case "ws-open":
627
+ // A handler opens a socket to another handler, through this router.
628
+ this.connectWebSocket(message.url, message.protocols).then(
629
+ ({ port, protocol }) =>
630
+ entry.ctl?.postMessage(
631
+ { t: "ws-opened", id: message.id, protocol, port } satisfies ControlMessage,
632
+ [port],
633
+ ),
634
+ (error) =>
635
+ entry.ctl?.postMessage({
636
+ t: "ws-failed",
637
+ id: message.id,
638
+ message: errorMessage(error),
639
+ } satisfies ControlMessage),
640
+ );
641
+ return;
642
+ }
643
+ }
644
+
645
+ private _isOwnClient(event: MessageEvent): boolean {
646
+ const source = event.source as Client | null;
647
+ if (!source?.url) return false;
648
+ try {
649
+ return new URL(source.url).origin === this.self.location.origin;
650
+ } catch {
651
+ return false;
652
+ }
653
+ }
654
+
655
+ private async _claim(key: string): Promise<void> {
656
+ if (!key || this.claimedKeys.has(key)) return;
657
+ this.claimedKeys.add(key);
658
+ try {
659
+ await set("claimedKeys", [...this.claimedKeys].sort());
660
+ } catch (error) {
661
+ this.log("[claimedKeys] not stored", error);
662
+ }
663
+ }
664
+
665
+ private async _restoreClaimedKeys(): Promise<void> {
666
+ const stored = ((await get<string[]>("claimedKeys")) ?? []) as string[];
667
+ for (const key of stored) this.claimedKeys.add(key);
668
+ }
114
669
  }
115
670
 
116
- export function startHttpDispatcher(options: SwPortDispatcherOptions): () => void {
671
+ /**
672
+ * Starts the same-origin dispatcher in a ServiceWorker. Returns a function
673
+ * that stops it.
674
+ */
675
+ export function startHttpDispatcher(options: SwHttpDispatcherOptions): () => void {
117
676
  const dispatcher = new SwHttpDispatcher(options);
118
677
  dispatcher.start();
119
678
  return () => {