@statewalker/webrun-http-browser 0.4.2 → 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.
@@ -0,0 +1,243 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import { callChannel, handleChannelCalls } from "./data-calls.js";
4
+ import { withDeadline } from "./deadline.js";
5
+
6
+ /**
7
+ * How long, by default, the page waits for its ServiceWorker to activate and
8
+ * to take control before giving up. Generous: a first install downloads and
9
+ * evaluates the worker script, which on a slow link takes seconds.
10
+ */
11
+ export const DEFAULT_SERVICE_WORKER_TIMEOUT = 30_000;
12
+
13
+ /**
14
+ * How long the page waits for `controllerchange` once the worker has
15
+ * answered the `CLAIM` request. `clients.claim()` resolves only after the
16
+ * browser has queued that event, so this is a grace period for delivery, not
17
+ * a second budget.
18
+ */
19
+ const CLAIM_GRACE_MS = 1_000;
20
+
21
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
22
+ export const CLAIM_CALL = "CLAIM";
23
+
24
+ /**
25
+ * - `activation-timeout`: the worker did not activate in time.
26
+ * - `uncontrolled`: the worker is active but the page is not controlled by it.
27
+ * - `unresponsive`: the page is controlled, but the worker did not answer the
28
+ * adapter's handshake in time.
29
+ */
30
+ export type ServiceWorkerControlFailure = "activation-timeout" | "uncontrolled" | "unresponsive";
31
+
32
+ /**
33
+ * Why a page could not get a working ServiceWorker. `reason` says which wait
34
+ * failed; check it (or `name`) rather than `instanceof`, because each of this
35
+ * package's bundles carries its own copy of this class.
36
+ */
37
+ export class ServiceWorkerControlError extends Error {
38
+ readonly reason: ServiceWorkerControlFailure;
39
+ constructor(reason: ServiceWorkerControlFailure, message: string) {
40
+ super(message);
41
+ this.name = "ServiceWorkerControlError";
42
+ this.reason = reason;
43
+ }
44
+ }
45
+
46
+ export interface AwaitServiceWorkerOptions {
47
+ /** Upper bound for the whole wait, in ms. Default `DEFAULT_SERVICE_WORKER_TIMEOUT`. */
48
+ timeout?: number;
49
+ }
50
+
51
+ export interface AwaitServiceWorkerControlOptions extends AwaitServiceWorkerOptions {
52
+ /**
53
+ * When the page is still uncontrolled after asking the worker to claim it,
54
+ * reload the page once instead of rejecting. A normal reload is a
55
+ * navigation, and navigations are controlled. Guarded by `sessionStorage`
56
+ * so it never loops: if the reloaded page is uncontrolled too, it rejects.
57
+ * Default `false`.
58
+ */
59
+ reloadIfUncontrolled?: boolean;
60
+ /** For tests. Default `navigator.serviceWorker`. */
61
+ container?: ServiceWorkerContainer;
62
+ }
63
+
64
+ /**
65
+ * Resolves with the registration's worker once it is `activated`. Rejects
66
+ * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
67
+ * takes longer than `timeout` — an install that throws or never finishes
68
+ * would otherwise leave the caller waiting forever.
69
+ */
70
+ export async function awaitActiveServiceWorker(
71
+ registration: ServiceWorkerRegistration,
72
+ { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT }: AwaitServiceWorkerOptions = {},
73
+ ): Promise<ServiceWorker> {
74
+ const deadline = Date.now() + timeout;
75
+ return await withDeadline(deadline, waitForActivated(registration), () => {
76
+ const worker = registration.installing ?? registration.waiting ?? registration.active;
77
+ return new ServiceWorkerControlError(
78
+ "activation-timeout",
79
+ `ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ` +
80
+ `${timeout} ms` +
81
+ (worker ? `; it is "${worker.state}"` : "") +
82
+ ". Check the worker script for errors during install (DevTools → Application → " +
83
+ "Service Workers), or raise the `timeout` option.",
84
+ );
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Resolves with the ServiceWorker that controls this page, once `registration`
90
+ * has an activated worker and the page is controlled by it.
91
+ *
92
+ * A page can stay uncontrolled while its worker is active, and then no
93
+ * `controllerchange` ever fires on its own:
94
+ * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
95
+ * worker's `clients.claim()` already ran when it activated;
96
+ * - Firefox can leave a page loaded while the worker is running uncontrolled.
97
+ *
98
+ * So when the page is uncontrolled, this asks the active worker to claim it
99
+ * again (a `CLAIM` channel call — this package's workers answer it) and waits
100
+ * for `controllerchange`. Every wait is bounded by `timeout`. If control never
101
+ * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
102
+ * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
103
+ * happened and what to do.
104
+ */
105
+ export async function awaitServiceWorkerControl(
106
+ registration: ServiceWorkerRegistration,
107
+ {
108
+ timeout = DEFAULT_SERVICE_WORKER_TIMEOUT,
109
+ reloadIfUncontrolled = false,
110
+ container = navigator.serviceWorker,
111
+ }: AwaitServiceWorkerControlOptions = {},
112
+ ): Promise<ServiceWorker> {
113
+ const deadline = Date.now() + timeout;
114
+ // Listen before anything else, so a `controllerchange` that lands while we
115
+ // wait for activation is not missed.
116
+ const controlled = waitForController(container);
117
+ try {
118
+ const active = await awaitActiveServiceWorker(registration, { timeout });
119
+ let controller = container.controller;
120
+ if (!controller) {
121
+ controller = await withDeadline(
122
+ deadline,
123
+ (async () => {
124
+ // Answered only once `clients.claim()` has resolved; by then the
125
+ // browser has queued `controllerchange` — or declined to.
126
+ await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
127
+ return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
128
+ })(),
129
+ () => null,
130
+ );
131
+ }
132
+ if (controller) {
133
+ forgetReload(registration);
134
+ return controller;
135
+ }
136
+ if (reloadIfUncontrolled && markReload(registration)) {
137
+ location.reload();
138
+ // The page is going away; settling now would only race the unload.
139
+ return await new Promise<never>(() => {});
140
+ }
141
+ forgetReload(registration);
142
+ throw new ServiceWorkerControlError(
143
+ "uncontrolled",
144
+ `This page is not controlled by its ServiceWorker ${scriptUrl(active)} ` +
145
+ `(scope ${registration.scope}), although the worker is active, and the worker did not ` +
146
+ `take control when asked (clients.claim()) within ${timeout} ms. ` +
147
+ "This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses " +
148
+ "ServiceWorkers for that load, and in Firefox for some pages opened while the worker " +
149
+ "was already running. Requests from this page would not reach the worker. " +
150
+ "Reload the page normally, pass `reloadIfUncontrolled: true` to do that automatically, " +
151
+ "check that the page is inside the worker's scope, and that the worker answers the " +
152
+ `"${CLAIM_CALL}" request (this package's workers do).`,
153
+ );
154
+ } finally {
155
+ controlled.cancel();
156
+ }
157
+ }
158
+
159
+ /**
160
+ * ServiceWorker side: answers the page's `CLAIM` request with
161
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
162
+ * Returns a function that stops answering.
163
+ */
164
+ export function handleClaimRequests(self: ServiceWorkerGlobalScope): () => void {
165
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
166
+ const claimed = self.clients.claim().then(() => true);
167
+ (event as unknown as Partial<ExtendableMessageEvent>).waitUntil?.(claimed);
168
+ return claimed;
169
+ });
170
+ }
171
+
172
+ function waitForActivated(registration: ServiceWorkerRegistration): Promise<ServiceWorker> {
173
+ return new Promise((resolve) => {
174
+ const watched = new Set<ServiceWorker>();
175
+ const check = () => {
176
+ const active = registration.active;
177
+ if (active?.state === "activated") {
178
+ registration.removeEventListener("updatefound", watch);
179
+ for (const worker of watched) worker.removeEventListener("statechange", check);
180
+ resolve(active);
181
+ return;
182
+ }
183
+ watch();
184
+ };
185
+ function watch() {
186
+ for (const worker of [registration.installing, registration.waiting, registration.active]) {
187
+ if (!worker || watched.has(worker)) continue;
188
+ watched.add(worker);
189
+ worker.addEventListener("statechange", check);
190
+ }
191
+ }
192
+ registration.addEventListener("updatefound", watch);
193
+ check();
194
+ });
195
+ }
196
+
197
+ function waitForController(container: ServiceWorkerContainer): {
198
+ promise: Promise<ServiceWorker>;
199
+ cancel: () => void;
200
+ } {
201
+ let cancel = () => {};
202
+ const promise = new Promise<ServiceWorker>((resolve) => {
203
+ const onChange = () => {
204
+ if (!container.controller) return;
205
+ cancel();
206
+ resolve(container.controller);
207
+ };
208
+ cancel = () => container.removeEventListener("controllerchange", onChange);
209
+ container.addEventListener("controllerchange", onChange);
210
+ });
211
+ return { promise, cancel };
212
+ }
213
+
214
+ function delay<T>(ms: number, value: T): Promise<T> {
215
+ return new Promise((resolve) => setTimeout(() => resolve(value), ms));
216
+ }
217
+
218
+ function scriptUrl(worker: ServiceWorker | null | undefined): string {
219
+ return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
220
+ }
221
+
222
+ function reloadKey(registration: ServiceWorkerRegistration): string {
223
+ return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
224
+ }
225
+
226
+ /** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
227
+ function markReload(registration: ServiceWorkerRegistration): boolean {
228
+ try {
229
+ const key = reloadKey(registration);
230
+ if (sessionStorage.getItem(key)) return false;
231
+ sessionStorage.setItem(key, "1");
232
+ return true;
233
+ } catch {
234
+ // No storage, no loop guard: reloading could then repeat forever.
235
+ return false;
236
+ }
237
+ }
238
+
239
+ function forgetReload(registration: ServiceWorkerRegistration): void {
240
+ try {
241
+ sessionStorage.removeItem(reloadKey(registration));
242
+ } catch {}
243
+ }
@@ -2,16 +2,175 @@ import { HttpError } from "@statewalker/webrun-http-streams";
2
2
  import { get, set } from "idb-keyval";
3
3
  import { callChannel, handleChannelCalls } from "../core/data-calls.js";
4
4
  import { newRegistry } from "../core/registry.js";
5
+ import { handleClaimRequests } from "../core/service-worker-control.js";
5
6
  import { sendHttpRequest } from "../http/http-send-recieve.js";
7
+ import { type MountSpec, type MountTable, newMountTable } from "./mount-table.js";
6
8
  import { splitServiceUrl } from "./split-service-url.js";
7
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
+
8
155
  /**
9
156
  * Boots the relay ServiceWorker: routes fetches shaped `<origin>/~<key>/…` to
10
157
  * the client that registered `key`, and exposes REGISTER/UNREGISTER/CONNECT
11
158
  * channel calls used by the page-side relay client.
12
159
  */
13
- export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => void {
160
+ export function startRelayServiceWorker(
161
+ self: ServiceWorkerGlobalScope,
162
+ options: RelayServiceWorkerOptions = {},
163
+ ): () => void {
14
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";
15
174
 
16
175
  if (typeof self.skipWaiting === "function") {
17
176
  self.addEventListener("install", (e: ExtendableEvent) => {
@@ -27,17 +186,65 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
27
186
 
28
187
  const clientsRegistry = newClientsRegistry({ self });
29
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
+
213
+ // Pages bridge to `registration.active` when uncontrolled, so they do not
214
+ // need this; it is here so any page of this origin can ask for control.
215
+ register(handleClaimRequests(self));
216
+
30
217
  register(
31
218
  handleChannelCalls(self, "REGISTER", async (event, data) => {
32
219
  const source = event.source as Client | null;
33
220
  if (!source) return false;
34
- const { key } = data as { key: string };
35
- 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;
36
242
  }),
37
243
  );
38
244
  register(
39
245
  handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
40
246
  const { key } = data as { key: string };
247
+ removeRegisteredMount(mounts, key, hostKeys);
41
248
  return await clientsRegistry.removeClient(key);
42
249
  }),
43
250
  );
@@ -50,31 +257,54 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
50
257
  }),
51
258
  );
52
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
+
53
284
  const fetchListener = (event: FetchEvent) => {
54
285
  const request = event.request;
55
- const params = splitServiceUrl(request.url);
56
- const { key } = params;
57
- if (!key) return;
286
+ const url = new URL(request.url);
58
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.
59
303
  event.respondWith(
60
304
  (async (): Promise<Response> => {
61
- try {
62
- const channel = new MessageChannel();
63
- const client = await clientsRegistry.getClient(key);
64
- if (!client) throw HttpError.errorResourceGone(params);
65
- const data = { type: "http", key };
66
- const accepted = await callChannel<boolean>(client, "CONNECT", data, channel.port2);
67
- if (!accepted) throw HttpError.errorForbidden(params);
68
- return await sendHttpRequest(channel.port1, request);
69
- } catch (error) {
70
- const httpError = HttpError.fromError(error);
71
- const options = httpError.getResponseOptions(params);
72
- return new Response(JSON.stringify(options), {
73
- status: httpError.status ?? 500,
74
- statusText: httpError.statusText ?? "Internal Error",
75
- headers: { "Content-Type": "application/json" },
76
- });
77
- }
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);
78
308
  })(),
79
309
  );
80
310
  };
@@ -90,33 +320,41 @@ interface ClientsRegistryOptions {
90
320
  }
91
321
 
92
322
  interface ClientsRegistry {
93
- addClient(clientKey: string, client: Client): Promise<boolean>;
323
+ addClient(clientKey: string, client: Client, path?: string): Promise<boolean>;
94
324
  removeClient(clientKey: string): Promise<boolean>;
95
325
  getClient(clientKey: string): Promise<Client | undefined>;
326
+ getMount(clientKey: string): Promise<RegisteredClient | undefined>;
327
+ restoreMounts(table: MountTable, hostKeys?: ReadonlySet<string>): Promise<void>;
96
328
  }
97
329
 
98
330
  function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions): ClientsRegistry {
99
- let _index: Record<string, string> | undefined;
331
+ let _index: Record<string, RegisteredClient> | undefined;
100
332
 
101
- async function loadClientsIndex(): Promise<Record<string, string>> {
333
+ async function loadClientsIndex(): Promise<Record<string, RegisteredClient>> {
102
334
  if (!_index) {
103
- const entries = ((await get<Array<[string, string]>>(key)) ?? []) as Array<[string, string]>;
104
- _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
+ }
105
343
  }
106
344
  return _index;
107
345
  }
108
346
 
109
- async function storeClientsIndex(): Promise<Record<string, string>> {
347
+ async function storeClientsIndex(): Promise<Record<string, RegisteredClient>> {
110
348
  const index = await loadClientsIndex();
111
349
  await set(key, Object.entries(index));
112
350
  return index;
113
351
  }
114
352
 
115
- async function addClient(clientKey: string, client: Client): Promise<boolean> {
353
+ async function addClient(clientKey: string, client: Client, path?: string): Promise<boolean> {
116
354
  const index = await loadClientsIndex();
117
- const clientId = client.id;
118
- if (index[clientKey] === clientId) return false;
119
- 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 };
120
358
  await storeClientsIndex();
121
359
  return true;
122
360
  }
@@ -129,11 +367,29 @@ function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions
129
367
  return true;
130
368
  }
131
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
+
132
388
  async function getClient(clientKey: string): Promise<Client | undefined> {
133
389
  const index = await loadClientsIndex();
134
- const clientId = index[clientKey];
135
- if (!clientId) return undefined;
136
- 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);
137
393
  if (!client) {
138
394
  delete index[clientKey];
139
395
  await storeClientsIndex();
@@ -141,5 +397,5 @@ function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions
141
397
  return client ?? undefined;
142
398
  }
143
399
 
144
- return { getClient, addClient, removeClient };
400
+ return { getClient, getMount, addClient, removeClient, restoreMounts };
145
401
  }