@statewalker/webrun-http-browser 0.6.4 → 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 (93) hide show
  1. package/README.md +319 -65
  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 +3907 -266
  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 +2 -1
  36. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  37. package/dist/sw/sw-http-adapter.d.ts +199 -0
  38. package/dist/sw/sw-http-adapter.d.ts.map +1 -0
  39. package/dist/sw/sw-websocket.d.ts +16 -0
  40. package/dist/sw/sw-websocket.d.ts.map +1 -0
  41. package/dist/sw/worker-service.d.ts +45 -0
  42. package/dist/sw/worker-service.d.ts.map +1 -0
  43. package/dist/sw/worker-url.d.ts +9 -0
  44. package/dist/sw/worker-url.d.ts.map +1 -0
  45. package/dist/sw-worker.js +2075 -210
  46. package/dist/sw.d.ts +2 -0
  47. package/dist/sw.d.ts.map +1 -1
  48. package/dist/sw.js +3853 -480
  49. package/dist/worker.d.ts +10 -0
  50. package/dist/worker.d.ts.map +1 -0
  51. package/dist/worker.js +3507 -0
  52. package/dist/ws/client.d.ts +73 -0
  53. package/dist/ws/client.d.ts.map +1 -0
  54. package/dist/ws/frames.d.ts +68 -0
  55. package/dist/ws/frames.d.ts.map +1 -0
  56. package/dist/ws/index.d.ts +6 -0
  57. package/dist/ws/index.d.ts.map +1 -0
  58. package/dist/ws/server.d.ts +53 -0
  59. package/dist/ws/server.d.ts.map +1 -0
  60. package/dist/ws/socket-core.d.ts +91 -0
  61. package/dist/ws/socket-core.d.ts.map +1 -0
  62. package/dist/ws/upgrade.d.ts +93 -0
  63. package/dist/ws/upgrade.d.ts.map +1 -0
  64. package/package.json +10 -5
  65. package/src/core/data-calls.ts +52 -2
  66. package/src/core/service-worker-control.ts +7 -1
  67. package/src/index.ts +3 -0
  68. package/src/link/errors.ts +3 -0
  69. package/src/link/http-link.ts +222 -0
  70. package/src/link/index.ts +3 -0
  71. package/src/link/locks.ts +97 -0
  72. package/src/link/rendezvous.ts +60 -0
  73. package/src/relay/index-sw.ts +84 -12
  74. package/src/relay/index.ts +205 -29
  75. package/src/sw/handler-endpoint.ts +278 -0
  76. package/src/sw/handler-table.ts +130 -0
  77. package/src/sw/http-sw-dispatcher.ts +649 -90
  78. package/src/sw/index.ts +5 -0
  79. package/src/sw/protocol.ts +103 -0
  80. package/src/sw/sw-dispatcher.ts +12 -30
  81. package/src/sw/sw-http-adapter.ts +613 -0
  82. package/src/sw/sw-websocket.ts +43 -0
  83. package/src/sw/worker-service.ts +133 -0
  84. package/src/sw/worker-url.ts +19 -0
  85. package/src/sw-worker.ts +8 -3
  86. package/src/sw.ts +2 -0
  87. package/src/worker.ts +9 -0
  88. package/src/ws/client.ts +346 -0
  89. package/src/ws/frames.ts +119 -0
  90. package/src/ws/index.ts +11 -0
  91. package/src/ws/server.ts +132 -0
  92. package/src/ws/socket-core.ts +251 -0
  93. package/src/ws/upgrade.ts +200 -0
@@ -0,0 +1,222 @@
1
+ import { fetchOverDuplex, serveFetchOverDuplex } from "@statewalker/webrun-http-streams";
2
+ import { connect, overPipe, serve, structuredCodec } from "@statewalker/webrun-rpc";
3
+
4
+ /**
5
+ * The version of the HTTP-over-`MessagePort` protocol this package speaks,
6
+ * sent in every hand-shake that sets up a link. Version 1 was this package's
7
+ * own `handleStreams` / `sendStream` (no backpressure); version 2 is
8
+ * webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
9
+ * `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
10
+ * multiplexed on it, each call a stream with a one-chunk window in both
11
+ * directions, and cancellation that reaches the handler.
12
+ */
13
+ export const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
14
+
15
+ export interface HttpLinkOptions {
16
+ /**
17
+ * Per-stream inactivity timeout in ms (webrun-rpc's): a stream with no chunk
18
+ * in either direction for that long fails. Unset — the default — means
19
+ * none: a slow consumer is throttled, never failed.
20
+ */
21
+ streamTimeout?: number;
22
+ }
23
+
24
+ /** The calling end of a link: sends requests, gets responses. */
25
+ export interface HttpLink {
26
+ /** Sends `request` and resolves with the response, whose body streams. */
27
+ send(request: Request): Promise<Response>;
28
+ /**
29
+ * Requests whose response has not finished yet: still waiting for the head,
30
+ * or with a body still streaming.
31
+ */
32
+ readonly inflight: number;
33
+ /** Resolves when `inflight` falls to 0. */
34
+ whenIdle(): Promise<void>;
35
+ /**
36
+ * Closes the link. Requests in flight fail with `reason` (default: the link
37
+ * was closed) instead of hanging.
38
+ */
39
+ close(reason?: Error): Promise<void>;
40
+ readonly closed: boolean;
41
+ }
42
+
43
+ /**
44
+ * Why a request failed when its link went away under it: the far side (the
45
+ * handler, or the ServiceWorker) was lost. The request may or may not have
46
+ * been applied.
47
+ */
48
+ export class LinkLostError extends Error {
49
+ constructor(message = "The link was lost while this request was in flight") {
50
+ super(`${message}; it may or may not have been applied.`);
51
+ this.name = "LinkLostError";
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Posted on a link's port by the calling end when it closes the link, so the
57
+ * answering end can release it: a `MessagePort` never reports that its other
58
+ * end is gone.
59
+ */
60
+ const LINK_CLOSE = "webrun-http:link-close";
61
+
62
+ /** Opens the calling end of a link on `port` (one port per link). */
63
+ export async function connectHttpLink(
64
+ port: MessagePort,
65
+ { streamTimeout }: HttpLinkOptions = {},
66
+ ): Promise<HttpLink> {
67
+ const { call, close } = await connect({
68
+ mux: overPipe(port, { codec: structuredCodec, side: "initiator" }),
69
+ timeout: streamTimeout,
70
+ });
71
+ return trackedLink(
72
+ (request) => fetchOverDuplex(call, request),
73
+ async () => {
74
+ try {
75
+ port.postMessage({ type: LINK_CLOSE });
76
+ } catch {}
77
+ await close();
78
+ },
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Serves `handler` on the answering end of a link. Resolves with a teardown
84
+ * that abandons the requests still running (their handlers see the
85
+ * cancellation) and releases the port.
86
+ */
87
+ export async function serveHttpLink(
88
+ port: MessagePort,
89
+ handler: (request: Request) => Response | Promise<Response>,
90
+ { streamTimeout }: HttpLinkOptions = {},
91
+ ): Promise<() => Promise<void>> {
92
+ const teardown = await serve(
93
+ {
94
+ mux: overPipe(port, { codec: structuredCodec, side: "responder" }),
95
+ timeout: streamTimeout,
96
+ },
97
+ serveFetchOverDuplex(async (request) => await handler(request)),
98
+ );
99
+ const onClose = (event: MessageEvent) => {
100
+ if ((event.data as { type?: unknown } | null)?.type === LINK_CLOSE) void close();
101
+ };
102
+ const close = async () => {
103
+ port.removeEventListener("message", onClose);
104
+ await teardown();
105
+ };
106
+ port.addEventListener("message", onClose);
107
+ return close;
108
+ }
109
+
110
+ /**
111
+ * Wraps a `send` so the link knows its requests in flight (for draining and
112
+ * load-based routing) and can fail them all at once when it is closed.
113
+ */
114
+ export function trackedLink(
115
+ send: (request: Request) => Promise<Response>,
116
+ release: () => Promise<void> | void,
117
+ ): HttpLink {
118
+ let inflight = 0;
119
+ let closed = false;
120
+ let lostReason: Error | undefined;
121
+ const failers = new Set<(error: Error) => void>();
122
+ const idleWaiters: Array<() => void> = [];
123
+
124
+ const done = () => {
125
+ inflight--;
126
+ if (inflight === 0) {
127
+ for (const resolve of idleWaiters.splice(0)) resolve();
128
+ }
129
+ };
130
+
131
+ const link: HttpLink = {
132
+ get inflight() {
133
+ return inflight;
134
+ },
135
+ get closed() {
136
+ return closed;
137
+ },
138
+ whenIdle() {
139
+ return inflight === 0 ? Promise.resolve() : new Promise((r) => idleWaiters.push(r));
140
+ },
141
+ async send(request) {
142
+ if (closed) throw lostReason ?? new LinkLostError("The link is closed");
143
+ inflight++;
144
+ let finished = false;
145
+ const finish = () => {
146
+ if (finished) return;
147
+ finished = true;
148
+ failers.delete(fail);
149
+ done();
150
+ };
151
+ let fail: (error: Error) => void = () => {};
152
+ const lost = new Promise<never>((_, reject) => {
153
+ fail = reject;
154
+ });
155
+ failers.add(fail);
156
+ lost.catch(() => {});
157
+ let response: Response;
158
+ try {
159
+ response = await Promise.race([send(request), lost]);
160
+ } catch (error) {
161
+ finish();
162
+ throw lostReason ?? error;
163
+ }
164
+ if (!response.body) {
165
+ finish();
166
+ return response;
167
+ }
168
+ return new Response(
169
+ guardBody(response.body, lost, () => lostReason, finish),
170
+ {
171
+ status: response.status,
172
+ statusText: response.statusText,
173
+ headers: response.headers,
174
+ },
175
+ );
176
+ },
177
+ async close(reason) {
178
+ if (closed) return;
179
+ closed = true;
180
+ lostReason = reason ?? new LinkLostError("The link was closed");
181
+ for (const fail of [...failers]) fail(lostReason);
182
+ try {
183
+ await release();
184
+ } catch {}
185
+ },
186
+ };
187
+ return link;
188
+ }
189
+
190
+ /**
191
+ * A response body that reports its end (done, cancelled or failed) and fails
192
+ * with the link's reason when the link is lost mid-stream.
193
+ */
194
+ function guardBody(
195
+ body: ReadableStream<Uint8Array>,
196
+ lost: Promise<never>,
197
+ reason: () => Error | undefined,
198
+ finish: () => void,
199
+ ): ReadableStream<Uint8Array> {
200
+ const reader = body.getReader();
201
+ return new ReadableStream<Uint8Array>({
202
+ async pull(controller) {
203
+ try {
204
+ const { done, value } = await Promise.race([reader.read(), lost]);
205
+ if (done) {
206
+ finish();
207
+ controller.close();
208
+ return;
209
+ }
210
+ controller.enqueue(value);
211
+ } catch (error) {
212
+ finish();
213
+ void reader.cancel().catch(() => {});
214
+ controller.error(reason() ?? error);
215
+ }
216
+ },
217
+ async cancel(why) {
218
+ finish();
219
+ await reader.cancel(why).catch(() => {});
220
+ },
221
+ });
222
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./http-link.js";
2
+ export { holdLock, locksAvailable, newLockName, watchLock } from "./locks.js";
3
+ export { PortRendezvous } from "./rendezvous.js";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Liveness through Web Locks.
3
+ *
4
+ * A `MessagePort` never reports that the other side died, but a Web Lock
5
+ * does: a lock held by a page, a worker or a ServiceWorker is released by the
6
+ * browser the moment that context goes away, and a request waiting for it is
7
+ * granted then. So each side that must notice the other's death holds a lock
8
+ * of its own and watches the other's:
9
+ *
10
+ * - a handler holds an owner lock; the ServiceWorker watches it, and fails the
11
+ * handler's requests in flight when it is granted (the tab closed, the
12
+ * worker was terminated);
13
+ * - the ServiceWorker holds a router lock; handlers watch it, and fail their
14
+ * calls through it when it is granted (the browser stopped the worker).
15
+ *
16
+ * Every function here degrades to a no-op where `navigator.locks` is missing.
17
+ */
18
+
19
+ interface LockManagerLike {
20
+ query?(): Promise<{ held?: Array<{ name?: string }> }>;
21
+ request(
22
+ name: string,
23
+ options: { signal?: AbortSignal; mode?: "exclusive" | "shared" },
24
+ callback: () => unknown,
25
+ ): Promise<unknown>;
26
+ }
27
+
28
+ function lockManager(): LockManagerLike | undefined {
29
+ return (globalThis.navigator as { locks?: LockManagerLike } | undefined)?.locks;
30
+ }
31
+
32
+ export function locksAvailable(): boolean {
33
+ return lockManager() !== undefined;
34
+ }
35
+
36
+ /** A fresh lock name: `<prefix>:<uuid>`. */
37
+ export function newLockName(prefix: string): string {
38
+ return `${prefix}:${crypto.randomUUID()}`;
39
+ }
40
+
41
+ /**
42
+ * Takes the lock `name` and holds it until the returned release is called or
43
+ * this context dies. Resolves once the lock is held; with no Web Locks,
44
+ * resolves at once.
45
+ */
46
+ export async function holdLock(name: string): Promise<() => void> {
47
+ const locks = lockManager();
48
+ if (!locks) return () => {};
49
+ let release: () => void = () => {};
50
+ const released = new Promise<void>((resolve) => {
51
+ release = resolve;
52
+ });
53
+ await new Promise<void>((held) => {
54
+ void locks
55
+ .request(name, {}, () => {
56
+ held();
57
+ return released;
58
+ })
59
+ .catch(() => held());
60
+ });
61
+ return release;
62
+ }
63
+
64
+ /**
65
+ * Calls `onLost` once the lock `name` is granted to this context — that is,
66
+ * once whoever holds it is gone — and releases it at once. Returns a function
67
+ * that stops watching. With no Web Locks, never calls `onLost`.
68
+ */
69
+ export function watchLock(name: string, onLost: () => void): () => void {
70
+ const locks = lockManager();
71
+ if (!locks) return () => {};
72
+ const controller = new AbortController();
73
+ void locks
74
+ .request(name, { signal: controller.signal }, () => {
75
+ if (!controller.signal.aborted) onLost();
76
+ })
77
+ .catch(() => {
78
+ // Aborted by the returned function: not a loss.
79
+ });
80
+ return () => controller.abort();
81
+ }
82
+
83
+ /**
84
+ * The names of the locks held right now in this origin, or `undefined` where
85
+ * that cannot be asked. A backup for `watchLock`: a grant can be late, a query
86
+ * cannot.
87
+ */
88
+ export async function heldLockNames(): Promise<Set<string> | undefined> {
89
+ const locks = lockManager();
90
+ if (!locks?.query) return undefined;
91
+ try {
92
+ const { held = [] } = await locks.query();
93
+ return new Set(held.map((lock) => lock.name ?? ""));
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Matches replies to requests by id, for the control messages that hand
3
+ * WebSocket ports across: each waiting request has a deadline, and all of a
4
+ * peer's waiting requests can be failed at once when the peer is lost.
5
+ */
6
+ export class PortRendezvous<T> {
7
+ private readonly _waiting = new Map<
8
+ string,
9
+ {
10
+ resolve: (value: T) => void;
11
+ reject: (error: Error) => void;
12
+ timer: ReturnType<typeof setTimeout>;
13
+ }
14
+ >();
15
+ private _counter = 0;
16
+
17
+ constructor(private readonly _prefix = "r") {}
18
+
19
+ /** A fresh id for a request. */
20
+ newId(): string {
21
+ return `${this._prefix}:${++this._counter}`;
22
+ }
23
+
24
+ get size(): number {
25
+ return this._waiting.size;
26
+ }
27
+
28
+ /** Waits for the reply to `id`, failing after `timeoutMs`. */
29
+ wait(id: string, timeoutMs: number, describe = "the reply"): Promise<T> {
30
+ return new Promise<T>((resolve, reject) => {
31
+ const timer = setTimeout(() => {
32
+ this._waiting.delete(id);
33
+ reject(new Error(`No answer to ${describe} within ${timeoutMs} ms`));
34
+ }, timeoutMs);
35
+ this._waiting.set(id, { resolve, reject, timer });
36
+ });
37
+ }
38
+
39
+ /** Delivers the reply to `id`. `false` when nobody waits for it (it timed out). */
40
+ settle(id: string, value: T): boolean {
41
+ const waiting = this._waiting.get(id);
42
+ if (!waiting) return false;
43
+ this._waiting.delete(id);
44
+ clearTimeout(waiting.timer);
45
+ waiting.resolve(value);
46
+ return true;
47
+ }
48
+
49
+ fail(id: string, error: Error): void {
50
+ const waiting = this._waiting.get(id);
51
+ if (!waiting) return;
52
+ this._waiting.delete(id);
53
+ clearTimeout(waiting.timer);
54
+ waiting.reject(error);
55
+ }
56
+
57
+ failAll(error: Error): void {
58
+ for (const id of [...this._waiting.keys()]) this.fail(id, error);
59
+ }
60
+ }
@@ -1,9 +1,12 @@
1
1
  import { HttpError } from "@statewalker/webrun-http-streams";
2
2
  import { get, set } from "idb-keyval";
3
- import { callChannel, handleChannelCalls } from "../core/data-calls.js";
3
+ import { callChannel, handleChannelCalls, withTransfer } from "../core/data-calls.js";
4
4
  import { newRegistry } from "../core/registry.js";
5
5
  import { handleClaimRequests } from "../core/service-worker-control.js";
6
- import { sendHttpRequest } from "../http/http-send-recieve.js";
6
+ import { HTTP_LINK_PROTOCOL } from "../link/http-link.js";
7
+ import { WS_CONNECT_CALL } from "../sw/protocol.js";
8
+ import type { WebSocketConnection } from "../ws/client.js";
9
+ import { type ConnectReply, sendOverConnection, type WebSocketConnectParams } from "./index.js";
7
10
  import { type MountSpec, type MountTable, newMountTable } from "./mount-table.js";
8
11
  import { splitServiceUrl } from "./split-service-url.js";
9
12
 
@@ -125,8 +128,18 @@ export interface RelayServiceWorkerOptions {
125
128
  mounts?: Array<{ key: string } & MountSpec>;
126
129
  /** Paths the relay never claims. Checked before the table. */
127
130
  exclude?: (url: URL) => boolean;
128
- /** Refuse a registration from the wrong client. Default: everyone may. */
129
- canRegister?: (client: Client, key: string) => boolean | Promise<boolean>;
131
+ /**
132
+ * Refuse a registration from the wrong client. Default: everyone may.
133
+ *
134
+ * `path` is where the registrant asks to be mounted (`undefined` for the
135
+ * `/~<key>/` addressing), so a host can vet where a key lands, not only who
136
+ * takes it: on an origin whose name is not a secret, a page could otherwise
137
+ * mount an allowed key at `/index.html` and outrank `/` on longest-prefix.
138
+ * Also consulted for `UNREGISTER`, with the path the key is mounted at.
139
+ */
140
+ canRegister?: (client: Client, key: string, path?: string) => boolean | Promise<boolean>;
141
+ /** Per-stream inactivity timeout of the links to services, in ms. Default none. */
142
+ streamTimeout?: number;
130
143
  /** Default `"last-wins"`, the behaviour before this option existed. */
131
144
  takeover?: "first-wins" | "last-wins";
132
145
  /** Stamp headers on responses the relay makes. Not applied to network fetches. */
@@ -220,8 +233,12 @@ export function startRelayServiceWorker(
220
233
  if (!source) return false;
221
234
  const { key, path } = data as { key: string; path?: string };
222
235
 
223
- if (options.canRegister != null && !(await options.canRegister(source, key))) {
224
- throw new Error(`this client may not register "${key}"`);
236
+ if (options.canRegister != null && !(await options.canRegister(source, key, path))) {
237
+ throw new Error(
238
+ path == null
239
+ ? `this client may not register "${key}"`
240
+ : `this client may not register "${key}" at "${path}"`,
241
+ );
225
242
  }
226
243
 
227
244
  // `last-wins` -- the untouched default -- must cost exactly what it did
@@ -242,8 +259,22 @@ export function startRelayServiceWorker(
242
259
  }),
243
260
  );
244
261
  register(
245
- handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
262
+ handleChannelCalls(self, "UNREGISTER", async (event, data) => {
263
+ // Only the client that holds a key may drop it -- or anyone the host
264
+ // allows, once the holder is gone. Any page of the relay origin can post
265
+ // here, including foreign content the host serves there.
266
+ const source = event.source as Client | null;
267
+ if (!source) return false;
246
268
  const { key } = data as { key: string };
269
+ const current = await clientsRegistry.getMount(key);
270
+ if (current == null) return false;
271
+ const isCurrentLive = (await clientsRegistry.getClient(key)) != null;
272
+ if (isCurrentLive && current.clientId !== source.id) {
273
+ throw new Error(`"${key}" is served by another client`);
274
+ }
275
+ if (options.canRegister != null && !(await options.canRegister(source, key, current.path))) {
276
+ throw new Error(`this client may not unregister "${key}"`);
277
+ }
247
278
  removeRegisteredMount(mounts, key, hostKeys);
248
279
  return await clientsRegistry.removeClient(key);
249
280
  }),
@@ -253,7 +284,41 @@ export function startRelayServiceWorker(
253
284
  const { key } = data as { key: string };
254
285
  const client = await clientsRegistry.getClient(key);
255
286
  if (!client) throw new Error(`Target client was not found. Target key: "${key}".`);
256
- return await callChannel<boolean>(client, "CONNECT", data, port);
287
+ const reply = await callChannel<unknown>(client, "CONNECT", data, port);
288
+ // A socket's port comes back in the reply: pass it on.
289
+ const socketPort = (reply as Partial<WebSocketConnection> | null)?.port;
290
+ return socketPort ? withTransfer(reply, [socketPort]) : reply;
291
+ }),
292
+ );
293
+ register(
294
+ handleChannelCalls(self, WS_CONNECT_CALL, async (event, data) => {
295
+ // A page of the relay origin -- content a service serves there -- opens
296
+ // a socket to the service its url routes to, as it would fetch it.
297
+ const source = event.source as Client | null;
298
+ if (!source?.url || new URL(source.url).origin !== self.location.origin) {
299
+ throw new Error("Only this origin's pages may open sockets");
300
+ }
301
+ const { url, protocols = [] } = data as { url: string; protocols?: string[] };
302
+ const httpUrl = new URL(url);
303
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
304
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
305
+ await restored.catch(() => {});
306
+ const key = resolveServiceKey(httpUrl, mounts, self.location.origin);
307
+ if (key == null) throw new Error(`No service serves ${url}`);
308
+ const client = await clientsRegistry.getClient(key);
309
+ if (!client) throw new Error(`No service serves ${url}`);
310
+ const channel = new MessageChannel();
311
+ try {
312
+ const connection = await callChannel<WebSocketConnection>(
313
+ client,
314
+ "CONNECT",
315
+ { type: "ws", key, url: `${httpUrl}`, protocols } satisfies WebSocketConnectParams,
316
+ channel.port2,
317
+ );
318
+ return withTransfer(connection, [connection.port]);
319
+ } finally {
320
+ channel.port1.close();
321
+ }
257
322
  }),
258
323
  );
259
324
 
@@ -264,10 +329,17 @@ export function startRelayServiceWorker(
264
329
  const channel = new MessageChannel();
265
330
  const client = await clientsRegistry.getClient(key);
266
331
  if (!client) throw HttpError.errorResourceGone(params);
267
- const data = { type: "http", key };
268
- const accepted = await callChannel<boolean>(client, "CONNECT", data, channel.port2);
269
- if (!accepted) throw HttpError.errorForbidden(params);
270
- const response = await sendHttpRequest(channel.port1, request);
332
+ const data = { type: "http", key, wire: HTTP_LINK_PROTOCOL };
333
+ const reply = await callChannel<boolean | ConnectReply>(
334
+ client,
335
+ "CONNECT",
336
+ data,
337
+ channel.port2,
338
+ );
339
+ if (!reply) throw HttpError.errorForbidden(params);
340
+ const response = await sendOverConnection(channel.port1, reply, request, {
341
+ streamTimeout: options.streamTimeout,
342
+ });
271
343
  return options.decorateResponse?.(response, request) ?? response;
272
344
  } catch (error) {
273
345
  const httpError = HttpError.fromError(error);