chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
type InstalledServiceWorkerSelection,
|
||||
type ServiceWorkerActivationOutcome,
|
||||
type ServiceWorkerResetOutcome,
|
||||
type ServiceWorkerRuntimeHost,
|
||||
type ServiceWorkerStartOutcome,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
import {
|
||||
createNonceRegistry,
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
import {
|
||||
expectedServiceWorkerUrls,
|
||||
isOwnedRegistration,
|
||||
purgeOwnedResources,
|
||||
removeOwnedRegistration,
|
||||
} from "./service-worker-removal.ts";
|
||||
|
||||
/**
|
||||
* §17.5–§17.16. The page-side controller.
|
||||
*
|
||||
* Registration happens after Runtime Config, release and contract set have all
|
||||
* validated and the first React effect has committed. The controller never
|
||||
* calls `skipWaiting()` blindly and never calls `clients.claim()`.
|
||||
*/
|
||||
|
||||
export type ActivationBlocker = () => boolean;
|
||||
|
||||
export type PageControllerDependencies = Readonly<{
|
||||
selection: InstalledServiceWorkerSelection | null;
|
||||
/** True when static selection is ACTIVE but Runtime Config disabled it. */
|
||||
disabledCleanup: boolean;
|
||||
routerBasePath: string;
|
||||
origin: string;
|
||||
buildId: string;
|
||||
container?: ServiceWorkerContainer;
|
||||
caches?: CacheStorage;
|
||||
/** §17.10. Any blocker returning true rejects automatic activation. */
|
||||
blockers?: readonly ActivationBlocker[];
|
||||
now?: () => number;
|
||||
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
|
||||
}>;
|
||||
|
||||
export function createServiceWorkerPageController(
|
||||
dependencies: PageControllerDependencies,
|
||||
): ServiceWorkerRuntimeHost {
|
||||
const nonces = createNonceRegistry();
|
||||
const now = dependencies.now ?? (() => Date.now());
|
||||
const urls = expectedServiceWorkerUrls(
|
||||
dependencies.routerBasePath,
|
||||
dependencies.origin,
|
||||
);
|
||||
|
||||
let registrationPromise: Promise<ServiceWorkerRegistration> | null = null;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
let updateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let stopped = false;
|
||||
const pendingStops = new Set<() => void>();
|
||||
|
||||
const observe = (event: string, outcome: string) =>
|
||||
dependencies.observe?.({ event, outcome });
|
||||
|
||||
function isBlocked(): boolean {
|
||||
for (const blocker of dependencies.blockers ?? []) {
|
||||
try {
|
||||
if (blocker()) return true;
|
||||
} catch {
|
||||
// A defective blocker is treated as blocking: never activate on doubt.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function start(): Promise<ServiceWorkerStartOutcome> {
|
||||
if (stopped) return failed("STOPPED");
|
||||
const container = dependencies.container;
|
||||
|
||||
// §3.6 / §17.6. Static ACTIVE plus runtime DISABLED performs exactly one
|
||||
// owned-registration lookup and at most one unregister. No new register, no
|
||||
// cache deletion, no message or update timer.
|
||||
if (dependencies.disabledCleanup) {
|
||||
if (!container) return Object.freeze({ kind: "DISABLED" as const });
|
||||
const outcome = await removeOwnedRegistration({
|
||||
container,
|
||||
routerBasePath: dependencies.routerBasePath,
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("disable_cleanup", outcome.kind);
|
||||
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
}
|
||||
|
||||
const selection = dependencies.selection;
|
||||
// §3.6 / §17.3 `null`: zero registration lookups and zero Cache Storage
|
||||
// access. The controller must not even probe.
|
||||
if (!selection) return Object.freeze({ kind: "DISABLED" as const });
|
||||
|
||||
if (!container) return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
||||
|
||||
if (selection.mode === "REMOVE_REGISTRATION") {
|
||||
const outcome = await removeOwnedRegistration({
|
||||
container,
|
||||
routerBasePath: dependencies.routerBasePath,
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("remove_registration", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
}
|
||||
if (selection.mode === "PURGE_OWNED_RESOURCES") {
|
||||
const outcome = await purgeOwnedResources({
|
||||
container,
|
||||
...(dependencies.caches ? { caches: dependencies.caches } : {}),
|
||||
routerBasePath: dependencies.routerBasePath,
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("purge_owned_resources", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
}
|
||||
|
||||
// §17.5. StrictMode's repeated effect returns the same in-flight promise
|
||||
// instead of issuing a second registration.
|
||||
registrationPromise ??= container.register(urls.scriptHref, {
|
||||
scope: urls.scopePath,
|
||||
type: "module",
|
||||
updateViaCache: "none",
|
||||
});
|
||||
|
||||
let installedRegistration: ServiceWorkerRegistration;
|
||||
try {
|
||||
installedRegistration = await registrationPromise;
|
||||
} catch {
|
||||
registrationPromise = null;
|
||||
observe("register", "FAILED");
|
||||
return failed("REGISTRATION_FAILED");
|
||||
}
|
||||
if (stopped) return failed("STOPPED");
|
||||
registration = installedRegistration;
|
||||
|
||||
if (
|
||||
!isOwnedRegistration({
|
||||
registration,
|
||||
expectedScopeHref: urls.scopeHref,
|
||||
expectedScriptHref: urls.scriptHref,
|
||||
})
|
||||
) {
|
||||
observe("register", "OWNERSHIP_MISMATCH");
|
||||
return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
||||
}
|
||||
|
||||
attachMessageListener(container);
|
||||
scheduleUpdateChecks();
|
||||
|
||||
if (registration.waiting) {
|
||||
observe("register", "UPDATE_WAITING");
|
||||
return Object.freeze({ kind: "UPDATE_WAITING" as const });
|
||||
}
|
||||
// §17.13. Without `clients.claim()` the first install leaves this page
|
||||
// uncontrolled. That is reported, never silently reloaded.
|
||||
if (registration.active && !container.controller) {
|
||||
observe("register", "RELOAD_TO_ENABLE");
|
||||
return Object.freeze({ kind: "RELOAD_TO_ENABLE" as const });
|
||||
}
|
||||
observe("register", "ACTIVE");
|
||||
return Object.freeze({
|
||||
kind: "ACTIVE" as const,
|
||||
buildId: dependencies.buildId,
|
||||
});
|
||||
}
|
||||
|
||||
function attachMessageListener(container: ServiceWorkerContainer): void {
|
||||
if (messageListener) return;
|
||||
messageListener = (event: MessageEvent) => {
|
||||
if (event.origin && event.origin !== dependencies.origin) return;
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (!parsed.ok) {
|
||||
observe("message", parsed.code);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== dependencies.buildId
|
||||
) {
|
||||
observe("message", "TARGET_BUILD_MISMATCH");
|
||||
return;
|
||||
}
|
||||
if (parsed.message.kind === "CLIENT_DRAIN_REQUEST") {
|
||||
const nonce = parsed.message.nonce;
|
||||
const source = event.source;
|
||||
if (!nonce || !canPostMessage(source)) {
|
||||
observe("client_drain", "MALFORMED");
|
||||
return;
|
||||
}
|
||||
const rejected = isBlocked();
|
||||
source.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: rejected ? "ACTIVATE_REJECTED" : "CLIENT_DRAINED",
|
||||
sourceBuildId: dependencies.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
observe("client_drain", rejected ? "BLOCKED" : "DRAINED");
|
||||
return;
|
||||
}
|
||||
observe("message", parsed.message.kind);
|
||||
};
|
||||
container.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(): void {
|
||||
// §17.14. At most one check per 6 hours, and none while the page is hidden.
|
||||
if (updateTimer) return;
|
||||
updateTimer = setInterval(() => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
||||
return;
|
||||
}
|
||||
void registration?.update().catch(() => {
|
||||
// A failed update check never fails a product flow.
|
||||
});
|
||||
}, SERVICE_WORKER_BOUNDS.updateCheckIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.11. Activation is a handshake: every controlled client must close new
|
||||
* admission and acknowledge within 30s. One missing client rejects it.
|
||||
*/
|
||||
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
const waiting = registration?.waiting;
|
||||
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
|
||||
if (isBlocked()) {
|
||||
observe("activation", "BLOCKED_DIRTY_CLIENT");
|
||||
return Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const });
|
||||
}
|
||||
|
||||
const nonce = nonces.issue();
|
||||
const deadline = now() + SERVICE_WORKER_BOUNDS.clientDrainMs;
|
||||
const accepted = await new Promise<ServiceWorkerActivationOutcome>(
|
||||
(resolve) => {
|
||||
const container = dependencies.container;
|
||||
if (!container) {
|
||||
resolve(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (outcome: ServiceWorkerActivationOutcome) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
nonces.consume(nonce);
|
||||
clearTimeout(timer);
|
||||
container.removeEventListener("message", onMessage);
|
||||
pendingStops.delete(onStop);
|
||||
resolve(outcome);
|
||||
};
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (!parsed.ok) return;
|
||||
if (
|
||||
parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== dependencies.buildId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "ACTIVATE_REJECTED" &&
|
||||
parsed.message.nonce === nonce
|
||||
) {
|
||||
finish(Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "ACTIVATED_RELOAD_REQUIRED" &&
|
||||
parsed.message.nonce === nonce
|
||||
) {
|
||||
finish(
|
||||
Object.freeze({ kind: "ACTIVATED_RELOAD_REQUIRED" as const }),
|
||||
);
|
||||
}
|
||||
};
|
||||
const onStop = () =>
|
||||
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
|
||||
const timer = setTimeout(
|
||||
() => finish(Object.freeze({ kind: "CLIENT_DRAIN_TIMEOUT" as const })),
|
||||
Math.max(0, deadline - now()),
|
||||
);
|
||||
pendingStops.add(onStop);
|
||||
container.addEventListener("message", onMessage);
|
||||
try {
|
||||
waiting.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REQUEST",
|
||||
sourceBuildId: dependencies.buildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
finish(Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }));
|
||||
}
|
||||
},
|
||||
);
|
||||
observe("activation", accepted.kind);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
/** §18.10. Static caches only; the registration itself is left in place. */
|
||||
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
const container = dependencies.container;
|
||||
if (!container?.controller) {
|
||||
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
||||
}
|
||||
const nonce = nonces.issue();
|
||||
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (outcome: ServiceWorkerResetOutcome) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
nonces.consume(nonce);
|
||||
clearTimeout(timer);
|
||||
container.removeEventListener("message", onMessage);
|
||||
pendingStops.delete(onStop);
|
||||
resolve(outcome);
|
||||
};
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.origin && event.origin !== dependencies.origin) return;
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (
|
||||
!parsed.ok ||
|
||||
parsed.message.kind !== "CACHE_RESET_RESULT" ||
|
||||
parsed.message.targetBuildId !== dependencies.buildId ||
|
||||
parsed.message.nonce !== nonce
|
||||
) {
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "RESET" as const,
|
||||
cachesDeleted: parsed.message.cachesDeleted ?? 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const onStop = () =>
|
||||
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
Object.freeze({ kind: "FAILED" as const, code: "RESET_TIMEOUT" }),
|
||||
),
|
||||
SERVICE_WORKER_BOUNDS.clientDrainMs,
|
||||
);
|
||||
pendingStops.add(onStop);
|
||||
container.addEventListener("message", onMessage);
|
||||
try {
|
||||
container.controller?.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CACHE_RESET_REQUEST",
|
||||
sourceBuildId: dependencies.buildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
observe("cache_reset", "REQUESTED");
|
||||
} catch {
|
||||
finish(
|
||||
Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** §17.16. Ordinary shutdown removes listeners and timers; it never unregisters. */
|
||||
async function stop(): Promise<void> {
|
||||
stopped = true;
|
||||
for (const stopPending of [...pendingStops]) stopPending();
|
||||
pendingStops.clear();
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
if (messageListener && dependencies.container) {
|
||||
dependencies.container.removeEventListener("message", messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
nonces.clear();
|
||||
}
|
||||
|
||||
return Object.freeze({ start, requestActivation, resetOwnedCaches, stop });
|
||||
}
|
||||
|
||||
function canPostMessage(
|
||||
source: MessageEventSource | null,
|
||||
): source is MessageEventSource & { postMessage(message: unknown): void } {
|
||||
return !!source && typeof source.postMessage === "function";
|
||||
}
|
||||
|
||||
function failed(code: string): ServiceWorkerStartOutcome {
|
||||
return Object.freeze({ kind: "FAILED" as const, code });
|
||||
}
|
||||
Reference in New Issue
Block a user