The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 lines
17 KiB
TypeScript
495 lines
17 KiB
TypeScript
import {
|
|
SERVICE_WORKER_BOUNDS,
|
|
type InstalledServiceWorkerSelection,
|
|
type ServiceWorkerActivationOutcome,
|
|
type ServiceWorkerRemovalOutcome,
|
|
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;
|
|
/** SW-06. Single-flight command state. */
|
|
let activationInFlight: Promise<ServiceWorkerActivationOutcome> | null = null;
|
|
let resetInFlight: Promise<ServiceWorkerResetOutcome> | 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;
|
|
}
|
|
|
|
/**
|
|
* SW-04. Staged removal reports what actually happened.
|
|
*
|
|
* Returning DISABLED for every outcome let a later release delete the worker
|
|
* source and handlers while a registration or an owned cache was still
|
|
* present, or while the registration belonged to someone else.
|
|
*/
|
|
function removalStartOutcome(
|
|
outcome: ServiceWorkerRemovalOutcome,
|
|
failureReason: string,
|
|
): ServiceWorkerStartOutcome {
|
|
switch (outcome.kind) {
|
|
case "ABSENT":
|
|
case "UNREGISTERED":
|
|
case "PURGED":
|
|
return Object.freeze({ kind: "DISABLED" as const });
|
|
case "OWNERSHIP_MISMATCH":
|
|
return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
|
case "FAILED":
|
|
return failed(failureReason);
|
|
}
|
|
}
|
|
|
|
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);
|
|
return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED");
|
|
}
|
|
|
|
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 removalStartOutcome(outcome, "REMOVE_FAILED");
|
|
}
|
|
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 removalStartOutcome(outcome, "PURGE_FAILED");
|
|
}
|
|
|
|
// §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;
|
|
}
|
|
// SW-06. An arbitrary same-origin source must not be able to close this
|
|
// page's admission. The request has to come from the worker we are
|
|
// actually waiting on or the one currently controlling us.
|
|
if (!isExpectedWorkerSource(source)) {
|
|
observe("client_drain", "SOURCE_MISMATCH");
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* SW-06. Source identity is checked by object identity against the
|
|
* registration's waiting/installing/active worker and the container's
|
|
* controller. An empty `event.origin` is never used as a trust signal.
|
|
*/
|
|
function isExpectedWorkerSource(source: unknown): boolean {
|
|
const expected = [
|
|
registration?.waiting,
|
|
registration?.installing,
|
|
registration?.active,
|
|
dependencies.container?.controller,
|
|
];
|
|
return expected.some(
|
|
(candidate) => candidate != null && candidate === source,
|
|
);
|
|
}
|
|
|
|
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.
|
|
*/
|
|
function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
|
// SW-06. Concurrent callers share one command: a second call must not issue
|
|
// a second nonce, a second listener or a second postMessage.
|
|
activationInFlight ??= runActivation().finally(() => {
|
|
activationInFlight = null;
|
|
});
|
|
return activationInFlight;
|
|
}
|
|
|
|
async function runActivation(): 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;
|
|
}
|
|
// SW-06 / SW-RR-02. The reply must come from the exact worker this
|
|
// request was sent to. A matching nonce is not identity: `null`
|
|
// source means the sender cannot be established, so it is refused
|
|
// like any other mismatch rather than accepted as this worker.
|
|
if (event.source !== waiting) {
|
|
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
|
return;
|
|
}
|
|
if (registration?.waiting !== waiting) {
|
|
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
|
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. */
|
|
function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
|
// SW-06. Single-flight, like activation.
|
|
resetInFlight ??= runReset().finally(() => {
|
|
resetInFlight = null;
|
|
});
|
|
return resetInFlight;
|
|
}
|
|
|
|
async function runReset(): Promise<ServiceWorkerResetOutcome> {
|
|
const container = dependencies.container;
|
|
if (!container?.controller) {
|
|
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
|
}
|
|
// SW-06. The reply must come from the controller this request was sent to.
|
|
const requestedController = container.controller;
|
|
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;
|
|
}
|
|
// SW-RR-02. An unattributable reset result is never proof this
|
|
// controller performed the reset.
|
|
if (
|
|
event.source !== requestedController ||
|
|
container.controller !== requestedController
|
|
) {
|
|
finish(
|
|
Object.freeze({
|
|
kind: "FAILED" as const,
|
|
code: "PROTOCOL_MISMATCH",
|
|
}),
|
|
);
|
|
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 });
|
|
}
|