@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.
- package/LICENSE +21 -0
- package/README.md +207 -12
- package/dist/core/deadline.d.ts +7 -0
- package/dist/core/deadline.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +72 -0
- package/dist/core/service-worker-control.d.ts.map +1 -0
- package/dist/index.js +328 -67
- package/dist/relay/index-sw.d.ts +86 -1
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +32 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay/mount-table.d.ts +48 -0
- package/dist/relay/mount-table.d.ts.map +1 -0
- package/dist/relay/split-service-url.d.ts.map +1 -1
- package/dist/relay-sw.js +293 -50
- package/dist/relay-worker.d.ts +20 -0
- package/dist/relay-worker.d.ts.map +1 -0
- package/dist/relay-worker.js +1899 -0
- package/dist/sw/sw-dispatcher.d.ts +22 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw-worker.js +23 -2
- package/dist/sw.js +219 -36
- package/package.json +9 -1
- package/src/core/deadline.ts +31 -0
- package/src/core/index.ts +1 -0
- package/src/core/service-worker-control.ts +243 -0
- package/src/relay/index-sw.ts +293 -37
- package/src/relay/index.ts +125 -51
- package/src/relay/mount-table.ts +115 -0
- package/src/relay/split-service-url.ts +73 -13
- package/src/relay-sw.ts +11 -3
- package/src/relay-worker.ts +20 -0
- package/src/sw/sw-dispatcher.ts +72 -45
- package/public-relay/heartbeat.js +0 -31
|
@@ -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
|
+
}
|
package/src/relay/index-sw.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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,
|
|
331
|
+
let _index: Record<string, RegisteredClient> | undefined;
|
|
100
332
|
|
|
101
|
-
async function loadClientsIndex(): Promise<Record<string,
|
|
333
|
+
async function loadClientsIndex(): Promise<Record<string, RegisteredClient>> {
|
|
102
334
|
if (!_index) {
|
|
103
|
-
const entries = ((await get<Array<[string,
|
|
104
|
-
|
|
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,
|
|
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
|
|
118
|
-
if (
|
|
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
|
|
135
|
-
if (!
|
|
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
|
}
|