@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
@@ -0,0 +1,613 @@
1
+ import type { HttpHandler } from "@statewalker/webrun-http-streams";
2
+ import { callChannel, callChannelWithin, handleChannelCalls } from "../core/data-calls.js";
3
+ import { withDeadline } from "../core/deadline.js";
4
+ import {
5
+ awaitServiceWorkerControl,
6
+ DEFAULT_SERVICE_WORKER_TIMEOUT,
7
+ ServiceWorkerControlError,
8
+ } from "../core/service-worker-control.js";
9
+ import { HTTP_LINK_PROTOCOL } from "../link/http-link.js";
10
+ import { heldLockNames, holdLock, locksAvailable, newLockName } from "../link/locks.js";
11
+ import {
12
+ createWebSocketClass,
13
+ type PortWebSocketClass,
14
+ type WebSocketClassOptions,
15
+ type WebSocketConnection,
16
+ } from "../ws/client.js";
17
+ import { serveWebSocketUpgrade } from "../ws/upgrade.js";
18
+ import { HandlerEndpoint } from "./handler-endpoint.js";
19
+ import {
20
+ ATTACH_CALL,
21
+ type AttachParams,
22
+ type AttachReply,
23
+ DETACH_CALL,
24
+ DRAIN_CALL,
25
+ KEEPALIVE_MESSAGE,
26
+ NEED_LINKS_CALL,
27
+ type NeedLinksParams,
28
+ WAKE_MESSAGE,
29
+ WORKER_HELLO,
30
+ type WorkerControlMessage,
31
+ WS_CONNECT_CALL,
32
+ } from "./protocol.js";
33
+ import { resolveWorkerUrl } from "./worker-url.js";
34
+
35
+ export interface SwHttpAdapterOptions {
36
+ /** The first path segment under the scope that this page's handlers serve. */
37
+ key: string;
38
+ scope?: string;
39
+ serviceWorkerUrl?: string;
40
+ /**
41
+ * Upper bound, in ms, for `start()`'s wait for the worker to activate, take
42
+ * control of the page and answer the handshake. `start()` rejects with a
43
+ * `ServiceWorkerControlError` past it instead of waiting forever.
44
+ * Default `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
45
+ */
46
+ timeout?: number;
47
+ /**
48
+ * If the page is still uncontrolled once the worker is active — after a
49
+ * hard reload, say — and the worker does not take it over when asked,
50
+ * reload the page once instead of rejecting. Default `false`.
51
+ */
52
+ reloadIfUncontrolled?: boolean;
53
+ /**
54
+ * Per-stream inactivity timeout of the links, in ms (webrun-rpc's): a
55
+ * request or response body with no chunk for that long fails. Unset — the
56
+ * default — means none: a slow reader is throttled, never failed.
57
+ */
58
+ streamTimeout?: number;
59
+ /** The handler slot of this page's own handlers. Default: one per adapter. */
60
+ id?: string;
61
+ /** What a `route` function in the ServiceWorker sees of this page's handlers. */
62
+ meta?: unknown;
63
+ /** Options of the `WebSocket` class (`adapter.WebSocket`). */
64
+ webSocket?: WebSocketClassOptions;
65
+ /** @deprecated Version 1 only; ignored. */
66
+ bindPort?: (port: MessagePort) => void | Promise<void>;
67
+ }
68
+
69
+ export interface SwHttpRegistration {
70
+ baseUrl: string;
71
+ prefix: string;
72
+ remove(): Promise<void>;
73
+ }
74
+
75
+ export interface AttachWorkerOptions {
76
+ /** The key the worker serves. Default: the adapter's. */
77
+ key?: string;
78
+ /** The handler slot. Attaching an id the key has already replaces it. Default: a fresh one. */
79
+ id?: string;
80
+ /** What a `route` function sees of this handler. */
81
+ meta?: unknown;
82
+ }
83
+
84
+ /** A Dedicated Worker attached as a handler. */
85
+ export interface WorkerLink {
86
+ readonly key: string;
87
+ readonly id: string;
88
+ /** Token of this attachment; `drain` and `detach` name it. */
89
+ readonly owner: string;
90
+ /**
91
+ * Hand-over: the worker gets no new requests (they wait for the next owner
92
+ * of the slot), and those in flight may finish for up to `ms`. Then the
93
+ * link is closed.
94
+ */
95
+ drain(options?: { ms?: number }): Promise<void>;
96
+ /** Removes the link now. Requests in flight fail. */
97
+ detach(): Promise<void>;
98
+ }
99
+
100
+ /**
101
+ * The page side of the same-origin ServiceWorker: registers the worker, and
102
+ * attaches handler links to it.
103
+ *
104
+ * - `register(prefix, handler)` serves a handler in this page, under
105
+ * `<scope>/<key>/...`.
106
+ * - `attachWorker(worker)` links a Dedicated Worker that runs
107
+ * `serveHttpInWorker(handler)`: the ServiceWorker then talks to the worker
108
+ * directly, and this page carries nothing.
109
+ * - `connectWebSocket(url, protocols)` and `WebSocket` open sockets to
110
+ * handlers, through the ServiceWorker or straight to a handler of this page.
111
+ *
112
+ * Links are attached again whenever the ServiceWorker asks (after a restart),
113
+ * and requests in flight on a handler that dies fail at once.
114
+ */
115
+ export class SwHttpAdapter {
116
+ readonly options: SwHttpAdapterOptions;
117
+ private readonly _handlers = new Map<string, HttpHandler>();
118
+ private readonly _endpoint: HandlerEndpoint;
119
+ private readonly _ownerLock = newLockName("webrun-http-owner");
120
+ private readonly _id: string;
121
+ private readonly _owner = crypto.randomUUID();
122
+ private readonly _workers = new Set<WorkerLinkImpl>();
123
+ private _releaseOwner?: () => void;
124
+ private _serviceWorkerUrl?: string;
125
+ private _serviceWorker?: ServiceWorker;
126
+ private _registration?: ServiceWorkerRegistration;
127
+ private _registrationPromise?: Promise<void>;
128
+ private _cleanup: Array<() => void> = [];
129
+ private _attaching?: Promise<void>;
130
+ private _routerLock?: string;
131
+ private _WebSocket?: PortWebSocketClass;
132
+
133
+ constructor(options: SwHttpAdapterOptions) {
134
+ this.options = options;
135
+ if (!this.key) throw new Error("Key is not defined.");
136
+ if (!this.scope) throw new Error("Scope is not defined");
137
+ this._id = options.id ?? `page:${crypto.randomUUID()}`;
138
+ this._endpoint = new HandlerEndpoint((request) => this._handleHttpRequest(request), {
139
+ streamTimeout: options.streamTimeout,
140
+ });
141
+ }
142
+
143
+ get key(): string {
144
+ return this.options.key;
145
+ }
146
+
147
+ get scope(): string {
148
+ return this.options.scope ?? new URL("./", this.serviceWorkerUrl).pathname;
149
+ }
150
+
151
+ /**
152
+ * The scope as a url on the worker's origin. Resolved against the worker's
153
+ * own url when one is given, not against this module's: a bundle that is
154
+ * not an ES module (an IIFE, Theia's frontend) has no `import.meta.url`, and
155
+ * `new URL(scope, undefined)` threw `Invalid URL` there. Only without a
156
+ * worker url does the module's location decide (its default `sw-worker.js`
157
+ * sits next to it).
158
+ */
159
+ get rootUrl(): URL {
160
+ const base = this.options.serviceWorkerUrl ? this.serviceWorkerUrl : import.meta.url;
161
+ return new URL(this.scope, base);
162
+ }
163
+
164
+ get serviceWorkerUrl(): string {
165
+ if (!this._serviceWorkerUrl) {
166
+ const url = this.options.serviceWorkerUrl
167
+ ? resolveWorkerUrl(this.options.serviceWorkerUrl)
168
+ : new URL("./sw-worker.js", this.rootUrl);
169
+ this._serviceWorkerUrl = `${url}`;
170
+ }
171
+ return this._serviceWorkerUrl;
172
+ }
173
+
174
+ /**
175
+ * Registers the worker and waits until it is activated and controls this
176
+ * page, then attaches this page's handler link. The page must be controlled,
177
+ * because only a controlled page's `fetch()` reaches the worker. When the
178
+ * page loaded uncontrolled (a hard reload does that), the worker is asked to
179
+ * claim it. Rejects with a `ServiceWorkerControlError` when that fails or
180
+ * `timeout` passes (`reason` says which step); a later `start()` tries
181
+ * again.
182
+ */
183
+ async start(): Promise<void> {
184
+ if (!this._registrationPromise) {
185
+ this._registrationPromise = (async () => {
186
+ const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl, {
187
+ scope: this.scope,
188
+ });
189
+ this._registration = registration;
190
+ const stopListening = handleChannelCalls(
191
+ navigator.serviceWorker,
192
+ NEED_LINKS_CALL,
193
+ async (_event, params) => {
194
+ await this.relink((params as NeedLinksParams | undefined)?.routerLock);
195
+ return true;
196
+ },
197
+ );
198
+ const onControllerChange = () => {
199
+ const controller = navigator.serviceWorker.controller;
200
+ if (controller) this._serviceWorker = controller;
201
+ };
202
+ navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
203
+ this._cleanup.push(stopListening, () =>
204
+ navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange),
205
+ );
206
+
207
+ const timeout = this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT;
208
+ const deadline = Date.now() + timeout;
209
+ try {
210
+ this._serviceWorker = await awaitServiceWorkerControl(registration, {
211
+ timeout,
212
+ reloadIfUncontrolled: this.options.reloadIfUncontrolled,
213
+ });
214
+ this._releaseOwner ??= await holdLock(this._ownerLock);
215
+ await withDeadline(
216
+ deadline,
217
+ this._attachPage(),
218
+ () =>
219
+ new ServiceWorkerControlError(
220
+ "unresponsive",
221
+ `ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did ` +
222
+ `not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. ` +
223
+ "Check that the worker script runs this package's same-origin dispatcher " +
224
+ "(it importScripts `sw-worker.js`, or calls `startHttpDispatcher`).",
225
+ ),
226
+ );
227
+ } catch (error) {
228
+ // Forget this attempt so a later start() retries. Keep the
229
+ // registration: the worker is fine — it is this page that is not
230
+ // controlled, and other pages may be using the worker.
231
+ for (const cleanup of this._cleanup.splice(0)) cleanup();
232
+ this._registration = undefined;
233
+ this._registrationPromise = undefined;
234
+ throw error;
235
+ }
236
+ })();
237
+ }
238
+ return this._registrationPromise;
239
+ }
240
+
241
+ /** Detaches every link, and unregisters the worker. */
242
+ async stop(): Promise<void> {
243
+ for (const cleanup of this._cleanup.splice(0)) cleanup();
244
+ for (const worker of [...this._workers]) await worker.detach().catch(() => {});
245
+ const sw = this._serviceWorker;
246
+ if (sw) {
247
+ await callChannel(sw, DETACH_CALL, { key: this.key, id: this._id, owner: this._owner }).catch(
248
+ () => {},
249
+ );
250
+ }
251
+ await this._endpoint.unlink();
252
+ this._releaseOwner?.();
253
+ this._releaseOwner = undefined;
254
+ const registration = this._registration;
255
+ this._registration = undefined;
256
+ this._registrationPromise = undefined;
257
+ try {
258
+ await registration?.unregister();
259
+ } catch (error) {
260
+ console.log("Service Worker unregistration failed: ", error);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Registers an HTTP handler on a prefix. Returns the resulting base URL and a
266
+ * disposer.
267
+ */
268
+ async register(prefix: string, handler: HttpHandler): Promise<SwHttpRegistration> {
269
+ const cleanPrefix = `./${(prefix ?? "").replace(/^[./]+/, "")}`;
270
+ const baseUrl = `${new URL(cleanPrefix, this.rootUrl)}`;
271
+ this._handlers.set(baseUrl, handler);
272
+ const handlers = this._handlers;
273
+ return {
274
+ baseUrl,
275
+ prefix: cleanPrefix,
276
+ async remove() {
277
+ handlers.delete(baseUrl);
278
+ },
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Links a Dedicated Worker that runs `serveHttpInWorker(handler)` as a
284
+ * handler of `key`. The ServiceWorker sends it requests directly, without
285
+ * this page relaying anything, so a busy or throttled page does not slow
286
+ * them down.
287
+ */
288
+ async attachWorker(worker: Worker, options: AttachWorkerOptions = {}): Promise<WorkerLink> {
289
+ await this.start();
290
+ // The hello is repeated until the worker answers: a worker that calls
291
+ // `serveHttpInWorker` only after an `await` has no listener yet, and a
292
+ // message to a worker with no listener is lost.
293
+ const controls: MessagePort[] = [];
294
+ let retry: ReturnType<typeof setInterval> | undefined;
295
+ const answered = new Promise<{ control: MessagePort; ownerLock?: string }>(
296
+ (resolve, reject) => {
297
+ const timer = setTimeout(() => {
298
+ clearInterval(retry);
299
+ for (const port of controls) port.close();
300
+ reject(
301
+ new Error(
302
+ "The worker did not answer within the timeout. Does it call serveHttpInWorker()?",
303
+ ),
304
+ );
305
+ }, this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT);
306
+ const hello = () => {
307
+ const channel = new MessageChannel();
308
+ controls.push(channel.port1);
309
+ channel.port1.onmessage = (event) => {
310
+ const message = event.data as WorkerControlMessage;
311
+ if (message?.type !== "ready") return;
312
+ clearTimeout(timer);
313
+ clearInterval(retry);
314
+ for (const port of controls) if (port !== channel.port1) port.close();
315
+ resolve({ control: channel.port1, ownerLock: message.ownerLock });
316
+ };
317
+ worker.postMessage({ type: WORKER_HELLO }, [channel.port2]);
318
+ };
319
+ hello();
320
+ retry = setInterval(hello, 500);
321
+ },
322
+ );
323
+ const { control, ownerLock } = await answered;
324
+ const key = options.key ?? this.key;
325
+ const id = options.id ?? `worker:${crypto.randomUUID()}`;
326
+ // Attaching a slot replaces whoever held it: the ServiceWorker lets the old
327
+ // one finish its requests, and this page must not attach it again later.
328
+ for (const old of [...this._workers]) {
329
+ if (old.key === key && old.id === id) old.retire();
330
+ }
331
+ const link = new WorkerLinkImpl(this, control, {
332
+ key,
333
+ id,
334
+ owner: crypto.randomUUID(),
335
+ meta: options.meta,
336
+ ownerLock,
337
+ });
338
+ this._workers.add(link);
339
+ await link.attach();
340
+ return link;
341
+ }
342
+
343
+ /**
344
+ * Opens the transport of a socket to the handler `url` routes to: a handler
345
+ * of this page directly, any other through the ServiceWorker. Resolves once
346
+ * the handler accepted. An arrow function, so it can be handed around:
347
+ * `createWebSocketClass(adapter.connectWebSocket)`.
348
+ */
349
+ connectWebSocket = async (
350
+ url: string | URL,
351
+ protocols: string[] = [],
352
+ ): Promise<WebSocketConnection> => {
353
+ const httpUrl = new URL(`${url}`, globalThis.location?.href);
354
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
355
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
356
+ const local = this._findHandler(`${httpUrl}`);
357
+ if (local) {
358
+ // A handler in this very page needs no ServiceWorker.
359
+ const result = await serveWebSocketUpgrade(local, { url: `${httpUrl}`, protocols });
360
+ if (!result.ok) throw new Error(result.message);
361
+ return { port: result.port, protocol: result.protocol };
362
+ }
363
+ await this.start();
364
+ return await callChannelWithin<WebSocketConnection>(
365
+ this._callTimeout(),
366
+ this._worker(),
367
+ WS_CONNECT_CALL,
368
+ { url: `${httpUrl}`, protocols },
369
+ );
370
+ };
371
+
372
+ /**
373
+ * A WHATWG `WebSocket` class whose sockets reach this adapter's handlers:
374
+ * `new adapter.WebSocket(`${baseUrl}chat`, ["v1"])`. The global
375
+ * `WebSocket` is left alone.
376
+ */
377
+ get WebSocket(): PortWebSocketClass {
378
+ this._WebSocket ??= createWebSocketClass(this.connectWebSocket, this.options.webSocket);
379
+ return this._WebSocket;
380
+ }
381
+
382
+ /**
383
+ * Attaches again every link not made for the router holding
384
+ * `currentRouterLock` (every link, without it). The ServiceWorker asks for
385
+ * this after a restart; a page may call it too.
386
+ */
387
+ async relink(currentRouterLock?: string): Promise<void> {
388
+ if (!this._registrationPromise) return;
389
+ const stale = (routerLock?: string) =>
390
+ currentRouterLock === undefined || routerLock !== currentRouterLock;
391
+ const jobs: Promise<void>[] = [];
392
+ if (stale(this._routerLock)) jobs.push(this._attachPage());
393
+ for (const worker of this._workers) {
394
+ if (stale(worker.routerLock)) jobs.push(worker.attach());
395
+ }
396
+ await Promise.allSettled(jobs);
397
+ }
398
+
399
+ /** @internal Posts a plain message to the ServiceWorker, which starts it if stopped. */
400
+ _poke(type: typeof WAKE_MESSAGE | typeof KEEPALIVE_MESSAGE): void {
401
+ try {
402
+ this._worker().postMessage({ type });
403
+ } catch {}
404
+ }
405
+
406
+ /** @internal */
407
+ _worker(): ServiceWorker {
408
+ const worker =
409
+ navigator.serviceWorker.controller ?? this._serviceWorker ?? this._registration?.active;
410
+ if (!worker) throw new Error("The ServiceWorker is not running; call start() first");
411
+ return worker;
412
+ }
413
+
414
+ /** @internal */
415
+ _forget(link: WorkerLinkImpl): void {
416
+ this._workers.delete(link);
417
+ }
418
+
419
+ /** @internal How long a call to the ServiceWorker may take. */
420
+ _callTimeout(): number {
421
+ return this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT;
422
+ }
423
+
424
+ private _attachPage(): Promise<void> {
425
+ this._attaching ??= (async () => {
426
+ try {
427
+ const down = new MessageChannel();
428
+ const ctl = new MessageChannel();
429
+ const params: AttachParams = {
430
+ wire: HTTP_LINK_PROTOCOL,
431
+ key: this.key,
432
+ id: this._id,
433
+ owner: this._owner,
434
+ // Sent only when held for real: without Web Locks the ServiceWorker
435
+ // falls back to `clients.get()`, which an owner lock would skip.
436
+ ownerLock: this._releaseOwner && locksAvailable() ? this._ownerLock : undefined,
437
+ meta: this.options.meta,
438
+ ctl: true,
439
+ };
440
+ const reply = await callChannelWithin<AttachReply>(
441
+ this._callTimeout(),
442
+ this._worker(),
443
+ ATTACH_CALL,
444
+ params,
445
+ down.port1,
446
+ ctl.port1,
447
+ );
448
+ checkReply(reply, this._worker());
449
+ // The previous link is not dropped here: the endpoint lets it finish
450
+ // what it serves (see `HandlerEndpoint.link`).
451
+ await this._endpoint.link(this.key, { down: down.port2, ctl: ctl.port2 }, reply.routerLock);
452
+ this._routerLock = reply.routerLock;
453
+ } finally {
454
+ this._attaching = undefined;
455
+ }
456
+ })();
457
+ return this._attaching;
458
+ }
459
+
460
+ private _findHandler(url: string): HttpHandler | undefined {
461
+ for (const [urlPrefix, handler] of this._handlers) {
462
+ if (url.indexOf(urlPrefix) === 0) return handler;
463
+ }
464
+ return undefined;
465
+ }
466
+
467
+ private async _handleHttpRequest(request: Request): Promise<Response> {
468
+ const handler = this._findHandler(request.url);
469
+ if (handler) return await handler(request);
470
+ return new Response(null, { status: 404, statusText: "Error 404: Not found" });
471
+ }
472
+ }
473
+
474
+ class WorkerLinkImpl implements WorkerLink {
475
+ readonly key: string;
476
+ readonly id: string;
477
+ readonly owner: string;
478
+ routerLock?: string;
479
+ private readonly _meta: unknown;
480
+ private readonly _ownerLock?: string;
481
+ private _attaching?: Promise<void>;
482
+ private _detached = false;
483
+
484
+ constructor(
485
+ private readonly _adapter: SwHttpAdapter,
486
+ private readonly _control: MessagePort,
487
+ init: { key: string; id: string; owner: string; meta?: unknown; ownerLock?: string },
488
+ ) {
489
+ this.key = init.key;
490
+ this.id = init.id;
491
+ this.owner = init.owner;
492
+ this._meta = init.meta;
493
+ this._ownerLock = init.ownerLock;
494
+ _control.onmessage = (event) => {
495
+ const message = event.data as WorkerControlMessage;
496
+ if (message?.type === "relink") {
497
+ // Starts the ServiceWorker if it was stopped; the attach follows.
498
+ this._adapter._poke(WAKE_MESSAGE);
499
+ void this.attach().catch(() => {});
500
+ } else if (message?.type === "keepalive") this._adapter._poke(KEEPALIVE_MESSAGE);
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Forgets this link without telling the ServiceWorker: another attachment
506
+ * took its slot, and the ServiceWorker retires it by itself.
507
+ */
508
+ retire(): void {
509
+ this._detached = true;
510
+ this._adapter._forget(this);
511
+ this._control.onmessage = null;
512
+ }
513
+
514
+ /** Makes fresh channels and attaches them; the worker gets its ends. */
515
+ attach(): Promise<void> {
516
+ if (this._detached) return Promise.resolve();
517
+ this._attaching ??= (async () => {
518
+ try {
519
+ // A worker that was terminated holds no owner lock any more: attaching
520
+ // it again would route requests to it until the ServiceWorker noticed.
521
+ if (this._ownerLock) {
522
+ const held = await heldLockNames();
523
+ if (held && !held.has(this._ownerLock)) {
524
+ this.retire();
525
+ return;
526
+ }
527
+ }
528
+ const down = new MessageChannel();
529
+ const ctl = new MessageChannel();
530
+ const up = new MessageChannel();
531
+ const params: AttachParams = {
532
+ wire: HTTP_LINK_PROTOCOL,
533
+ key: this.key,
534
+ id: this.id,
535
+ owner: this.owner,
536
+ ownerLock: this._ownerLock,
537
+ meta: this._meta,
538
+ ctl: true,
539
+ up: true,
540
+ };
541
+ const sw = this._adapter._worker();
542
+ const reply = await callChannelWithin<AttachReply>(
543
+ this._adapter._callTimeout(),
544
+ sw,
545
+ ATTACH_CALL,
546
+ params,
547
+ down.port1,
548
+ ctl.port1,
549
+ up.port1,
550
+ );
551
+ checkReply(reply, sw);
552
+ this.routerLock = reply.routerLock;
553
+ this._control.postMessage(
554
+ {
555
+ type: "link",
556
+ key: this.key,
557
+ routerLock: reply.routerLock,
558
+ } satisfies WorkerControlMessage,
559
+ [down.port2, ctl.port2, up.port2],
560
+ );
561
+ } finally {
562
+ this._attaching = undefined;
563
+ }
564
+ })();
565
+ return this._attaching;
566
+ }
567
+
568
+ async drain({ ms }: { ms?: number } = {}): Promise<void> {
569
+ if (this._detached) return;
570
+ this._detached = true;
571
+ this._adapter._forget(this);
572
+ try {
573
+ await callChannel(this._adapter._worker(), DRAIN_CALL, {
574
+ key: this.key,
575
+ id: this.id,
576
+ owner: this.owner,
577
+ ms,
578
+ });
579
+ } finally {
580
+ this._unlink();
581
+ }
582
+ }
583
+
584
+ async detach(): Promise<void> {
585
+ if (this._detached) return;
586
+ this._detached = true;
587
+ this._adapter._forget(this);
588
+ await callChannel(this._adapter._worker(), DETACH_CALL, {
589
+ key: this.key,
590
+ id: this.id,
591
+ owner: this.owner,
592
+ }).catch(() => {});
593
+ this._unlink();
594
+ }
595
+
596
+ private _unlink(): void {
597
+ this._control.postMessage({ type: "unlink", key: this.key } satisfies WorkerControlMessage);
598
+ this._control.onmessage = null;
599
+ }
600
+ }
601
+
602
+ function checkReply(
603
+ reply: AttachReply | undefined,
604
+ sw: ServiceWorker,
605
+ ): asserts reply is AttachReply {
606
+ if (reply?.accepted === HTTP_LINK_PROTOCOL) return;
607
+ throw new ServiceWorkerControlError(
608
+ "incompatible",
609
+ `ServiceWorker "${sw.scriptURL}" answered the handshake but does not speak ` +
610
+ `${HTTP_LINK_PROTOCOL}: it runs an older version of @statewalker/webrun-http-browser. ` +
611
+ "Reload the page once the updated worker is active.",
612
+ );
613
+ }
@@ -0,0 +1,43 @@
1
+ import { callChannel } from "../core/data-calls.js";
2
+ import {
3
+ createWebSocketClass,
4
+ type PortWebSocketClass,
5
+ type WebSocketClassOptions,
6
+ type WebSocketConnection,
7
+ } from "../ws/client.js";
8
+ import { WS_CONNECT_CALL } from "./protocol.js";
9
+
10
+ /**
11
+ * Opens the transport of a socket through the ServiceWorker controlling this
12
+ * page, for a page that has no adapter of its own: content a handler serves
13
+ * under the same-origin dispatcher, or under a relay. The ServiceWorker
14
+ * routes `url` like a fetch, and the handler accepts or refuses the upgrade.
15
+ */
16
+ export async function connectWebSocketThroughServiceWorker(
17
+ url: string | URL,
18
+ protocols: string[] = [],
19
+ container: ServiceWorkerContainer = navigator.serviceWorker,
20
+ ): Promise<WebSocketConnection> {
21
+ const worker = container.controller;
22
+ if (!worker) throw new Error("This page is not controlled by a ServiceWorker");
23
+ const httpUrl = new URL(`${url}`, globalThis.location?.href);
24
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
25
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
26
+ return await callChannel<WebSocketConnection>(worker, WS_CONNECT_CALL, {
27
+ url: `${httpUrl}`,
28
+ protocols,
29
+ });
30
+ }
31
+
32
+ /**
33
+ * A WHATWG `WebSocket` class over `connectWebSocketThroughServiceWorker`.
34
+ * Install it where the page's code expects `WebSocket`, e.g.
35
+ * `globalThis.WebSocket = serviceWorkerWebSocketClass()` in content that only
36
+ * talks to its own handlers.
37
+ */
38
+ export function serviceWorkerWebSocketClass(options?: WebSocketClassOptions): PortWebSocketClass {
39
+ return createWebSocketClass(
40
+ (url, protocols) => connectWebSocketThroughServiceWorker(url, protocols),
41
+ options,
42
+ );
43
+ }