@statewalker/webrun-http-browser 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) 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 +3906 -265
  9. package/dist/link/errors.d.ts +2 -0
  10. package/dist/link/errors.d.ts.map +1 -0
  11. package/dist/link/http-link.d.ts +58 -0
  12. package/dist/link/http-link.d.ts.map +1 -0
  13. package/dist/link/index.d.ts +4 -0
  14. package/dist/link/index.d.ts.map +1 -0
  15. package/dist/link/locks.d.ts +39 -0
  16. package/dist/link/locks.d.ts.map +1 -0
  17. package/dist/link/rendezvous.d.ts +21 -0
  18. package/dist/link/rendezvous.d.ts.map +1 -0
  19. package/dist/relay/index-sw.d.ts +12 -2
  20. package/dist/relay/index-sw.d.ts.map +1 -1
  21. package/dist/relay/index.d.ts +66 -8
  22. package/dist/relay/index.d.ts.map +1 -1
  23. package/dist/relay-sw.js +1160 -55
  24. package/dist/relay-worker.js +1161 -55
  25. package/dist/sw/handler-endpoint.d.ts +81 -0
  26. package/dist/sw/handler-endpoint.d.ts.map +1 -0
  27. package/dist/sw/handler-table.d.ts +53 -0
  28. package/dist/sw/handler-table.d.ts.map +1 -0
  29. package/dist/sw/http-sw-dispatcher.d.ts +162 -21
  30. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  31. package/dist/sw/index.d.ts +5 -0
  32. package/dist/sw/index.d.ts.map +1 -1
  33. package/dist/sw/protocol.d.ts +115 -0
  34. package/dist/sw/protocol.d.ts.map +1 -0
  35. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  36. package/dist/sw/sw-http-adapter.d.ts +199 -0
  37. package/dist/sw/sw-http-adapter.d.ts.map +1 -0
  38. package/dist/sw/sw-websocket.d.ts +16 -0
  39. package/dist/sw/sw-websocket.d.ts.map +1 -0
  40. package/dist/sw/worker-service.d.ts +45 -0
  41. package/dist/sw/worker-service.d.ts.map +1 -0
  42. package/dist/sw/worker-url.d.ts +9 -0
  43. package/dist/sw/worker-url.d.ts.map +1 -0
  44. package/dist/sw-worker.js +2075 -210
  45. package/dist/sw.d.ts +2 -0
  46. package/dist/sw.d.ts.map +1 -1
  47. package/dist/sw.js +3901 -531
  48. package/dist/worker.d.ts +10 -0
  49. package/dist/worker.d.ts.map +1 -0
  50. package/dist/worker.js +3507 -0
  51. package/dist/ws/client.d.ts +73 -0
  52. package/dist/ws/client.d.ts.map +1 -0
  53. package/dist/ws/frames.d.ts +68 -0
  54. package/dist/ws/frames.d.ts.map +1 -0
  55. package/dist/ws/index.d.ts +6 -0
  56. package/dist/ws/index.d.ts.map +1 -0
  57. package/dist/ws/server.d.ts +53 -0
  58. package/dist/ws/server.d.ts.map +1 -0
  59. package/dist/ws/socket-core.d.ts +91 -0
  60. package/dist/ws/socket-core.d.ts.map +1 -0
  61. package/dist/ws/upgrade.d.ts +93 -0
  62. package/dist/ws/upgrade.d.ts.map +1 -0
  63. package/package.json +10 -5
  64. package/src/core/data-calls.ts +52 -2
  65. package/src/core/service-worker-control.ts +7 -1
  66. package/src/index.ts +3 -0
  67. package/src/link/errors.ts +3 -0
  68. package/src/link/http-link.ts +222 -0
  69. package/src/link/index.ts +3 -0
  70. package/src/link/locks.ts +97 -0
  71. package/src/link/rendezvous.ts +60 -0
  72. package/src/relay/index-sw.ts +84 -12
  73. package/src/relay/index.ts +203 -27
  74. package/src/sw/handler-endpoint.ts +278 -0
  75. package/src/sw/handler-table.ts +130 -0
  76. package/src/sw/http-sw-dispatcher.ts +649 -90
  77. package/src/sw/index.ts +5 -0
  78. package/src/sw/protocol.ts +103 -0
  79. package/src/sw/sw-dispatcher.ts +1 -20
  80. package/src/sw/sw-http-adapter.ts +613 -0
  81. package/src/sw/sw-websocket.ts +43 -0
  82. package/src/sw/worker-service.ts +133 -0
  83. package/src/sw/worker-url.ts +19 -0
  84. package/src/sw-worker.ts +8 -3
  85. package/src/sw.ts +2 -0
  86. package/src/worker.ts +9 -0
  87. package/src/ws/client.ts +346 -0
  88. package/src/ws/frames.ts +119 -0
  89. package/src/ws/index.ts +11 -0
  90. package/src/ws/server.ts +132 -0
  91. package/src/ws/socket-core.ts +251 -0
  92. package/src/ws/upgrade.ts +200 -0
@@ -1,13 +1,31 @@
1
1
  import type { HttpHandler } from "@statewalker/webrun-http-streams";
2
2
  import { serializeError } from "@statewalker/webrun-streams";
3
- import { type ChannelCallHandler, callChannel } from "../core/data-calls.js";
3
+ import {
4
+ type ChannelCallHandler,
5
+ callChannel,
6
+ callChannelWithin,
7
+ unwrapTransfer,
8
+ withTransfer,
9
+ } from "../core/data-calls.js";
4
10
  import type { MessageTarget } from "../core/message-target.js";
5
- import { newRegistry } from "../core/registry.js";
6
11
  import {
7
12
  awaitActiveServiceWorker,
8
13
  DEFAULT_SERVICE_WORKER_TIMEOUT,
9
14
  } from "../core/service-worker-control.js";
10
15
  import { handleHttpRequests, sendHttpRequest } from "../http/http-send-recieve.js";
16
+ import {
17
+ connectHttpLink,
18
+ HTTP_LINK_PROTOCOL,
19
+ type HttpLinkOptions,
20
+ serveHttpLink,
21
+ } from "../link/http-link.js";
22
+ import {
23
+ createWebSocketClass,
24
+ type PortWebSocketClass,
25
+ type WebSocketClassOptions,
26
+ type WebSocketConnection,
27
+ } from "../ws/client.js";
28
+ import { serveWebSocketUpgrade } from "../ws/upgrade.js";
11
29
 
12
30
  export * from "./split-service-url.js";
13
31
 
@@ -78,7 +96,7 @@ async function registerServiceWorker({
78
96
  return { registration, worker: navigator.serviceWorker.controller ?? active };
79
97
  }
80
98
 
81
- export interface ServiceOptions {
99
+ export interface ServiceOptions extends HttpLinkOptions {
82
100
  key: string;
83
101
  /**
84
102
  * Where this service is mounted on the relay origin, e.g. `/` or `/peers/`.
@@ -89,35 +107,166 @@ export interface ServiceOptions {
89
107
  }
90
108
 
91
109
  /**
92
- * Registers `handler` as the server for the given service `key` on the relay.
93
- * Returns a cleanup function that unregisters the service.
110
+ * What a service answers to a `CONNECT` that speaks `HTTP_LINK_PROTOCOL`. A
111
+ * service of version 1 answers `true` instead, and the caller then falls back
112
+ * to version 1's transport: both ends of a call must speak the same one, and
113
+ * the relay, the caller and the service may each run another version.
114
+ */
115
+ export interface ConnectReply {
116
+ accepted: true;
117
+ wire: string;
118
+ }
119
+
120
+ /** A `CONNECT` asking a service for a socket. */
121
+ export interface WebSocketConnectParams {
122
+ type: "ws";
123
+ key: string;
124
+ url: string;
125
+ protocols?: string[];
126
+ headers?: [string, string][];
127
+ }
128
+
129
+ /**
130
+ * Serves a service's side of one `CONNECT`: an HTTP link on `callPort`, or a
131
+ * WebSocket upgrade run through `handler`.
132
+ */
133
+ export async function answerConnect(
134
+ handler: HttpHandler,
135
+ data: unknown,
136
+ callPort: MessagePort | undefined,
137
+ options: HttpLinkOptions = {},
138
+ ): Promise<unknown> {
139
+ const params = (data ?? {}) as { type?: string; wire?: string };
140
+ if (params.type === "ws") {
141
+ callPort?.close();
142
+ const { url, protocols = [], headers } = params as WebSocketConnectParams;
143
+ const result = await serveWebSocketUpgrade(handler, { url, protocols, headers });
144
+ if (!result.ok) throw new Error(result.message);
145
+ const connection: WebSocketConnection = { port: result.port, protocol: result.protocol };
146
+ return withTransfer(connection, [result.port]);
147
+ }
148
+ if (!callPort) throw new Error("A CONNECT for HTTP carries a port");
149
+ if (params.wire === HTTP_LINK_PROTOCOL) {
150
+ await serveHttpLink(callPort, handler, options);
151
+ return { accepted: true, wire: HTTP_LINK_PROTOCOL } satisfies ConnectReply;
152
+ }
153
+ // A caller or relay of version 1.
154
+ handleHttpRequests(callPort, handler);
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * Registers `handler` as the server for the given service `key` on the relay:
160
+ * it answers HTTP requests and WebSocket upgrades. Returns a function that
161
+ * unregisters the service; its promise settles once the relay acknowledged.
94
162
  */
95
163
  export async function initHttpService(
96
164
  handler: HttpHandler,
97
- { key, path, port }: ServiceOptions,
98
- ): Promise<() => void> {
165
+ { key, path, port, streamTimeout }: ServiceOptions,
166
+ ): Promise<() => Promise<void>> {
99
167
  return await registerConnectionsHandler({
100
168
  key,
101
169
  path,
102
170
  communicationPort: port,
103
- handler: async (_event, _data, callPort) => {
104
- handleHttpRequests(callPort, handler);
105
- return true;
106
- },
171
+ handler: (_event, data, callPort) =>
172
+ answerConnect(handler, data, callPort, { streamTimeout }) as Promise<boolean>,
107
173
  });
108
174
  }
109
175
 
110
176
  /**
111
177
  * Sends a `Request` to the service registered under `key` on the relay and
112
- * resolves with the corresponding `Response`.
178
+ * resolves with the corresponding `Response`, whose body streams with
179
+ * backpressure.
113
180
  */
114
181
  export async function callHttpService(
115
182
  request: Request,
116
- { key, port }: ServiceOptions,
183
+ { key, port, streamTimeout }: ServiceOptions,
184
+ ): Promise<Response> {
185
+ const channel = new MessageChannel();
186
+ const reply = await callChannel<boolean | ConnectReply>(
187
+ port,
188
+ "CONNECT",
189
+ { key, type: "http", wire: HTTP_LINK_PROTOCOL },
190
+ channel.port2,
191
+ );
192
+ if (!reply) {
193
+ channel.port1.close();
194
+ throw new Error(`No service with key "${key}"`);
195
+ }
196
+ return await sendOverConnection(channel.port1, reply, request, { streamTimeout });
197
+ }
198
+
199
+ /**
200
+ * Sends `request` on a port a service accepted with `reply`: over an HTTP link
201
+ * when the service speaks `HTTP_LINK_PROTOCOL`, over version 1's transport
202
+ * when it answered `true`. The link is closed once the response is done.
203
+ */
204
+ export async function sendOverConnection(
205
+ port: MessagePort,
206
+ reply: boolean | ConnectReply,
207
+ request: Request,
208
+ options: HttpLinkOptions = {},
117
209
  ): Promise<Response> {
118
- const callPort = await initializeConnection({ key, communicationPort: port });
119
- if (!callPort) throw new Error(`No service with key "${key}"`);
120
- return await sendHttpRequest(callPort, request);
210
+ if (reply === true) return await sendHttpRequest(port, request);
211
+ const link = await connectHttpLink(port, options);
212
+ try {
213
+ const response = await link.send(request);
214
+ void link.whenIdle().then(() => link.close());
215
+ return response;
216
+ } catch (error) {
217
+ await link.close();
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Opens the transport of a socket to the service registered under `key` on
224
+ * the relay: the service runs the upgrade through its handler, and the
225
+ * socket's port comes back through the relay. After that, the socket runs
226
+ * between the caller and the service directly.
227
+ */
228
+ export async function callWebSocketService(
229
+ url: string | URL,
230
+ protocols: string[],
231
+ { key, port, timeout = 15_000 }: Pick<ServiceOptions, "key" | "port"> & { timeout?: number },
232
+ ): Promise<WebSocketConnection> {
233
+ const httpUrl = new URL(`${url}`, globalThis.location?.href);
234
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
235
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
236
+ const channel = new MessageChannel();
237
+ try {
238
+ // Bounded: a relay worker or a service of version 1 cannot answer a
239
+ // socket request (the first fails to pass the port on, the second
240
+ // answers `true`), and the caller must not wait for ever.
241
+ const reply = await callChannelWithin<unknown>(
242
+ timeout,
243
+ port,
244
+ "CONNECT",
245
+ { type: "ws", key, url: `${httpUrl}`, protocols } satisfies WebSocketConnectParams,
246
+ channel.port2,
247
+ );
248
+ const connection = reply as Partial<WebSocketConnection> | null;
249
+ if (!(connection?.port instanceof MessagePort)) {
250
+ throw new Error(`The service "${key}" does not accept WebSockets (an older version?)`);
251
+ }
252
+ return { port: connection.port, protocol: connection.protocol ?? "" };
253
+ } finally {
254
+ channel.port1.close();
255
+ }
256
+ }
257
+
258
+ /**
259
+ * A WHATWG `WebSocket` class whose sockets reach the service registered under
260
+ * `key` on the relay.
261
+ */
262
+ export function relayWebSocketClass(
263
+ service: Pick<ServiceOptions, "key" | "port">,
264
+ options?: WebSocketClassOptions,
265
+ ): PortWebSocketClass {
266
+ return createWebSocketClass(
267
+ (url, protocols) => callWebSocketService(url, protocols, service),
268
+ options,
269
+ );
121
270
  }
122
271
 
123
272
  export interface RelayWindowHandlerOptions {
@@ -266,27 +415,52 @@ export interface RegisterConnectionsHandlerOptions {
266
415
  key: string;
267
416
  /** Where this service is mounted; see `ServiceOptions.path`. */
268
417
  path?: string;
269
- handler: (event: MessageEvent, data: unknown, port: MessagePort) => boolean | Promise<boolean>;
418
+ /**
419
+ * Answers one `CONNECT`. Its result is the reply; wrap a result carrying a
420
+ * port in `withTransfer`.
421
+ */
422
+ handler: (event: MessageEvent, data: unknown, port: MessagePort) => unknown | Promise<unknown>;
270
423
  communicationPort: MessageTarget;
271
424
  }
272
425
 
426
+ /**
427
+ * Registers `key` on the relay and answers its `CONNECT`s with `handler`.
428
+ * Resolves with a function that unregisters it. That function's promise
429
+ * settles once the relay acknowledged the `UNREGISTER`, so a caller can stop
430
+ * its services and only then close the connection: closing first tore the
431
+ * ports down before the message landed, and left the mount behind.
432
+ */
273
433
  export async function registerConnectionsHandler({
274
434
  key,
275
435
  path,
276
436
  handler,
277
437
  communicationPort,
278
- }: RegisterConnectionsHandlerOptions): Promise<() => void> {
279
- const [register, cleanup] = newRegistry();
438
+ }: RegisterConnectionsHandlerOptions): Promise<() => Promise<void>> {
280
439
  // `path` is omitted rather than sent as undefined: the worker distinguishes
281
440
  // "mounted at /" from "not mounted", and a key with no path keeps /~<key>/.
282
441
  await callChannel(communicationPort, "REGISTER", path == null ? { key } : { key, path });
283
- register(() => callChannel(communicationPort, "UNREGISTER", { key }));
284
- register(
285
- handleKeyedChannelCalls(communicationPort, "CONNECT", key, async (event, data, port) => {
286
- return await handler(event, data, port);
287
- }),
442
+ const stopAnswering = handleKeyedChannelCalls(
443
+ communicationPort,
444
+ "CONNECT",
445
+ key,
446
+ async (event, data, port) => await handler(event, data, port),
288
447
  );
289
- return cleanup;
448
+ let stopped: Promise<void> | undefined;
449
+ return () => {
450
+ stopped ??= (async () => {
451
+ try {
452
+ await callChannel(communicationPort, "UNREGISTER", { key });
453
+ } catch (error) {
454
+ // The connection is gone, or the relay refused: nothing left to undo here.
455
+ console.warn(`[relay] UNREGISTER of "${key}" failed`, error);
456
+ } finally {
457
+ // After the relay stopped routing here, not before: a CONNECT landing
458
+ // in between would otherwise get no answer at all.
459
+ stopAnswering();
460
+ }
461
+ })();
462
+ return stopped;
463
+ };
290
464
  }
291
465
 
292
466
  /**
@@ -322,12 +496,14 @@ function handleKeyedChannelCalls(
322
496
  if (data.params?.key !== key) return;
323
497
  const [port, ...transfers] = (event.ports ?? []) as MessagePort[];
324
498
  const response: { result?: unknown; error?: unknown } = {};
499
+ let transfer: Transferable[] = [];
325
500
  try {
326
- response.result = await handler(event, data.params, ...transfers);
501
+ const result = await handler(event, data.params, ...transfers);
502
+ ({ value: response.result, transfer } = unwrapTransfer(result));
327
503
  } catch (error) {
328
504
  response.error = serializeError(error);
329
505
  }
330
- port?.postMessage(response);
506
+ port?.postMessage(response, transfer);
331
507
  };
332
508
  target.addEventListener("message", listener);
333
509
  target.start?.();
@@ -0,0 +1,278 @@
1
+ import { errorMessage } from "../link/errors.js";
2
+ import {
3
+ connectHttpLink,
4
+ type HttpLink,
5
+ type HttpLinkOptions,
6
+ LinkLostError,
7
+ serveHttpLink,
8
+ } from "../link/http-link.js";
9
+ import { watchLock } from "../link/locks.js";
10
+ import { PortRendezvous } from "../link/rendezvous.js";
11
+ import type { WebSocketConnection } from "../ws/client.js";
12
+ import { serveWebSocketUpgrade } from "../ws/upgrade.js";
13
+ import type { ControlMessage } from "./protocol.js";
14
+
15
+ export type FetchHandler = (request: Request) => Response | Promise<Response>;
16
+
17
+ export interface HandlerEndpointOptions extends HttpLinkOptions {
18
+ /** Asks whoever attaches this endpoint to do it again (its links died). */
19
+ requestRelink?: () => void;
20
+ /** Pokes the ServiceWorker so that it stays awake while calls are in flight. */
21
+ keepAlive?: () => void;
22
+ /** Period of `keepAlive` while calls are in flight, in ms. Default 20 s. */
23
+ keepAliveMs?: number;
24
+ /** How often a waiting call asks for a link again, in ms. Default 2 s. */
25
+ relinkRetryMs?: number;
26
+ /** How long a call waits for a link before failing, in ms. Default 30 s. */
27
+ relinkTimeoutMs?: number;
28
+ /** How long a socket waits for the far handler's answer, in ms. Default 15 s. */
29
+ wsConnectTimeoutMs?: number;
30
+ /**
31
+ * How long a link replaced by a newer one of the same router keeps serving
32
+ * what it has, in ms. The ServiceWorker closes it first, once its requests
33
+ * are done; this only bounds a link it forgot. Default 30 s.
34
+ */
35
+ supersededLinkMs?: number;
36
+ }
37
+
38
+ export interface EndpointLinkPorts {
39
+ down: MessagePort;
40
+ ctl?: MessagePort;
41
+ up?: MessagePort;
42
+ }
43
+
44
+ interface LiveLink {
45
+ key: string;
46
+ routerLock?: string;
47
+ ctl?: MessagePort;
48
+ up?: HttpLink;
49
+ closeDown: () => Promise<void>;
50
+ stopWatch: () => void;
51
+ /** A newer link of the same key took over: finish, take nothing new. */
52
+ superseded?: boolean;
53
+ }
54
+
55
+ /** The message calls through a lost ServiceWorker fail with. */
56
+ export const ROUTER_LOST_MESSAGE = "The ServiceWorker was lost while this call was in flight";
57
+
58
+ /**
59
+ * The handler's end of its links to the ServiceWorker, the same in a page and
60
+ * in a Dedicated Worker.
61
+ *
62
+ * - Serves `handler` on each link's `down` port, and answers WebSocket
63
+ * upgrades arriving on its `ctl` port by running them through `handler`.
64
+ * - Sends requests through the router on `up` (`fetch`) and opens sockets to
65
+ * other handlers over `ctl` (`connectWebSocket`).
66
+ * - Watches the ServiceWorker's router lock. When the ServiceWorker dies, the
67
+ * links made for it are dropped: calls in flight on them fail with
68
+ * `LinkLostError` ("may or may not have been applied") instead of hanging,
69
+ * and new calls wait for a link, asking for one (`requestRelink`) every
70
+ * `relinkRetryMs` until it comes.
71
+ * - While calls are in flight, calls `keepAlive` every `keepAliveMs`: port
72
+ * messages are not events, so without it the browser may stop the
73
+ * ServiceWorker in the middle of a long call.
74
+ */
75
+ export class HandlerEndpoint {
76
+ private readonly _links = new Set<LiveLink>();
77
+ private _waiters: Array<() => void> = [];
78
+ private readonly _sockets = new PortRendezvous<WebSocketConnection>("o");
79
+ private _keepAliveTimer?: ReturnType<typeof setInterval>;
80
+ private _closed = false;
81
+
82
+ constructor(
83
+ readonly handler: FetchHandler,
84
+ readonly options: HandlerEndpointOptions = {},
85
+ ) {}
86
+
87
+ /** Whether any link is live. */
88
+ get linked(): boolean {
89
+ return this._links.size > 0;
90
+ }
91
+
92
+ /** Installs a fresh link made for the router holding `routerLock`. */
93
+ async link(key: string, ports: EndpointLinkPorts, routerLock?: string): Promise<void> {
94
+ if (this._closed) {
95
+ for (const port of Object.values(ports)) port?.close();
96
+ return;
97
+ }
98
+ const closeDown = await serveHttpLink(ports.down, this.handler, this.options);
99
+ const up = ports.up ? await connectHttpLink(ports.up, this.options) : undefined;
100
+ const link: LiveLink = { key, routerLock, ctl: ports.ctl, up, closeDown, stopWatch: () => {} };
101
+ if (ports.ctl) {
102
+ ports.ctl.onmessage = (event) => this._onControl(link, event.data as ControlMessage);
103
+ }
104
+ // Each watch drops only the links made for its own router, so a link made
105
+ // by the next router is never dropped by the death of the previous one.
106
+ if (routerLock) link.stopWatch = watchLock(routerLock, () => this._routerLost(routerLock));
107
+ for (const old of [...this._links]) {
108
+ if (old.key !== key) continue;
109
+ if (old.routerLock !== routerLock) {
110
+ // Made for another router: it belongs to a dead ServiceWorker,
111
+ // whatever its lock watch has not noticed yet.
112
+ void this._drop(old, new LinkLostError(ROUTER_LOST_MESSAGE));
113
+ } else if (!old.superseded) {
114
+ // Same router: the ServiceWorker lets it finish its requests in
115
+ // flight, so it keeps serving them; it only stops being picked.
116
+ old.superseded = true;
117
+ setTimeout(() => void this._drop(old), this.options.supersededLinkMs ?? 30_000);
118
+ }
119
+ }
120
+ this._links.add(link);
121
+ for (const wake of this._waiters.splice(0)) wake();
122
+ }
123
+
124
+ /** Drops the links attached under `key` (all of them without a key). */
125
+ async unlink(key?: string, reason?: Error): Promise<void> {
126
+ const dropped = [...this._links].filter((link) => key === undefined || link.key === key);
127
+ await Promise.all(dropped.map((link) => this._drop(link, reason)));
128
+ }
129
+
130
+ /** The routers this endpoint has live links for. */
131
+ routerLocks(): Set<string | undefined> {
132
+ return new Set([...this._links].map((link) => link.routerLock));
133
+ }
134
+
135
+ /**
136
+ * Sends `request` through the ServiceWorker's router, as if the browser had
137
+ * fetched it: it reaches the handler its url routes to.
138
+ */
139
+ async fetch(request: Request): Promise<Response> {
140
+ const link = await this._linkWith("up");
141
+ this._startKeepAlive();
142
+ return await (link.up as HttpLink).send(request);
143
+ }
144
+
145
+ /** Opens a socket to the handler `url` routes to, through the ServiceWorker. */
146
+ async connectWebSocket(url: string, protocols: string[] = []): Promise<WebSocketConnection> {
147
+ const link = await this._linkWith("ctl");
148
+ const id = this._sockets.newId();
149
+ const answer = this._sockets.wait(
150
+ id,
151
+ this.options.wsConnectTimeoutMs ?? 15_000,
152
+ `the socket request for ${url}`,
153
+ );
154
+ link.ctl?.postMessage({ t: "ws-open", id, url, protocols } satisfies ControlMessage);
155
+ return await answer;
156
+ }
157
+
158
+ async close(): Promise<void> {
159
+ this._closed = true;
160
+ if (this._keepAliveTimer) clearInterval(this._keepAliveTimer);
161
+ await this.unlink(undefined, new LinkLostError("The handler was closed"));
162
+ for (const wake of this._waiters.splice(0)) wake();
163
+ }
164
+
165
+ private async _drop(link: LiveLink, reason?: Error): Promise<void> {
166
+ if (!this._links.delete(link)) return;
167
+ link.stopWatch();
168
+ await link.up?.close(reason);
169
+ try {
170
+ await link.closeDown();
171
+ } catch {}
172
+ if (link.ctl) {
173
+ link.ctl.onmessage = null;
174
+ link.ctl.close();
175
+ }
176
+ }
177
+
178
+ private _routerLost(routerLock: string): void {
179
+ const reason = new LinkLostError(ROUTER_LOST_MESSAGE);
180
+ for (const link of [...this._links]) {
181
+ if (link.routerLock === routerLock) void this._drop(link, reason);
182
+ }
183
+ this._sockets.failAll(reason);
184
+ }
185
+
186
+ /** A live link with `port`, waiting (and asking) for one when there is none. */
187
+ private async _linkWith(port: "up" | "ctl"): Promise<LiveLink> {
188
+ const find = () =>
189
+ [...this._links]
190
+ .filter((link) => !link.superseded)
191
+ .reverse()
192
+ .find((link) => (port === "up" ? link.up && !link.up.closed : link.ctl));
193
+ const found = find();
194
+ if (found) return found;
195
+ const retryMs = this.options.relinkRetryMs ?? 2_000;
196
+ const deadline = Date.now() + (this.options.relinkTimeoutMs ?? 30_000);
197
+ while (!this._closed) {
198
+ this.options.requestRelink?.();
199
+ await new Promise<void>((resolve) => {
200
+ const timer = setTimeout(resolve, Math.max(0, Math.min(retryMs, deadline - Date.now())));
201
+ this._waiters.push(() => {
202
+ clearTimeout(timer);
203
+ resolve();
204
+ });
205
+ });
206
+ const link = find();
207
+ if (link) return link;
208
+ if (Date.now() >= deadline) break;
209
+ }
210
+ throw new LinkLostError("No link to the ServiceWorker could be made for this call");
211
+ }
212
+
213
+ private _startKeepAlive(): void {
214
+ if (this._keepAliveTimer || !this.options.keepAlive) return;
215
+ this._keepAliveTimer = setInterval(() => {
216
+ const inflight = [...this._links].reduce((sum, link) => sum + (link.up?.inflight ?? 0), 0);
217
+ if (inflight === 0) {
218
+ clearInterval(this._keepAliveTimer);
219
+ this._keepAliveTimer = undefined;
220
+ return;
221
+ }
222
+ this.options.keepAlive?.();
223
+ }, this.options.keepAliveMs ?? 20_000);
224
+ }
225
+
226
+ private _onControl(link: LiveLink, message: ControlMessage): void {
227
+ if (!message || typeof message !== "object") return;
228
+ switch (message.t) {
229
+ case "ws-connect":
230
+ void this._acceptSocket(link, message);
231
+ return;
232
+ case "ws-opened":
233
+ if (!this._sockets.settle(message.id, { port: message.port, protocol: message.protocol })) {
234
+ message.port.close();
235
+ }
236
+ return;
237
+ case "ws-failed":
238
+ this._sockets.fail(message.id, new Error(message.message));
239
+ return;
240
+ }
241
+ }
242
+
243
+ private async _acceptSocket(
244
+ link: LiveLink,
245
+ { id, url, protocols, headers }: Extract<ControlMessage, { t: "ws-connect" }>,
246
+ ): Promise<void> {
247
+ const ctl = link.ctl;
248
+ if (!ctl) return;
249
+ try {
250
+ const result = await serveWebSocketUpgrade(this.handler, { url, protocols, headers });
251
+ if (result.ok) {
252
+ ctl.postMessage(
253
+ {
254
+ t: "ws-accept",
255
+ id,
256
+ protocol: result.protocol,
257
+ port: result.port,
258
+ } satisfies ControlMessage,
259
+ [result.port],
260
+ );
261
+ } else {
262
+ ctl.postMessage({
263
+ t: "ws-refuse",
264
+ id,
265
+ status: result.status,
266
+ message: result.message,
267
+ } satisfies ControlMessage);
268
+ }
269
+ } catch (error) {
270
+ ctl.postMessage({
271
+ t: "ws-refuse",
272
+ id,
273
+ status: 500,
274
+ message: errorMessage(error),
275
+ } satisfies ControlMessage);
276
+ }
277
+ }
278
+ }