@statewalker/webrun-http-browser 0.5.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-browser",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ServiceWorker-based HTTP server for browsers, with relay and same-origin dispatch modes",
@@ -29,6 +29,10 @@
29
29
  "./relay-sw": {
30
30
  "default": "./dist/relay-sw.js"
31
31
  },
32
+ "./relay-worker": {
33
+ "types": "./dist/relay-worker.d.ts",
34
+ "import": "./dist/relay-worker.js"
35
+ },
32
36
  "./sw-worker": {
33
37
  "default": "./dist/sw-worker.js"
34
38
  }
@@ -43,8 +47,8 @@
43
47
  "dependencies": {
44
48
  "idb-keyval": "^6.3.0",
45
49
  "@statewalker/webrun-http-streams": "0.2.2",
46
- "@statewalker/webrun-streams": "0.2.0",
47
- "@statewalker/webrun-rpc": "0.4.0"
50
+ "@statewalker/webrun-rpc": "0.4.0",
51
+ "@statewalker/webrun-streams": "0.2.0"
48
52
  },
49
53
  "devDependencies": {
50
54
  "@biomejs/biome": "^2.5.8",
@@ -4,15 +4,173 @@ import { callChannel, handleChannelCalls } from "../core/data-calls.js";
4
4
  import { newRegistry } from "../core/registry.js";
5
5
  import { handleClaimRequests } from "../core/service-worker-control.js";
6
6
  import { sendHttpRequest } from "../http/http-send-recieve.js";
7
+ import { type MountSpec, type MountTable, newMountTable } from "./mount-table.js";
7
8
  import { splitServiceUrl } from "./split-service-url.js";
8
9
 
10
+ /** What the registry keeps per service key. */
11
+ export interface RegisteredClient {
12
+ clientId: string;
13
+ /** Where the service is mounted. Absent means `/~<key>/`, as before mounts. */
14
+ path?: string;
15
+ }
16
+
17
+ /**
18
+ * One stored registry entry, whatever shape it is on disk.
19
+ *
20
+ * BEFORE MOUNTS THE VALUE WAS A BARE CLIENT ID. A browser that ran the earlier
21
+ * worker still holds that shape, and reading it as an object would drop the id
22
+ * and quietly unregister every service the visitor had.
23
+ */
24
+ export function readStoredEntry(value: unknown): RegisteredClient | undefined {
25
+ if (typeof value === "string") return { clientId: value };
26
+ if (typeof value !== "object" || value === null) return undefined;
27
+ const { clientId, path } = value as { clientId?: unknown; path?: unknown };
28
+ if (typeof clientId !== "string" || clientId === "") return undefined;
29
+ return typeof path === "string" ? { clientId, path } : { clientId };
30
+ }
31
+
32
+ /**
33
+ * Which service, if any, should answer `url`.
34
+ *
35
+ * `undefined` means NOT THE RELAY'S, and the caller must not call
36
+ * `respondWith`: the request then goes to the network, which is how a host
37
+ * keeps serving its own files from its own origin. Answering 404 here instead
38
+ * would make a root mount fatal.
39
+ */
40
+ export function resolveServiceKey(
41
+ url: URL,
42
+ table: MountTable,
43
+ selfOrigin: string,
44
+ ): string | undefined {
45
+ // Another origin's resource is the network's business, as in any page.
46
+ if (url.origin !== selfOrigin) return undefined;
47
+ // Checked before EITHER route: an excluded path is never claimed, and the
48
+ // `/~<key>/` fallback below does not consult the table.
49
+ if (table.excludes(url)) return undefined;
50
+ const mounted = table.find(url);
51
+ if (mounted != null) return mounted;
52
+ const { key } = splitServiceUrl(url);
53
+ return key === "" ? undefined : key;
54
+ }
55
+
56
+ /**
57
+ * Waits for the mount table to be restored from the registry before routing
58
+ * `url` — but a restore failure must never wedge every fetch. `restored`
59
+ * rejecting (blocked storage, quota, private-mode edge cases) would otherwise
60
+ * propagate straight to `respondWith` on every request, including ones that
61
+ * should reach the network, which breaks the one rule this file exists to
62
+ * uphold. So: log the failure and route with whatever the in-memory table
63
+ * already holds — possibly empty, never fatal.
64
+ */
65
+ export async function resolveAfterRestore(
66
+ restored: Promise<void>,
67
+ url: URL,
68
+ table: MountTable,
69
+ selfOrigin: string,
70
+ ): Promise<string | undefined> {
71
+ try {
72
+ await restored;
73
+ } catch (error) {
74
+ console.error("[relay] failed to restore mounts from the registry", error);
75
+ }
76
+ return resolveServiceKey(url, table, selfOrigin);
77
+ }
78
+
79
+ /**
80
+ * What REGISTER does to the mount table: set it when `path` is given, or
81
+ * remove any earlier mount when it is not. A path-less re-registration
82
+ * reverts a service to `/~<key>/` addressing, and a stale prefix left behind
83
+ * would keep routing requests to a mount that no longer exists.
84
+ *
85
+ * A REGISTRATION ONLY TOUCHES WHAT A REGISTRATION MADE. `hostKeys` names the
86
+ * mounts the host declared itself, in `options.mounts`. Those are the host's
87
+ * build-time decision and a page may not undo it: the documented static flow
88
+ * -- host declares `{ key: "app", path: "/" }`, page calls `initHttpService(h,
89
+ * { key: "app", port })` with no path -- would otherwise have its very first
90
+ * registration delete the host's own mount and leave the origin unmounted. A
91
+ * path-ful REGISTER naming a host key is ignored for the same reason (it would
92
+ * replace a `match` predicate with a prefix of the page's choosing).
93
+ */
94
+ export function applyRegisteredMount(
95
+ table: MountTable,
96
+ key: string,
97
+ path: string | undefined,
98
+ hostKeys: ReadonlySet<string> = new Set(),
99
+ ): void {
100
+ if (hostKeys.has(key)) return;
101
+ if (path != null) {
102
+ table.set(key, { path });
103
+ } else {
104
+ table.remove(key);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * What UNREGISTER does to the mount table: drop the mount a registration
110
+ * made. A host-declared mount stays, for the reason `applyRegisteredMount`
111
+ * gives -- a page tearing down its service must not take the host's table
112
+ * with it.
113
+ */
114
+ export function removeRegisteredMount(
115
+ table: MountTable,
116
+ key: string,
117
+ hostKeys: ReadonlySet<string> = new Set(),
118
+ ): void {
119
+ if (hostKeys.has(key)) return;
120
+ table.remove(key);
121
+ }
122
+
123
+ export interface RelayServiceWorkerOptions {
124
+ /** A fixed table, for a host that knows its services at build time. */
125
+ mounts?: Array<{ key: string } & MountSpec>;
126
+ /** Paths the relay never claims. Checked before the table. */
127
+ exclude?: (url: URL) => boolean;
128
+ /** Refuse a registration from the wrong client. Default: everyone may. */
129
+ canRegister?: (client: Client, key: string) => boolean | Promise<boolean>;
130
+ /** Default `"last-wins"`, the behaviour before this option existed. */
131
+ takeover?: "first-wins" | "last-wins";
132
+ /** Stamp headers on responses the relay makes. Not applied to network fetches. */
133
+ decorateResponse?: (response: Response, request: Request) => Response;
134
+ }
135
+
136
+ /**
137
+ * May `candidateId` take the key?
138
+ *
139
+ * `last-wins` is what the relay has always done and stays the default. With
140
+ * `first-wins`, a LIVE holder keeps its key: on an origin whose name is
141
+ * guessable, a second page proves nothing by existing. A holder that reloaded
142
+ * is no longer live, so a host's own re-registration is never blocked.
143
+ */
144
+ export function mayRegister(args: {
145
+ current?: RegisteredClient;
146
+ candidateId: string;
147
+ isCurrentLive: boolean;
148
+ takeover: "first-wins" | "last-wins";
149
+ }): boolean {
150
+ if (args.takeover === "last-wins") return true;
151
+ if (args.current == null || !args.isCurrentLive) return true;
152
+ return args.current.clientId === args.candidateId;
153
+ }
154
+
9
155
  /**
10
156
  * Boots the relay ServiceWorker: routes fetches shaped `<origin>/~<key>/…` to
11
157
  * the client that registered `key`, and exposes REGISTER/UNREGISTER/CONNECT
12
158
  * channel calls used by the page-side relay client.
13
159
  */
14
- export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => void {
160
+ export function startRelayServiceWorker(
161
+ self: ServiceWorkerGlobalScope,
162
+ options: RelayServiceWorkerOptions = {},
163
+ ): () => void {
15
164
  const [register, clear] = newRegistry();
165
+ const mounts = newMountTable({ exclude: options.exclude });
166
+ // The keys the HOST declared. Kept so a page's REGISTER/UNREGISTER cannot
167
+ // replace or delete a mount it never created -- see `applyRegisteredMount`.
168
+ const hostKeys = new Set<string>();
169
+ for (const { key, ...spec } of options.mounts ?? []) {
170
+ mounts.set(key, spec);
171
+ hostKeys.add(key);
172
+ }
173
+ const takeover = options.takeover ?? "last-wins";
16
174
 
17
175
  if (typeof self.skipWaiting === "function") {
18
176
  self.addEventListener("install", (e: ExtendableEvent) => {
@@ -28,6 +186,30 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
28
186
 
29
187
  const clientsRegistry = newClientsRegistry({ self });
30
188
 
189
+ // A RESTARTED WORKER HAS AN EMPTY TABLE AND A FULL DATABASE. The registry
190
+ // survives in IndexedDB; the mounts are in memory, so they must be read back
191
+ // or the first fetch after a restart finds nothing mounted.
192
+ const restored = clientsRegistry.restoreMounts(mounts, hostKeys);
193
+
194
+ // ONCE THE RESTORE HAS SETTLED THE TABLE CAN BE READ SYNCHRONOUSLY, and the
195
+ // fetch listener needs that: `respondWith` must be called synchronously, so
196
+ // a listener that can only decide after an `await` has to call it for EVERY
197
+ // request and re-issue the ones that are nobody's. That is not the same as
198
+ // leaving them alone -- it defeats navigation preload and makes a request
199
+ // with `cache: "only-if-cached"` throw. This latch keeps the async path for
200
+ // the handful of requests that arrive before the restore settles, and only
201
+ // those.
202
+ //
203
+ // The handler is attached here, at creation, rather than at the first fetch:
204
+ // a rejection with nothing listening surfaces as an `unhandledrejection` in
205
+ // the worker. `resolveAfterRestore` still does its own logging for the
206
+ // requests that await `restored` directly.
207
+ let restoreSettled = false;
208
+ const latch = () => {
209
+ restoreSettled = true;
210
+ };
211
+ restored.then(latch, latch);
212
+
31
213
  // Pages bridge to `registration.active` when uncontrolled, so they do not
32
214
  // need this; it is here so any page of this origin can ask for control.
33
215
  register(handleClaimRequests(self));
@@ -36,13 +218,33 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
36
218
  handleChannelCalls(self, "REGISTER", async (event, data) => {
37
219
  const source = event.source as Client | null;
38
220
  if (!source) return false;
39
- const { key } = data as { key: string };
40
- return await clientsRegistry.addClient(key, source);
221
+ const { key, path } = data as { key: string; path?: string };
222
+
223
+ if (options.canRegister != null && !(await options.canRegister(source, key))) {
224
+ throw new Error(`this client may not register "${key}"`);
225
+ }
226
+
227
+ // `last-wins` -- the untouched default -- must cost exactly what it did
228
+ // before this option existed: no registry lookup beyond `addClient`'s
229
+ // own. Only `first-wins` needs to know who currently holds the key and
230
+ // whether they are still live, so only it pays for finding out.
231
+ if (takeover === "first-wins") {
232
+ const current = await clientsRegistry.getMount(key);
233
+ const isCurrentLive = current != null && (await clientsRegistry.getClient(key)) != null;
234
+ if (!mayRegister({ current, candidateId: source.id, isCurrentLive, takeover })) {
235
+ throw new Error(`"${key}" is already served by another client`);
236
+ }
237
+ }
238
+
239
+ const added = await clientsRegistry.addClient(key, source, path);
240
+ applyRegisteredMount(mounts, key, path, hostKeys);
241
+ return added;
41
242
  }),
42
243
  );
43
244
  register(
44
245
  handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
45
246
  const { key } = data as { key: string };
247
+ removeRegisteredMount(mounts, key, hostKeys);
46
248
  return await clientsRegistry.removeClient(key);
47
249
  }),
48
250
  );
@@ -55,31 +257,54 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
55
257
  }),
56
258
  );
57
259
 
260
+ /** Answers `request` as the service registered under `key`. */
261
+ async function serve(key: string, request: Request, url: URL): Promise<Response> {
262
+ const params = splitServiceUrl(url);
263
+ try {
264
+ const channel = new MessageChannel();
265
+ const client = await clientsRegistry.getClient(key);
266
+ 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);
271
+ return options.decorateResponse?.(response, request) ?? response;
272
+ } catch (error) {
273
+ const httpError = HttpError.fromError(error);
274
+ const errorOptions = httpError.getResponseOptions(params);
275
+ const errorResponse = new Response(JSON.stringify(errorOptions), {
276
+ status: httpError.status ?? 500,
277
+ statusText: httpError.statusText ?? "Internal Error",
278
+ headers: { "Content-Type": "application/json" },
279
+ });
280
+ return options.decorateResponse?.(errorResponse, request) ?? errorResponse;
281
+ }
282
+ }
283
+
58
284
  const fetchListener = (event: FetchEvent) => {
59
285
  const request = event.request;
60
- const params = splitServiceUrl(request.url);
61
- const { key } = params;
62
- if (!key) return;
286
+ const url = new URL(request.url);
63
287
 
288
+ // THE ORDINARY CASE: decide synchronously and, when the request is
289
+ // nobody's, RETURN WITHOUT `respondWith` -- the browser then performs it
290
+ // exactly as it would with no worker installed.
291
+ if (restoreSettled) {
292
+ const key = resolveServiceKey(url, mounts, self.location.origin);
293
+ if (key == null) return;
294
+ event.respondWith(serve(key, request, url));
295
+ return;
296
+ }
297
+
298
+ // Before the restore settles there is nothing to decide on synchronously,
299
+ // and `respondWith` cannot be called after an await. Such a request is
300
+ // claimed and, if it turns out to be nobody's, re-issued -- the one
301
+ // window in which the worker still stands in for the network, and it
302
+ // lasts only until the registry read completes.
64
303
  event.respondWith(
65
304
  (async (): Promise<Response> => {
66
- try {
67
- const channel = new MessageChannel();
68
- const client = await clientsRegistry.getClient(key);
69
- if (!client) throw HttpError.errorResourceGone(params);
70
- const data = { type: "http", key };
71
- const accepted = await callChannel<boolean>(client, "CONNECT", data, channel.port2);
72
- if (!accepted) throw HttpError.errorForbidden(params);
73
- return await sendHttpRequest(channel.port1, request);
74
- } catch (error) {
75
- const httpError = HttpError.fromError(error);
76
- const options = httpError.getResponseOptions(params);
77
- return new Response(JSON.stringify(options), {
78
- status: httpError.status ?? 500,
79
- statusText: httpError.statusText ?? "Internal Error",
80
- headers: { "Content-Type": "application/json" },
81
- });
82
- }
305
+ const key = await resolveAfterRestore(restored, url, mounts, self.location.origin);
306
+ if (key == null) return await fetch(request);
307
+ return await serve(key, request, url);
83
308
  })(),
84
309
  );
85
310
  };
@@ -95,33 +320,41 @@ interface ClientsRegistryOptions {
95
320
  }
96
321
 
97
322
  interface ClientsRegistry {
98
- addClient(clientKey: string, client: Client): Promise<boolean>;
323
+ addClient(clientKey: string, client: Client, path?: string): Promise<boolean>;
99
324
  removeClient(clientKey: string): Promise<boolean>;
100
325
  getClient(clientKey: string): Promise<Client | undefined>;
326
+ getMount(clientKey: string): Promise<RegisteredClient | undefined>;
327
+ restoreMounts(table: MountTable, hostKeys?: ReadonlySet<string>): Promise<void>;
101
328
  }
102
329
 
103
330
  function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions): ClientsRegistry {
104
- let _index: Record<string, string> | undefined;
331
+ let _index: Record<string, RegisteredClient> | undefined;
105
332
 
106
- async function loadClientsIndex(): Promise<Record<string, string>> {
333
+ async function loadClientsIndex(): Promise<Record<string, RegisteredClient>> {
107
334
  if (!_index) {
108
- const entries = ((await get<Array<[string, string]>>(key)) ?? []) as Array<[string, string]>;
109
- _index = Object.fromEntries(entries);
335
+ const entries = ((await get<Array<[string, unknown]>>(key)) ?? []) as Array<
336
+ [string, unknown]
337
+ >;
338
+ _index = {};
339
+ for (const [clientKey, value] of entries) {
340
+ const entry = readStoredEntry(value);
341
+ if (entry) _index[clientKey] = entry;
342
+ }
110
343
  }
111
344
  return _index;
112
345
  }
113
346
 
114
- async function storeClientsIndex(): Promise<Record<string, string>> {
347
+ async function storeClientsIndex(): Promise<Record<string, RegisteredClient>> {
115
348
  const index = await loadClientsIndex();
116
349
  await set(key, Object.entries(index));
117
350
  return index;
118
351
  }
119
352
 
120
- async function addClient(clientKey: string, client: Client): Promise<boolean> {
353
+ async function addClient(clientKey: string, client: Client, path?: string): Promise<boolean> {
121
354
  const index = await loadClientsIndex();
122
- const clientId = client.id;
123
- if (index[clientKey] === clientId) return false;
124
- index[clientKey] = clientId;
355
+ const current = index[clientKey];
356
+ if (current?.clientId === client.id && current.path === path) return false;
357
+ index[clientKey] = path == null ? { clientId: client.id } : { clientId: client.id, path };
125
358
  await storeClientsIndex();
126
359
  return true;
127
360
  }
@@ -134,11 +367,29 @@ function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions
134
367
  return true;
135
368
  }
136
369
 
370
+ async function getMount(clientKey: string): Promise<RegisteredClient | undefined> {
371
+ const index = await loadClientsIndex();
372
+ return index[clientKey];
373
+ }
374
+
375
+ async function restoreMounts(
376
+ table: MountTable,
377
+ hostKeys: ReadonlySet<string> = new Set(),
378
+ ): Promise<void> {
379
+ const index = await loadClientsIndex();
380
+ for (const [clientKey, entry] of Object.entries(index)) {
381
+ // A host-declared mount outranks a persisted registration of the same
382
+ // key, exactly as it does on a live REGISTER.
383
+ if (hostKeys.has(clientKey)) continue;
384
+ if (entry.path != null) table.set(clientKey, { path: entry.path });
385
+ }
386
+ }
387
+
137
388
  async function getClient(clientKey: string): Promise<Client | undefined> {
138
389
  const index = await loadClientsIndex();
139
- const clientId = index[clientKey];
140
- if (!clientId) return undefined;
141
- const client = await self.clients.get(clientId);
390
+ const entry = index[clientKey];
391
+ if (!entry) return undefined;
392
+ const client = await self.clients.get(entry.clientId);
142
393
  if (!client) {
143
394
  delete index[clientKey];
144
395
  await storeClientsIndex();
@@ -146,5 +397,5 @@ function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions
146
397
  return client ?? undefined;
147
398
  }
148
399
 
149
- return { getClient, addClient, removeClient };
400
+ return { getClient, getMount, addClient, removeClient, restoreMounts };
150
401
  }
@@ -1,6 +1,6 @@
1
1
  import type { HttpHandler } from "@statewalker/webrun-http-streams";
2
2
  import { serializeError } from "@statewalker/webrun-streams";
3
- import { callChannel, handleChannelCalls } from "../core/data-calls.js";
3
+ import { type ChannelCallHandler, callChannel } from "../core/data-calls.js";
4
4
  import type { MessageTarget } from "../core/message-target.js";
5
5
  import { newRegistry } from "../core/registry.js";
6
6
  import {
@@ -80,6 +80,11 @@ async function registerServiceWorker({
80
80
 
81
81
  export interface ServiceOptions {
82
82
  key: string;
83
+ /**
84
+ * Where this service is mounted on the relay origin, e.g. `/` or `/peers/`.
85
+ * Omitted, the service stays reachable at `/~<key>/`, as before mounts.
86
+ */
87
+ path?: string;
83
88
  port: MessageTarget;
84
89
  }
85
90
 
@@ -89,10 +94,11 @@ export interface ServiceOptions {
89
94
  */
90
95
  export async function initHttpService(
91
96
  handler: HttpHandler,
92
- { key, port }: ServiceOptions,
97
+ { key, path, port }: ServiceOptions,
93
98
  ): Promise<() => void> {
94
99
  return await registerConnectionsHandler({
95
100
  key,
101
+ path,
96
102
  communicationPort: port,
97
103
  handler: async (_event, _data, callPort) => {
98
104
  handleHttpRequests(callPort, handler);
@@ -258,22 +264,72 @@ export async function initializeConnection({
258
264
 
259
265
  export interface RegisterConnectionsHandlerOptions {
260
266
  key: string;
267
+ /** Where this service is mounted; see `ServiceOptions.path`. */
268
+ path?: string;
261
269
  handler: (event: MessageEvent, data: unknown, port: MessagePort) => boolean | Promise<boolean>;
262
270
  communicationPort: MessageTarget;
263
271
  }
264
272
 
265
273
  export async function registerConnectionsHandler({
266
274
  key,
275
+ path,
267
276
  handler,
268
277
  communicationPort,
269
278
  }: RegisterConnectionsHandlerOptions): Promise<() => void> {
270
279
  const [register, cleanup] = newRegistry();
271
- await callChannel(communicationPort, "REGISTER", { key });
280
+ // `path` is omitted rather than sent as undefined: the worker distinguishes
281
+ // "mounted at /" from "not mounted", and a key with no path keeps /~<key>/.
282
+ await callChannel(communicationPort, "REGISTER", path == null ? { key } : { key, path });
272
283
  register(() => callChannel(communicationPort, "UNREGISTER", { key }));
273
284
  register(
274
- handleChannelCalls(communicationPort, "CONNECT", async (event, data, port) => {
285
+ handleKeyedChannelCalls(communicationPort, "CONNECT", key, async (event, data, port) => {
275
286
  return await handler(event, data, port);
276
287
  }),
277
288
  );
278
289
  return cleanup;
279
290
  }
291
+
292
+ /**
293
+ * `handleChannelCalls`, but only for calls whose `params.key` is `key`.
294
+ *
295
+ * ONE CONNECTION CARRIES SEVERAL SERVICES — an app at `/` and a mesh gateway
296
+ * at `/peers/` over one relay iframe is the shape mounts exist for. Plain
297
+ * `handleChannelCalls` cannot do that: every listener it has for a call type
298
+ * runs on every message, and each is handed the SAME reply port and the SAME
299
+ * transferred stream port. Two services then both serve the one channel the
300
+ * worker is reading, and its response comes back with both bodies in it.
301
+ *
302
+ * WHY NOT A FILTER INSIDE THE HANDLER. Returning `false` for a foreign key
303
+ * does not help: `handleChannelCalls` still replies, `callChannel` resolves on
304
+ * the FIRST reply it receives, and the loser's `false` reaches the worker as
305
+ * "the client refused" — a 403, non-deterministically. A service that is not
306
+ * the addressee must stay SILENT and leave the transferred port untouched.
307
+ *
308
+ * Local to this module on purpose. `handleChannelCalls` is also used where a
309
+ * call carries no key (REGISTER/UNREGISTER, and the worker's own CONNECT in
310
+ * `index-sw.ts`, which is the other direction), so its semantics must not
311
+ * change.
312
+ */
313
+ function handleKeyedChannelCalls(
314
+ target: MessageTarget,
315
+ callType: string,
316
+ key: string,
317
+ handler: ChannelCallHandler,
318
+ ): () => void {
319
+ const listener = async (event: MessageEvent) => {
320
+ const data = event.data as { type?: string; params?: { key?: unknown } } | null | undefined;
321
+ if (!data || data.type !== callType) return;
322
+ if (data.params?.key !== key) return;
323
+ const [port, ...transfers] = (event.ports ?? []) as MessagePort[];
324
+ const response: { result?: unknown; error?: unknown } = {};
325
+ try {
326
+ response.result = await handler(event, data.params, ...transfers);
327
+ } catch (error) {
328
+ response.error = serializeError(error);
329
+ }
330
+ port?.postMessage(response);
331
+ };
332
+ target.addEventListener("message", listener);
333
+ target.start?.();
334
+ return () => target.removeEventListener("message", listener);
335
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Which registered service owns a URL.
3
+ *
4
+ * A pure lookup: no ServiceWorker, no storage, no I/O, so the routing rules
5
+ * can be tested as arithmetic rather than through a browser.
6
+ *
7
+ * TWO KINDS OF MOUNT, AND WHY BOTH. A `path` is a prefix, which is all most
8
+ * hosts need and costs nothing to match. A `match` predicate is the escape
9
+ * hatch for anything richer -- a host that wants URLPattern brings it and pays
10
+ * for it; this file must stay dependency-free, because it runs in a
11
+ * ServiceWorker that has to start fast.
12
+ *
13
+ * SPECIFICITY, NOT REGISTRATION ORDER. Prefixes are tried longest-first, so a
14
+ * catch-all at "/" cannot swallow "/peers/" and a host need not register in a
15
+ * careful order. Predicates are opaque -- nothing can be said about how
16
+ * specific they are -- so they are tried after every prefix, in the order they
17
+ * were registered.
18
+ */
19
+
20
+ export interface MountSpec {
21
+ /** A path prefix, e.g. `/peers/`. `/` is the whole origin. */
22
+ path?: string;
23
+ /** Anything richer. Consulted only when no prefix matches. */
24
+ match?: (url: URL) => boolean;
25
+ }
26
+
27
+ export interface MountTable {
28
+ /** Add or replace the mount for `key`. */
29
+ set(key: string, spec: MountSpec): void;
30
+ remove(key: string): void;
31
+ /** The key that owns `url`, or `undefined` — meaning "not the relay's". */
32
+ find(url: URL): string | undefined;
33
+ /**
34
+ * Is `url` reserved by `exclude`? `find` already applies it, but the relay
35
+ * has a SECOND route — the `/~<key>/` spelling, which does not go through
36
+ * the table at all — and "an excluded path is never claimed" has to hold
37
+ * for both. The predicate lives here so there is one copy of it.
38
+ */
39
+ excludes(url: URL): boolean;
40
+ }
41
+
42
+ export interface MountTableOptions {
43
+ /**
44
+ * Paths the relay never claims, checked BEFORE the table. A root mount
45
+ * matches every path, so a host with files of its own (a relay page, a
46
+ * worker, hashed assets) is unusable without this.
47
+ */
48
+ exclude?: (url: URL) => boolean;
49
+ }
50
+
51
+ interface Entry {
52
+ key: string;
53
+ /** `""` for a predicate-only mount; otherwise `/` or `/a/b/`. */
54
+ prefix: string;
55
+ match?: (url: URL) => boolean;
56
+ /** Registration order, to break ties between predicates. */
57
+ seq: number;
58
+ }
59
+
60
+ /** `/` stays `/`; `/peers` and `/peers/` both become `/peers/`. */
61
+ function normalise(path: string): string {
62
+ if (path === "" || path === "/") return "/";
63
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
64
+ return withSlash.endsWith("/") ? withSlash : `${withSlash}/`;
65
+ }
66
+
67
+ /** Does `prefix` own `pathname`? `/peers/` owns `/peers/`, `/peers` and `/peers/x`. */
68
+ function owns(prefix: string, pathname: string): boolean {
69
+ if (prefix === "/") return true;
70
+ if (pathname.startsWith(prefix)) return true;
71
+ return `${pathname}/` === prefix;
72
+ }
73
+
74
+ export function newMountTable(options: MountTableOptions = {}): MountTable {
75
+ const entries = new Map<string, Entry>();
76
+ let seq = 0;
77
+
78
+ return {
79
+ set(key, spec) {
80
+ entries.set(key, {
81
+ key,
82
+ prefix: spec.path == null ? "" : normalise(spec.path),
83
+ match: spec.match,
84
+ seq: seq++,
85
+ });
86
+ },
87
+
88
+ remove(key) {
89
+ entries.delete(key);
90
+ },
91
+
92
+ excludes(url) {
93
+ return options.exclude?.(url) === true;
94
+ },
95
+
96
+ find(url) {
97
+ if (options.exclude?.(url) === true) return undefined;
98
+
99
+ let best: Entry | undefined;
100
+ for (const entry of entries.values()) {
101
+ if (entry.prefix === "" || !owns(entry.prefix, url.pathname)) continue;
102
+ if (best == null || entry.prefix.length > best.prefix.length) best = entry;
103
+ }
104
+ if (best != null) return best.key;
105
+
106
+ const predicates = [...entries.values()]
107
+ .filter((entry) => entry.match != null)
108
+ .sort((a, b) => a.seq - b.seq);
109
+ for (const entry of predicates) {
110
+ if (entry.match?.(url) === true) return entry.key;
111
+ }
112
+ return undefined;
113
+ },
114
+ };
115
+ }