@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.
- package/README.md +302 -52
- package/dist/core/data-calls.d.ts +17 -0
- package/dist/core/data-calls.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +3 -1
- package/dist/core/service-worker-control.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4218 -301
- package/dist/link/async-dispose.d.ts +10 -0
- package/dist/link/async-dispose.d.ts.map +1 -0
- package/dist/link/errors.d.ts +2 -0
- package/dist/link/errors.d.ts.map +1 -0
- package/dist/link/http-link.d.ts +72 -0
- package/dist/link/http-link.d.ts.map +1 -0
- package/dist/link/index.d.ts +4 -0
- package/dist/link/index.d.ts.map +1 -0
- package/dist/link/locks.d.ts +39 -0
- package/dist/link/locks.d.ts.map +1 -0
- package/dist/link/rendezvous.d.ts +21 -0
- package/dist/link/rendezvous.d.ts.map +1 -0
- package/dist/relay/index-sw.d.ts +12 -2
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +66 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +1328 -64
- package/dist/relay-worker.js +1329 -64
- package/dist/sw/handler-endpoint.d.ts +81 -0
- package/dist/sw/handler-endpoint.d.ts.map +1 -0
- package/dist/sw/handler-table.d.ts +53 -0
- package/dist/sw/handler-table.d.ts.map +1 -0
- package/dist/sw/http-sw-dispatcher.d.ts +162 -21
- package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/index.d.ts +5 -0
- package/dist/sw/index.d.ts.map +1 -1
- package/dist/sw/protocol.d.ts +115 -0
- package/dist/sw/protocol.d.ts.map +1 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +199 -0
- package/dist/sw/sw-http-adapter.d.ts.map +1 -0
- package/dist/sw/sw-websocket.d.ts +16 -0
- package/dist/sw/sw-websocket.d.ts.map +1 -0
- package/dist/sw/worker-service.d.ts +45 -0
- package/dist/sw/worker-service.d.ts.map +1 -0
- package/dist/sw/worker-url.d.ts +9 -0
- package/dist/sw/worker-url.d.ts.map +1 -0
- package/dist/sw-worker.js +2403 -265
- package/dist/sw.d.ts +2 -0
- package/dist/sw.d.ts.map +1 -1
- package/dist/sw.js +4189 -546
- package/dist/worker.d.ts +10 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +3780 -0
- package/dist/ws/client.d.ts +73 -0
- package/dist/ws/client.d.ts.map +1 -0
- package/dist/ws/frames.d.ts +68 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/index.d.ts +6 -0
- package/dist/ws/index.d.ts.map +1 -0
- package/dist/ws/server.d.ts +53 -0
- package/dist/ws/server.d.ts.map +1 -0
- package/dist/ws/socket-core.d.ts +91 -0
- package/dist/ws/socket-core.d.ts.map +1 -0
- package/dist/ws/upgrade.d.ts +93 -0
- package/dist/ws/upgrade.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/core/data-calls.ts +52 -2
- package/src/core/service-worker-control.ts +7 -1
- package/src/index.ts +3 -0
- package/src/link/async-dispose.ts +22 -0
- package/src/link/errors.ts +3 -0
- package/src/link/http-link.ts +238 -0
- package/src/link/index.ts +3 -0
- package/src/link/locks.ts +97 -0
- package/src/link/rendezvous.ts +60 -0
- package/src/relay/index-sw.ts +84 -12
- package/src/relay/index.ts +203 -27
- package/src/sw/handler-endpoint.ts +278 -0
- package/src/sw/handler-table.ts +130 -0
- package/src/sw/http-sw-dispatcher.ts +649 -90
- package/src/sw/index.ts +5 -0
- package/src/sw/protocol.ts +103 -0
- package/src/sw/sw-dispatcher.ts +1 -20
- package/src/sw/sw-http-adapter.ts +613 -0
- package/src/sw/sw-websocket.ts +43 -0
- package/src/sw/worker-service.ts +133 -0
- package/src/sw/worker-url.ts +19 -0
- package/src/sw-worker.ts +8 -3
- package/src/sw.ts +2 -0
- package/src/worker.ts +9 -0
- package/src/ws/client.ts +346 -0
- package/src/ws/frames.ts +119 -0
- package/src/ws/index.ts +11 -0
- package/src/ws/server.ts +132 -0
- package/src/ws/socket-core.ts +251 -0
- package/src/ws/upgrade.ts +200 -0
package/src/relay/index.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import type { HttpHandler } from "@statewalker/webrun-http-streams";
|
|
2
2
|
import { serializeError } from "@statewalker/webrun-streams";
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
93
|
-
*
|
|
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:
|
|
104
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
442
|
+
const stopAnswering = handleKeyedChannelCalls(
|
|
443
|
+
communicationPort,
|
|
444
|
+
"CONNECT",
|
|
445
|
+
key,
|
|
446
|
+
async (event, data, port) => await handler(event, data, port),
|
|
288
447
|
);
|
|
289
|
-
|
|
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
|
-
|
|
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
|
+
}
|