///
import { callChannel, handleChannelCalls } from "./data-calls.js";
import { withDeadline } from "./deadline.js";
/**
* How long, by default, the page waits for its ServiceWorker to activate and
* to take control before giving up. Generous: a first install downloads and
* evaluates the worker script, which on a slow link takes seconds.
*/
export const DEFAULT_SERVICE_WORKER_TIMEOUT = 30_000;
/**
* How long the page waits for `controllerchange` once the worker has
* answered the `CLAIM` request. `clients.claim()` resolves only after the
* browser has queued that event, so this is a grace period for delivery, not
* a second budget.
*/
const CLAIM_GRACE_MS = 1_000;
/** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
export const CLAIM_CALL = "CLAIM";
/**
* - `activation-timeout`: the worker did not activate in time.
* - `uncontrolled`: the worker is active but the page is not controlled by it.
* - `unresponsive`: the page is controlled, but the worker did not answer the
* adapter's handshake in time.
* - `incompatible`: the worker answered, but runs another version of this
* package's link protocol.
*/
export type ServiceWorkerControlFailure =
| "activation-timeout"
| "uncontrolled"
| "unresponsive"
| "incompatible";
/**
* Why a page could not get a working ServiceWorker. `reason` says which wait
* failed; check it (or `name`) rather than `instanceof`, because each of this
* package's bundles carries its own copy of this class.
*/
export class ServiceWorkerControlError extends Error {
readonly reason: ServiceWorkerControlFailure;
constructor(reason: ServiceWorkerControlFailure, message: string) {
super(message);
this.name = "ServiceWorkerControlError";
this.reason = reason;
}
}
export interface AwaitServiceWorkerOptions {
/** Upper bound for the whole wait, in ms. Default `DEFAULT_SERVICE_WORKER_TIMEOUT`. */
timeout?: number;
}
export interface AwaitServiceWorkerControlOptions extends AwaitServiceWorkerOptions {
/**
* When the page is still uncontrolled after asking the worker to claim it,
* reload the page once instead of rejecting. A normal reload is a
* navigation, and navigations are controlled. Guarded by `sessionStorage`
* so it never loops: if the reloaded page is uncontrolled too, it rejects.
* Default `false`.
*/
reloadIfUncontrolled?: boolean;
/** For tests. Default `navigator.serviceWorker`. */
container?: ServiceWorkerContainer;
}
/**
* Resolves with the registration's worker once it is `activated`. Rejects
* with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
* takes longer than `timeout` — an install that throws or never finishes
* would otherwise leave the caller waiting forever.
*/
export async function awaitActiveServiceWorker(
registration: ServiceWorkerRegistration,
{ timeout = DEFAULT_SERVICE_WORKER_TIMEOUT }: AwaitServiceWorkerOptions = {},
): Promise {
const deadline = Date.now() + timeout;
return await withDeadline(deadline, waitForActivated(registration), () => {
const worker = registration.installing ?? registration.waiting ?? registration.active;
return new ServiceWorkerControlError(
"activation-timeout",
`ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ` +
`${timeout} ms` +
(worker ? `; it is "${worker.state}"` : "") +
". Check the worker script for errors during install (DevTools → Application → " +
"Service Workers), or raise the `timeout` option.",
);
});
}
/**
* Resolves with the ServiceWorker that controls this page, once `registration`
* has an activated worker and the page is controlled by it.
*
* A page can stay uncontrolled while its worker is active, and then no
* `controllerchange` ever fires on its own:
* - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
* worker's `clients.claim()` already ran when it activated;
* - Firefox can leave a page loaded while the worker is running uncontrolled.
*
* So when the page is uncontrolled, this asks the active worker to claim it
* again (a `CLAIM` channel call — this package's workers answer it) and waits
* for `controllerchange`. Every wait is bounded by `timeout`. If control never
* comes it reloads once (`reloadIfUncontrolled`) or rejects with a
* `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
* happened and what to do.
*/
export async function awaitServiceWorkerControl(
registration: ServiceWorkerRegistration,
{
timeout = DEFAULT_SERVICE_WORKER_TIMEOUT,
reloadIfUncontrolled = false,
container = navigator.serviceWorker,
}: AwaitServiceWorkerControlOptions = {},
): Promise {
const deadline = Date.now() + timeout;
// Listen before anything else, so a `controllerchange` that lands while we
// wait for activation is not missed.
const controlled = waitForController(container);
try {
const active = await awaitActiveServiceWorker(registration, { timeout });
let controller = container.controller;
if (!controller) {
controller = await withDeadline(
deadline,
(async () => {
// Answered only once `clients.claim()` has resolved; by then the
// browser has queued `controllerchange` — or declined to.
await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
})(),
() => null,
);
}
if (controller) {
forgetReload(registration);
return controller;
}
if (reloadIfUncontrolled && markReload(registration)) {
location.reload();
// The page is going away; settling now would only race the unload.
return await new Promise(() => {});
}
forgetReload(registration);
throw new ServiceWorkerControlError(
"uncontrolled",
`This page is not controlled by its ServiceWorker ${scriptUrl(active)} ` +
`(scope ${registration.scope}), although the worker is active, and the worker did not ` +
`take control when asked (clients.claim()) within ${timeout} ms. ` +
"This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses " +
"ServiceWorkers for that load, and in Firefox for some pages opened while the worker " +
"was already running. Requests from this page would not reach the worker. " +
"Reload the page normally, pass `reloadIfUncontrolled: true` to do that automatically, " +
"check that the page is inside the worker's scope, and that the worker answers the " +
`"${CLAIM_CALL}" request (this package's workers do).`,
);
} finally {
controlled.cancel();
}
}
/**
* ServiceWorker side: answers the page's `CLAIM` request with
* `clients.claim()`, which takes over every uncontrolled client in scope.
* Returns a function that stops answering.
*/
export function handleClaimRequests(self: ServiceWorkerGlobalScope): () => void {
return handleChannelCalls(self, CLAIM_CALL, (event) => {
const claimed = self.clients.claim().then(() => true);
(event as unknown as Partial).waitUntil?.(claimed);
return claimed;
});
}
function waitForActivated(registration: ServiceWorkerRegistration): Promise {
return new Promise((resolve) => {
const watched = new Set();
const check = () => {
const active = registration.active;
if (active?.state === "activated") {
registration.removeEventListener("updatefound", watch);
for (const worker of watched) worker.removeEventListener("statechange", check);
resolve(active);
return;
}
watch();
};
function watch() {
for (const worker of [registration.installing, registration.waiting, registration.active]) {
if (!worker || watched.has(worker)) continue;
watched.add(worker);
worker.addEventListener("statechange", check);
}
}
registration.addEventListener("updatefound", watch);
check();
});
}
function waitForController(container: ServiceWorkerContainer): {
promise: Promise;
cancel: () => void;
} {
let cancel = () => {};
const promise = new Promise((resolve) => {
const onChange = () => {
if (!container.controller) return;
cancel();
resolve(container.controller);
};
cancel = () => container.removeEventListener("controllerchange", onChange);
container.addEventListener("controllerchange", onChange);
});
return { promise, cancel };
}
function delay(ms: number, value: T): Promise {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
function scriptUrl(worker: ServiceWorker | null | undefined): string {
return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
}
function reloadKey(registration: ServiceWorkerRegistration): string {
return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
}
/** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
function markReload(registration: ServiceWorkerRegistration): boolean {
try {
const key = reloadKey(registration);
if (sessionStorage.getItem(key)) return false;
sessionStorage.setItem(key, "1");
return true;
} catch {
// No storage, no loop guard: reloading could then repeat forever.
return false;
}
}
function forgetReload(registration: ServiceWorkerRegistration): void {
try {
sessionStorage.removeItem(reloadKey(registration));
} catch {}
}