@statewalker/webrun-http-browser 0.4.2 → 0.5.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 +80 -7
- 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 +236 -47
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +23 -6
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +18 -1
- 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 +7 -3
- 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 +5 -0
- package/src/relay/index.ts +65 -47
- package/src/sw/sw-dispatcher.ts +72 -45
|
@@ -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,6 +2,7 @@ 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";
|
|
6
7
|
import { splitServiceUrl } from "./split-service-url.js";
|
|
7
8
|
|
|
@@ -27,6 +28,10 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
|
|
|
27
28
|
|
|
28
29
|
const clientsRegistry = newClientsRegistry({ self });
|
|
29
30
|
|
|
31
|
+
// Pages bridge to `registration.active` when uncontrolled, so they do not
|
|
32
|
+
// need this; it is here so any page of this origin can ask for control.
|
|
33
|
+
register(handleClaimRequests(self));
|
|
34
|
+
|
|
30
35
|
register(
|
|
31
36
|
handleChannelCalls(self, "REGISTER", async (event, data) => {
|
|
32
37
|
const source = event.source as Client | null;
|
package/src/relay/index.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import type { HttpHandler } from "@statewalker/webrun-http-streams";
|
|
2
|
+
import { serializeError } from "@statewalker/webrun-streams";
|
|
2
3
|
import { callChannel, handleChannelCalls } from "../core/data-calls.js";
|
|
3
4
|
import type { MessageTarget } from "../core/message-target.js";
|
|
4
5
|
import { newRegistry } from "../core/registry.js";
|
|
6
|
+
import {
|
|
7
|
+
awaitActiveServiceWorker,
|
|
8
|
+
DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
9
|
+
} from "../core/service-worker-control.js";
|
|
5
10
|
import { handleHttpRequests, sendHttpRequest } from "../http/http-send-recieve.js";
|
|
6
11
|
|
|
7
12
|
export * from "./split-service-url.js";
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The URL of this module, kept in a variable on purpose. Bundlers (Vite
|
|
16
|
+
* among them) rewrite every literal `new URL("<path>", import.meta.url)` into
|
|
17
|
+
* an emitted asset at build time, before tree-shaking — so the defaults below
|
|
18
|
+
* made every Vite consumer of this entry emit a dead copy of the package's
|
|
19
|
+
* own `dist/index.js` (`"../"` resolves to the package, hence to its `main`).
|
|
20
|
+
* Resolving against a variable is the same URL at run time and invisible to
|
|
21
|
+
* that transform.
|
|
22
|
+
*/
|
|
23
|
+
const moduleUrl: string = import.meta.url;
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Returns a MessagePort that transparently bridges messages to/from the
|
|
11
|
-
* ServiceWorker controlling
|
|
27
|
+
* page's ServiceWorker: the one controlling the page, or — when the page is
|
|
28
|
+
* not controlled (a hard reload, or a page Firefox left uncontrolled) — the
|
|
29
|
+
* active worker of `registration`, which answers messages all the same.
|
|
12
30
|
*/
|
|
13
|
-
export function newServiceWorkerPort(): MessagePort {
|
|
31
|
+
export function newServiceWorkerPort(registration?: ServiceWorkerRegistration): MessagePort {
|
|
14
32
|
const channel = new MessageChannel();
|
|
15
33
|
channel.port1.onmessage = (event) => {
|
|
16
|
-
navigator.serviceWorker.controller?.
|
|
34
|
+
const worker = navigator.serviceWorker.controller ?? registration?.active;
|
|
35
|
+
worker?.postMessage(event.data, [...event.ports]);
|
|
17
36
|
};
|
|
18
37
|
navigator.serviceWorker.addEventListener("message", (event) => {
|
|
19
38
|
channel.port1.postMessage(event.data, [...event.ports]);
|
|
@@ -25,51 +44,38 @@ export interface InitServiceWorkerOptions {
|
|
|
25
44
|
swUrl: string;
|
|
26
45
|
scopeUrl?: string;
|
|
27
46
|
type?: WorkerType;
|
|
47
|
+
/**
|
|
48
|
+
* Upper bound, in ms, for the wait for the worker to activate; past it the
|
|
49
|
+
* promise rejects with a `ServiceWorkerControlError`. Default
|
|
50
|
+
* `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
|
|
51
|
+
*/
|
|
52
|
+
timeout?: number;
|
|
28
53
|
}
|
|
29
54
|
|
|
30
55
|
/**
|
|
31
|
-
* Registers a ServiceWorker and resolves once it
|
|
56
|
+
* Registers a ServiceWorker and resolves with it once it is activated: the
|
|
57
|
+
* worker controlling the page, or the registration's active worker when the
|
|
58
|
+
* page is not controlled. Messaging works either way, which is all the relay
|
|
59
|
+
* needs; nothing here waits for control, because an uncontrolled page (hard
|
|
60
|
+
* reload; Firefox) may never get it. Rejects with a
|
|
61
|
+
* `ServiceWorkerControlError` if activation takes longer than `timeout`.
|
|
32
62
|
*/
|
|
33
|
-
export async function initServiceWorker({
|
|
63
|
+
export async function initServiceWorker(options: InitServiceWorkerOptions): Promise<ServiceWorker> {
|
|
64
|
+
return (await registerServiceWorker(options)).worker;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function registerServiceWorker({
|
|
34
68
|
swUrl,
|
|
35
69
|
scopeUrl,
|
|
36
70
|
type,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return new Promise((resolve) => {
|
|
46
|
-
const container = navigator.serviceWorker;
|
|
47
|
-
if (container.controller) {
|
|
48
|
-
resolve(container.controller);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const onChange = () => {
|
|
52
|
-
if (!container.controller) return;
|
|
53
|
-
resolve(container.controller);
|
|
54
|
-
container.removeEventListener("controllerchange", onChange);
|
|
55
|
-
};
|
|
56
|
-
container.addEventListener("controllerchange", onChange);
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function awaitServiceWorkerActivation(worker: ServiceWorker): Promise<void> {
|
|
61
|
-
return new Promise((resolve) => {
|
|
62
|
-
if (worker.state === "activated") {
|
|
63
|
-
resolve();
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
const onStateChange = () => {
|
|
67
|
-
if (worker.state !== "activated") return;
|
|
68
|
-
worker.removeEventListener("statechange", onStateChange);
|
|
69
|
-
resolve();
|
|
70
|
-
};
|
|
71
|
-
worker.addEventListener("statechange", onStateChange);
|
|
72
|
-
});
|
|
71
|
+
timeout = DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
72
|
+
}: InitServiceWorkerOptions): Promise<{
|
|
73
|
+
registration: ServiceWorkerRegistration;
|
|
74
|
+
worker: ServiceWorker;
|
|
75
|
+
}> {
|
|
76
|
+
const registration = await navigator.serviceWorker.register(swUrl, { type, scope: scopeUrl });
|
|
77
|
+
const active = await awaitActiveServiceWorker(registration, { timeout });
|
|
78
|
+
return { registration, worker: navigator.serviceWorker.controller ?? active };
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
export interface ServiceOptions {
|
|
@@ -111,16 +117,21 @@ export async function callHttpService(
|
|
|
111
117
|
export interface RelayWindowHandlerOptions {
|
|
112
118
|
swUrl?: string;
|
|
113
119
|
scopeUrl?: string;
|
|
120
|
+
/** Passed to `initServiceWorker`: how long to wait for the relay worker to activate. */
|
|
121
|
+
timeout?: number;
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
/**
|
|
117
125
|
* Returns a `window.onmessage` handler for use inside the relay iframe:
|
|
118
126
|
* it accepts a CONNECT message, starts the relay ServiceWorker, and bridges
|
|
119
|
-
* the parent's MessagePort with the SW.
|
|
127
|
+
* the parent's MessagePort with the SW. If the worker cannot be started, every
|
|
128
|
+
* call the parent makes on that port is answered with the error, so the
|
|
129
|
+
* parent's `initHttpService` / `callHttpService` reject instead of waiting.
|
|
120
130
|
*/
|
|
121
131
|
export function getRelayWindowMessageHandler({
|
|
122
|
-
swUrl = `${new URL("./index-sw.js",
|
|
123
|
-
scopeUrl = `${new URL("../",
|
|
132
|
+
swUrl = `${new URL("./index-sw.js", moduleUrl)}`,
|
|
133
|
+
scopeUrl = `${new URL("../", moduleUrl)}`,
|
|
134
|
+
timeout,
|
|
124
135
|
}: RelayWindowHandlerOptions = {}): (ev: MessageEvent) => Promise<void> {
|
|
125
136
|
let externalPort: MessagePort | undefined;
|
|
126
137
|
return async (ev) => {
|
|
@@ -132,8 +143,15 @@ export function getRelayWindowMessageHandler({
|
|
|
132
143
|
return;
|
|
133
144
|
}
|
|
134
145
|
externalPort = newExternalPort;
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
let registration: ServiceWorkerRegistration;
|
|
147
|
+
try {
|
|
148
|
+
({ registration } = await registerServiceWorker({ swUrl, scopeUrl, timeout }));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const serialized = serializeError(error);
|
|
151
|
+
externalPort.onmessage = (event) => event.ports[0]?.postMessage({ error: serialized });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const serviceWorkerPort = newServiceWorkerPort(registration);
|
|
137
155
|
serviceWorkerPort.onmessage = (event) => {
|
|
138
156
|
externalPort?.postMessage(event.data, [...event.ports]);
|
|
139
157
|
};
|
|
@@ -160,7 +178,7 @@ export interface RemoteRelayChannel {
|
|
|
160
178
|
* returns the port to be used with `initHttpService` / `callHttpService`.
|
|
161
179
|
*/
|
|
162
180
|
export async function newRemoteRelayChannel({
|
|
163
|
-
baseUrl = new URL("../public-relay/",
|
|
181
|
+
baseUrl = new URL("../public-relay/", moduleUrl),
|
|
164
182
|
url = new URL("relay.html", baseUrl),
|
|
165
183
|
container = document.body,
|
|
166
184
|
}: RemoteRelayChannelOptions = {}): Promise<RemoteRelayChannel> {
|
package/src/sw/sw-dispatcher.ts
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
import { get, set } from "idb-keyval";
|
|
2
2
|
import { callChannel, handleChannelCalls } from "../core/data-calls.js";
|
|
3
|
+
import { withDeadline } from "../core/deadline.js";
|
|
3
4
|
import { newRegistry } from "../core/registry.js";
|
|
5
|
+
import {
|
|
6
|
+
awaitServiceWorkerControl,
|
|
7
|
+
DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
8
|
+
handleClaimRequests,
|
|
9
|
+
ServiceWorkerControlError,
|
|
10
|
+
} from "../core/service-worker-control.js";
|
|
4
11
|
|
|
5
12
|
export interface SwPortHandlerOptions {
|
|
6
13
|
key: string;
|
|
7
14
|
scope?: string;
|
|
8
15
|
serviceWorkerUrl?: string;
|
|
9
16
|
bindPort: (port: MessagePort) => void | Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Upper bound, in ms, for `start()`'s wait for the worker to activate, take
|
|
19
|
+
* control of the page and answer the handshake. `start()` rejects with a
|
|
20
|
+
* `ServiceWorkerControlError` past it instead of waiting forever.
|
|
21
|
+
* Default `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
|
|
22
|
+
*/
|
|
23
|
+
timeout?: number;
|
|
24
|
+
/**
|
|
25
|
+
* If the page is still uncontrolled once the worker is active — after a
|
|
26
|
+
* hard reload, say — and the worker does not take it over when asked,
|
|
27
|
+
* reload the page once instead of rejecting. Default `false`.
|
|
28
|
+
*/
|
|
29
|
+
reloadIfUncontrolled?: boolean;
|
|
10
30
|
}
|
|
11
31
|
|
|
12
32
|
interface ChannelInfo {
|
|
@@ -84,6 +104,15 @@ export class SwPortHandler {
|
|
|
84
104
|
return { key: this.key };
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Registers the worker and waits until it is activated and controls this
|
|
109
|
+
* page, then opens the port to it. The page must be controlled, because
|
|
110
|
+
* only a controlled page's `fetch()` reaches the worker. When the page
|
|
111
|
+
* loaded uncontrolled (a hard reload does that), the worker is asked to
|
|
112
|
+
* claim it. Rejects with a `ServiceWorkerControlError` when that fails or
|
|
113
|
+
* `timeout` passes (`reason` says which step); a later `start()` tries
|
|
114
|
+
* again.
|
|
115
|
+
*/
|
|
87
116
|
async start(): Promise<void> {
|
|
88
117
|
if (!this._registrationPromise) {
|
|
89
118
|
this._registrationPromise = (async () => {
|
|
@@ -97,20 +126,44 @@ export class SwPortHandler {
|
|
|
97
126
|
});
|
|
98
127
|
register(() => registration.unregister());
|
|
99
128
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
},
|
|
108
|
-
),
|
|
129
|
+
const stopListening = handleChannelCalls(
|
|
130
|
+
navigator.serviceWorker,
|
|
131
|
+
"UPDATE_COMMUNICATION_PORT",
|
|
132
|
+
async (_event, _params, port: MessagePort) => {
|
|
133
|
+
await this._setCommunicationPort(port);
|
|
134
|
+
return this._getRegistrationInfo();
|
|
135
|
+
},
|
|
109
136
|
);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
137
|
+
register(stopListening);
|
|
138
|
+
|
|
139
|
+
const timeout = this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT;
|
|
140
|
+
const deadline = Date.now() + timeout;
|
|
141
|
+
try {
|
|
142
|
+
this._serviceWorker = await awaitServiceWorkerControl(registration, {
|
|
143
|
+
timeout,
|
|
144
|
+
reloadIfUncontrolled: this.options.reloadIfUncontrolled,
|
|
145
|
+
});
|
|
146
|
+
await withDeadline(
|
|
147
|
+
deadline,
|
|
148
|
+
this._updateCommunicationChannel(),
|
|
149
|
+
() =>
|
|
150
|
+
new ServiceWorkerControlError(
|
|
151
|
+
"unresponsive",
|
|
152
|
+
`ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did ` +
|
|
153
|
+
`not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. ` +
|
|
154
|
+
"Check that the worker script runs this package's same-origin dispatcher " +
|
|
155
|
+
"(it importScripts `sw-worker.js`, or calls `startHttpDispatcher`).",
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// Forget this attempt so a later start() retries. Stop listening,
|
|
160
|
+
// but keep the registration: the worker is fine — it is this page
|
|
161
|
+
// that is not controlled, and other pages may be using the worker.
|
|
162
|
+
stopListening();
|
|
163
|
+
this._cleanupRegistrations = undefined;
|
|
164
|
+
this._registrationPromise = undefined;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
114
167
|
})();
|
|
115
168
|
}
|
|
116
169
|
return this._registrationPromise;
|
|
@@ -129,37 +182,6 @@ export class SwPortHandler {
|
|
|
129
182
|
}
|
|
130
183
|
}
|
|
131
184
|
|
|
132
|
-
function getServiceWorkerController(): Promise<ServiceWorker> {
|
|
133
|
-
return new Promise((resolve) => {
|
|
134
|
-
const container = navigator.serviceWorker;
|
|
135
|
-
if (container.controller) {
|
|
136
|
-
resolve(container.controller);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
const onChange = () => {
|
|
140
|
-
if (!container.controller) return;
|
|
141
|
-
resolve(container.controller);
|
|
142
|
-
container.removeEventListener("controllerchange", onChange);
|
|
143
|
-
};
|
|
144
|
-
container.addEventListener("controllerchange", onChange);
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function awaitServiceWorkerActivation(worker: ServiceWorker): Promise<void> {
|
|
149
|
-
return new Promise((resolve) => {
|
|
150
|
-
if (worker.state === "activated") {
|
|
151
|
-
resolve();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const onStateChange = () => {
|
|
155
|
-
if (worker.state !== "activated") return;
|
|
156
|
-
worker.removeEventListener("statechange", onStateChange);
|
|
157
|
-
resolve();
|
|
158
|
-
};
|
|
159
|
-
worker.addEventListener("statechange", onStateChange);
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
185
|
export interface SwPortDispatcherOptions {
|
|
164
186
|
self: ServiceWorkerGlobalScope;
|
|
165
187
|
log?: (...args: unknown[]) => void;
|
|
@@ -221,7 +243,8 @@ export class SwPortDispatcher {
|
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
start(): void {
|
|
224
|
-
|
|
246
|
+
const stopClaims = handleClaimRequests(this.self);
|
|
247
|
+
const stopPortUpdates = handleChannelCalls(
|
|
225
248
|
this.self,
|
|
226
249
|
"UPDATE_COMMUNICATION_PORT",
|
|
227
250
|
async (event, channelInfo, port: MessagePort) => {
|
|
@@ -232,6 +255,10 @@ export class SwPortDispatcher {
|
|
|
232
255
|
return { ...(channelInfo as ChannelInfo) };
|
|
233
256
|
},
|
|
234
257
|
);
|
|
258
|
+
this._cleanup = () => {
|
|
259
|
+
stopClaims();
|
|
260
|
+
stopPortUpdates();
|
|
261
|
+
};
|
|
235
262
|
|
|
236
263
|
this.self.addEventListener("install", (event) => {
|
|
237
264
|
this.log("Skip waiting on install.", event);
|