refactor: 리펙토링
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { OFFLINE_SYNC_TAG } from "../../contracts/offline-command.ts";
|
||||
import type {
|
||||
ServiceWorkerHandlerId,
|
||||
ServiceWorkerProtocolIdentity,
|
||||
StaticAssetManifestV1,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
import {
|
||||
createServiceWorkerRuntime,
|
||||
type WorkerScopeLike,
|
||||
} from "./service-worker-lifecycle.ts";
|
||||
import { parseServiceWorkerMessage } from "./service-worker-protocol.ts";
|
||||
|
||||
/**
|
||||
* §17.1. The one physical worker entry for this scope.
|
||||
*
|
||||
* PWA lifecycle, verified static asset fetch, Web Push and the optional sync
|
||||
* wake-up are all handler factories inside this single entry. A second
|
||||
* registration for any of them is prohibited.
|
||||
*
|
||||
* This module is compiled only by `vite.service-worker.config.ts` when the
|
||||
* static selection is `ACTIVE`; it is never part of the page bundle.
|
||||
*/
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// Build-time virtual modules (§18.3). They resolve through the Service Worker
|
||||
// Vite config only, so the page bundle can never import a worker asset list.
|
||||
declare const __CA_SERVICE_WORKER_BUILD_INFO__: ServiceWorkerProtocolIdentity;
|
||||
declare const __CA_SERVICE_WORKER_ASSETS__: StaticAssetManifestV1 | null;
|
||||
declare const __CA_SERVICE_WORKER_HANDLERS__: readonly ServiceWorkerHandlerId[];
|
||||
declare const __CA_RUNTIME_CONFIG_URL__: string;
|
||||
declare const __CA_RELEASE_MANIFEST_URL__: string;
|
||||
|
||||
const identity = __CA_SERVICE_WORKER_BUILD_INFO__;
|
||||
const handlers = __CA_SERVICE_WORKER_HANDLERS__;
|
||||
|
||||
const scope: WorkerScopeLike = {
|
||||
caches: {
|
||||
open: (name) => caches.open(name),
|
||||
keys: () => caches.keys(),
|
||||
delete: (name) => caches.delete(name),
|
||||
match: (request) => caches.match(request),
|
||||
},
|
||||
clients: {
|
||||
matchAll: (options) =>
|
||||
self.clients.matchAll(
|
||||
options as { type?: "window"; includeUncontrolled?: boolean },
|
||||
) as Promise<
|
||||
readonly {
|
||||
id: string;
|
||||
url: string;
|
||||
postMessage(m: unknown): void;
|
||||
}[]
|
||||
>,
|
||||
},
|
||||
registrationScope: self.registration.scope,
|
||||
skipWaiting: () => self.skipWaiting(),
|
||||
fetcher: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
|
||||
async digest(bytes) {
|
||||
const buffer = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytes.slice().buffer as ArrayBuffer,
|
||||
);
|
||||
let hex = "";
|
||||
for (const byte of new Uint8Array(buffer)) {
|
||||
hex += byte.toString(16).padStart(2, "0");
|
||||
}
|
||||
return `sha256:${hex}`;
|
||||
},
|
||||
};
|
||||
|
||||
const runtime = createServiceWorkerRuntime(scope, {
|
||||
identity,
|
||||
handlers,
|
||||
manifest: __CA_SERVICE_WORKER_ASSETS__,
|
||||
runtimeConfigUrl: __CA_RUNTIME_CONFIG_URL__,
|
||||
releaseManifestUrl: __CA_RELEASE_MANIFEST_URL__,
|
||||
});
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// §17.10. Install never calls skipWaiting(); activation is a page handshake.
|
||||
event.waitUntil(runtime.onInstall());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
// §17.12. No clients.claim() in the baseline.
|
||||
event.waitUntil(runtime.onActivate());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
event.respondWith(
|
||||
runtime
|
||||
.onFetch({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
})
|
||||
.then((cached) => cached ?? fetch(request)),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
const parsed = parseServiceWorkerMessage(event.data);
|
||||
if (!parsed.ok) return;
|
||||
if (parsed.message.kind === "ACTIVATE_REQUEST") {
|
||||
event.waitUntil(runtime.onActivateRequest(event.data));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "CLIENT_DRAINED" ||
|
||||
parsed.message.kind === "ACTIVATE_REJECTED"
|
||||
) {
|
||||
const source = event.source;
|
||||
if (source && "id" in source && typeof source.id === "string") {
|
||||
runtime.onClientMessage(event.data, source.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parsed.message.kind === "CACHE_RESET_REQUEST") {
|
||||
const source = event.source;
|
||||
if (
|
||||
source &&
|
||||
"id" in source &&
|
||||
typeof source.id === "string" &&
|
||||
"postMessage" in source &&
|
||||
typeof source.postMessage === "function"
|
||||
) {
|
||||
event.waitUntil(runtime.onCacheResetRequest(event.data, source));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// §17.1 WEB_PUSH composition point.
|
||||
//
|
||||
// The Web Push runtime is a handler factory inside this one entry, never a
|
||||
// second registration. It is not wired here because the template cannot supply
|
||||
// the two product-owned inputs it needs: a PushAssociationFenceStore over the
|
||||
// product push control repository, and a WebPushNotificationRegistry of exact
|
||||
// notification types with the same-origin routes their clicks may open
|
||||
// (§21.11). Selecting WEB_PUSH means adding, inside a
|
||||
// `handlers.includes("WEB_PUSH")` guard: import
|
||||
// createWebPushServiceWorkerRuntime and createServiceWorkerScopeHost from the
|
||||
// sibling web-push adapter, then call the runtime with the scope host built
|
||||
// from `self` plus the product fence store and notification registry.
|
||||
//
|
||||
// Keeping the import out of the baseline entry is also what lets the realtime
|
||||
// and Web Push runtime be removed as a pure file deletion (§24.12).
|
||||
|
||||
if (handlers.includes("OFFLINE_SYNC_WAKEUP")) {
|
||||
// §19.18. Wake-up only: the handler records that a sync fired and notifies
|
||||
// controlled clients. It never sends an authenticated command (§19.20).
|
||||
self.addEventListener("sync", (rawEvent: Event) => {
|
||||
const event = rawEvent as ExtendableEvent & { tag?: string };
|
||||
if (event.tag !== OFFLINE_SYNC_TAG) return;
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window" }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
client.postMessage({
|
||||
protocolVersion: 1,
|
||||
kind: "SYNC_WAKE_OBSERVED",
|
||||
messageId: crypto.randomUUID(),
|
||||
sourceBuildId: identity.buildId,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
isOwnedStaticCacheName,
|
||||
staticCacheName,
|
||||
type ServiceWorkerHandlerId,
|
||||
type ServiceWorkerProtocolIdentity,
|
||||
type StaticAssetManifestV1,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
import {
|
||||
createServiceWorkerMessage,
|
||||
parseServiceWorkerMessage,
|
||||
} from "./service-worker-protocol.ts";
|
||||
import {
|
||||
classifyFetch,
|
||||
installStaticAssets,
|
||||
selectCachesToDelete,
|
||||
} from "./service-worker-static-assets.ts";
|
||||
|
||||
/**
|
||||
* §17.9–§17.15. Worker-side lifecycle, expressed against structural types so it
|
||||
* can be unit-tested outside a real Service Worker global and compiled under
|
||||
* `tsconfig.service-worker.json` without pulling in DOM globals.
|
||||
*/
|
||||
|
||||
export type WorkerClientLike = Readonly<{
|
||||
id: string;
|
||||
url: string;
|
||||
postMessage(message: unknown): void;
|
||||
}>;
|
||||
|
||||
export type WorkerScopeLike = Readonly<{
|
||||
caches: Readonly<{
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
match(request: string): Promise<Response | undefined>;
|
||||
}>;
|
||||
clients: Readonly<{
|
||||
matchAll(
|
||||
options?: Readonly<{
|
||||
type?: "window";
|
||||
includeUncontrolled?: boolean;
|
||||
}>,
|
||||
): Promise<
|
||||
readonly WorkerClientLike[]
|
||||
>;
|
||||
}>;
|
||||
registrationScope: string;
|
||||
skipWaiting(): Promise<void>;
|
||||
fetcher: typeof fetch;
|
||||
digest(bytes: Uint8Array): Promise<string>;
|
||||
}>;
|
||||
|
||||
export type WorkerRuntimeConfig = Readonly<{
|
||||
identity: ServiceWorkerProtocolIdentity;
|
||||
handlers: readonly ServiceWorkerHandlerId[];
|
||||
manifest: StaticAssetManifestV1 | null;
|
||||
runtimeConfigUrl: string;
|
||||
releaseManifestUrl: string;
|
||||
}>;
|
||||
|
||||
const ACTIVATION_MARKER_URL =
|
||||
"https://clean-architecture.invalid/__service-worker-activation-v1__";
|
||||
const ACTIVATION_MARKER_MAX_BYTES = 256;
|
||||
|
||||
type ActivationMarker = Readonly<{
|
||||
cacheName: string;
|
||||
activationSequence: number;
|
||||
}>;
|
||||
|
||||
export function createServiceWorkerRuntime(
|
||||
scope: WorkerScopeLike,
|
||||
config: WorkerRuntimeConfig,
|
||||
) {
|
||||
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
|
||||
const manifestUrls = new Set(
|
||||
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
|
||||
);
|
||||
const consumedNonces = new Set<string>();
|
||||
type PendingActivation = Readonly<{
|
||||
requesterBuildId: string;
|
||||
expectedClientIds: ReadonlySet<string>;
|
||||
acknowledgedClientIds: Set<string>;
|
||||
resolve(drained: boolean): void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>;
|
||||
const pendingActivations = new Map<string, PendingActivation>();
|
||||
|
||||
/**
|
||||
* §17.9. Without the static asset handler the install step opens zero caches;
|
||||
* it only registers lifecycle, push and sync handlers.
|
||||
*/
|
||||
async function onInstall(): Promise<void> {
|
||||
if (!staticEnabled || !config.manifest) return;
|
||||
const outcome = await installStaticAssets(config.manifest, {
|
||||
caches: scope.caches,
|
||||
fetcher: scope.fetcher,
|
||||
digest: scope.digest,
|
||||
});
|
||||
if (outcome.kind === "REJECTED") {
|
||||
throw new Error(`STATIC_INSTALL_REJECTED:${outcome.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.15. Delete only owned caches outside the current and one previous
|
||||
* revision. `clients.claim()` is never called (§17.12).
|
||||
*/
|
||||
async function onActivate(): Promise<number> {
|
||||
if (!staticEnabled || !config.manifest) return 0;
|
||||
const current = staticCacheName(config.manifest.setDigest);
|
||||
const names = await scope.caches.keys();
|
||||
const owned = names.filter(isOwnedStaticCacheName);
|
||||
const currentCache = await scope.caches.open(current);
|
||||
const oldCaches = owned.filter((name) => name !== current);
|
||||
const markers: ActivationMarker[] = [];
|
||||
for (const name of oldCaches) {
|
||||
const marker = await readActivationMarker(await scope.caches.open(name), name);
|
||||
if (marker) markers.push(marker);
|
||||
}
|
||||
const currentMarker = await readActivationMarker(currentCache, current);
|
||||
const highestOld = markers.reduce<ActivationMarker | null>(
|
||||
(highest, marker) =>
|
||||
!highest || marker.activationSequence >= highest.activationSequence
|
||||
? marker
|
||||
: highest,
|
||||
null,
|
||||
);
|
||||
const previous = highestOld?.cacheName ?? oldCaches.at(-1) ?? null;
|
||||
if (
|
||||
!currentMarker ||
|
||||
currentMarker.activationSequence < (highestOld?.activationSequence ?? 0)
|
||||
) {
|
||||
const nextSequence = (highestOld?.activationSequence ?? 0) + 1;
|
||||
if (!Number.isSafeInteger(nextSequence)) {
|
||||
throw new Error("SERVICE_WORKER_ACTIVATION_SEQUENCE_EXHAUSTED");
|
||||
}
|
||||
await currentCache.put(
|
||||
ACTIVATION_MARKER_URL,
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
cacheName: current,
|
||||
activationSequence: nextSequence,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
const stale = selectCachesToDelete(names, current, previous);
|
||||
let deleted = 0;
|
||||
for (const name of stale) {
|
||||
if (await scope.caches.delete(name)) deleted += 1;
|
||||
}
|
||||
void SERVICE_WORKER_BOUNDS.retainedPreviousCaches;
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* §18.5–§18.7. A verified cache hit is returned; anything else goes to the
|
||||
* network and is never written back into the active cache at runtime.
|
||||
*/
|
||||
async function onFetch(
|
||||
request: Readonly<{ method: string; url: string; mode?: string }>,
|
||||
): Promise<Response | null> {
|
||||
const classification = classifyFetch({
|
||||
method: request.method,
|
||||
requestUrl: request.url,
|
||||
isNavigation: request.mode === "navigate",
|
||||
runtimeConfigUrl: config.runtimeConfigUrl,
|
||||
releaseManifestUrl: config.releaseManifestUrl,
|
||||
manifestUrls,
|
||||
});
|
||||
if (classification !== "VERIFIED_CACHE_FIRST") return null;
|
||||
|
||||
const cached = await scope.caches.match(request.url);
|
||||
if (!cached) return null;
|
||||
if (cached.status !== 200 || cached.type === "opaque") {
|
||||
// §18.6. An invalid hit is deleted and treated as a release mismatch.
|
||||
const current = config.manifest
|
||||
? staticCacheName(config.manifest.setDigest)
|
||||
: null;
|
||||
if (current) {
|
||||
const cache = await scope.caches.open(current);
|
||||
await cache.delete(request.url).catch(() => false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.11. The waiting worker validates the request, drains every controlled
|
||||
* client, and only then calls `skipWaiting()`.
|
||||
*/
|
||||
async function onActivateRequest(
|
||||
data: unknown,
|
||||
): Promise<"ACCEPTED" | "REJECTED" | "IGNORED"> {
|
||||
const parsed = parseServiceWorkerMessage(data);
|
||||
if (!parsed.ok || parsed.message.kind !== "ACTIVATE_REQUEST") return "IGNORED";
|
||||
const nonce = parsed.message.nonce;
|
||||
if (!nonce || consumedNonces.has(nonce)) return "REJECTED";
|
||||
if (
|
||||
parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== config.identity.buildId
|
||||
) {
|
||||
return "REJECTED";
|
||||
}
|
||||
consumedNonces.add(nonce);
|
||||
if (consumedNonces.size > 64) {
|
||||
const oldest = consumedNonces.values().next().value;
|
||||
if (oldest !== undefined) consumedNonces.delete(oldest);
|
||||
}
|
||||
|
||||
const candidates = await scope.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const clients = candidates.filter((client) =>
|
||||
isClientWithinRegistrationScope(client.url, scope.registrationScope),
|
||||
);
|
||||
const drained = await drainClients(
|
||||
clients,
|
||||
nonce,
|
||||
parsed.message.sourceBuildId,
|
||||
);
|
||||
if (!drained) {
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REJECTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return "REJECTED";
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_ACCEPTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await scope.skipWaiting();
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATED_RELOAD_REQUIRED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return "ACCEPTED";
|
||||
}
|
||||
|
||||
async function drainClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
nonce: string,
|
||||
requesterBuildId: string,
|
||||
): Promise<boolean> {
|
||||
if (clients.length === 0) return false;
|
||||
const drained = new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingActivations.delete(nonce);
|
||||
resolve(false);
|
||||
}, SERVICE_WORKER_BOUNDS.clientDrainMs);
|
||||
pendingActivations.set(
|
||||
nonce,
|
||||
Object.freeze({
|
||||
requesterBuildId,
|
||||
expectedClientIds: new Set(clients.map((client) => client.id)),
|
||||
acknowledgedClientIds: new Set<string>(),
|
||||
resolve,
|
||||
timer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
|
||||
function onClientMessage(data: unknown, sourceClientId: string): void {
|
||||
const parsed = parseServiceWorkerMessage(data);
|
||||
if (!parsed.ok || !parsed.message.nonce) return;
|
||||
if (
|
||||
parsed.message.targetBuildId !== config.identity.buildId ||
|
||||
(parsed.message.kind !== "CLIENT_DRAINED" &&
|
||||
parsed.message.kind !== "ACTIVATE_REJECTED")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pending = pendingActivations.get(parsed.message.nonce);
|
||||
if (
|
||||
!pending ||
|
||||
parsed.message.sourceBuildId !== pending.requesterBuildId ||
|
||||
!pending.expectedClientIds.has(sourceClientId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (parsed.message.kind === "ACTIVATE_REJECTED") {
|
||||
settlePendingActivation(parsed.message.nonce, pending, false);
|
||||
return;
|
||||
}
|
||||
pending.acknowledgedClientIds.add(sourceClientId);
|
||||
if (
|
||||
pending.acknowledgedClientIds.size === pending.expectedClientIds.size
|
||||
) {
|
||||
settlePendingActivation(parsed.message.nonce, pending, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCacheResetRequest(
|
||||
data: unknown,
|
||||
source: WorkerClientLike,
|
||||
): Promise<void> {
|
||||
const parsed = parseServiceWorkerMessage(data);
|
||||
if (
|
||||
!parsed.ok ||
|
||||
parsed.message.kind !== "CACHE_RESET_REQUEST" ||
|
||||
!parsed.message.nonce ||
|
||||
(parsed.message.targetBuildId !== undefined &&
|
||||
parsed.message.targetBuildId !== config.identity.buildId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let cachesDeleted = 0;
|
||||
const names = await scope.caches.keys();
|
||||
for (const name of names) {
|
||||
if (!name.startsWith("ca-static-v1-")) continue;
|
||||
try {
|
||||
if (await scope.caches.delete(name)) cachesDeleted += 1;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
source.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CACHE_RESET_RESULT",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce: parsed.message.nonce,
|
||||
cachesDeleted,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
onInstall,
|
||||
onActivate,
|
||||
onFetch,
|
||||
onActivateRequest,
|
||||
onClientMessage,
|
||||
onCacheResetRequest,
|
||||
manifestUrls: manifestUrls as ReadonlySet<string>,
|
||||
});
|
||||
|
||||
function settlePendingActivation(
|
||||
nonce: string,
|
||||
pending: PendingActivation,
|
||||
drained: boolean,
|
||||
): void {
|
||||
clearTimeout(pending.timer);
|
||||
pendingActivations.delete(nonce);
|
||||
pending.resolve(drained);
|
||||
}
|
||||
}
|
||||
|
||||
function isClientWithinRegistrationScope(
|
||||
clientUrl: string,
|
||||
registrationScope: string,
|
||||
): boolean {
|
||||
try {
|
||||
const client = new URL(clientUrl);
|
||||
const scope = new URL(registrationScope);
|
||||
return client.origin === scope.origin && client.href.startsWith(scope.href);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readActivationMarker(
|
||||
cache: Cache,
|
||||
expectedCacheName: string,
|
||||
): Promise<ActivationMarker | null> {
|
||||
try {
|
||||
const response = await cache.match(ACTIVATION_MARKER_URL);
|
||||
if (!response || response.status !== 200) return null;
|
||||
const declaredLength = response.headers.get("content-length");
|
||||
if (
|
||||
declaredLength !== null &&
|
||||
(!/^\d+$/u.test(declaredLength) ||
|
||||
Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
|
||||
(value as { cacheName?: unknown }).cacheName !== expectedCacheName ||
|
||||
!Number.isSafeInteger(
|
||||
(value as { activationSequence?: unknown }).activationSequence,
|
||||
) ||
|
||||
((value as { activationSequence: number }).activationSequence ?? 0) < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
cacheName: expectedCacheName,
|
||||
activationSequence: (value as { activationSequence: number })
|
||||
.activationSequence,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
SERVICE_WORKER_PROTOCOL_VERSION,
|
||||
type ServiceWorkerMessage,
|
||||
type ServiceWorkerMessageKind,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
|
||||
/**
|
||||
* §17.8. Message protocol shared by the page controller and the worker entry.
|
||||
*
|
||||
* Only structural types are used here: this module is compiled into both the
|
||||
* DOM realm and the WebWorker realm, so it must not reference a global from
|
||||
* either one.
|
||||
*/
|
||||
|
||||
const MESSAGE_KINDS: ReadonlySet<string> = new Set<ServiceWorkerMessageKind>([
|
||||
"PAGE_HELLO",
|
||||
"WORKER_HELLO_ACK",
|
||||
"UPDATE_READY",
|
||||
"ACTIVATE_REQUEST",
|
||||
"ACTIVATE_ACCEPTED",
|
||||
"ACTIVATE_REJECTED",
|
||||
"CLIENT_DRAIN_REQUEST",
|
||||
"CLIENT_DRAINED",
|
||||
"ACTIVATED_RELOAD_REQUIRED",
|
||||
"CACHE_RESET_REQUEST",
|
||||
"CACHE_RESET_RESULT",
|
||||
"SYNC_WAKE_OBSERVED",
|
||||
]);
|
||||
|
||||
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
|
||||
export type ParsedMessage =
|
||||
| Readonly<{ ok: true; message: ServiceWorkerMessage }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Exact key set, exact protocol version, bounded identifiers. Anything else is
|
||||
* rejected rather than partially interpreted: a postMessage payload is an
|
||||
* untrusted runtime input (§21.1).
|
||||
*/
|
||||
export function parseServiceWorkerMessage(value: unknown): ParsedMessage {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const allowed = new Set([
|
||||
"protocolVersion",
|
||||
"kind",
|
||||
"messageId",
|
||||
"sourceBuildId",
|
||||
"targetBuildId",
|
||||
"nonce",
|
||||
"cachesDeleted",
|
||||
]);
|
||||
for (const key of Object.keys(candidate)) {
|
||||
if (!allowed.has(key)) return reject("MALFORMED");
|
||||
}
|
||||
if (candidate.protocolVersion !== SERVICE_WORKER_PROTOCOL_VERSION) {
|
||||
return reject("PROTOCOL_MISMATCH");
|
||||
}
|
||||
if (typeof candidate.kind !== "string" || !MESSAGE_KINDS.has(candidate.kind)) {
|
||||
return reject("UNKNOWN_KIND");
|
||||
}
|
||||
if (
|
||||
typeof candidate.messageId !== "string" ||
|
||||
!ID.test(candidate.messageId) ||
|
||||
typeof candidate.sourceBuildId !== "string" ||
|
||||
!ID.test(candidate.sourceBuildId)
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
candidate.cachesDeleted !== undefined &&
|
||||
(!Number.isSafeInteger(candidate.cachesDeleted) ||
|
||||
(candidate.cachesDeleted as number) < 0 ||
|
||||
(candidate.cachesDeleted as number) > 1_024)
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
(candidate.kind === "CACHE_RESET_RESULT") !==
|
||||
(candidate.cachesDeleted !== undefined)
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
candidate.targetBuildId !== undefined &&
|
||||
(typeof candidate.targetBuildId !== "string" ||
|
||||
!ID.test(candidate.targetBuildId))
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
if (
|
||||
candidate.nonce !== undefined &&
|
||||
(typeof candidate.nonce !== "string" || !ID.test(candidate.nonce))
|
||||
) {
|
||||
return reject("MALFORMED");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
message: Object.freeze({
|
||||
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
|
||||
kind: candidate.kind as ServiceWorkerMessageKind,
|
||||
messageId: candidate.messageId,
|
||||
sourceBuildId: candidate.sourceBuildId,
|
||||
...(candidate.targetBuildId === undefined
|
||||
? {}
|
||||
: { targetBuildId: candidate.targetBuildId }),
|
||||
...(candidate.nonce === undefined ? {} : { nonce: candidate.nonce }),
|
||||
...(candidate.cachesDeleted === undefined
|
||||
? {}
|
||||
: { cachesDeleted: candidate.cachesDeleted as number }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createServiceWorkerMessage(
|
||||
input: Readonly<{
|
||||
kind: ServiceWorkerMessageKind;
|
||||
sourceBuildId: string;
|
||||
targetBuildId?: string;
|
||||
nonce?: string;
|
||||
cachesDeleted?: number;
|
||||
messageId?: string;
|
||||
}>,
|
||||
): ServiceWorkerMessage {
|
||||
return Object.freeze({
|
||||
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
|
||||
kind: input.kind,
|
||||
messageId: input.messageId ?? randomId(),
|
||||
sourceBuildId: input.sourceBuildId,
|
||||
...(input.targetBuildId === undefined
|
||||
? {}
|
||||
: { targetBuildId: input.targetBuildId }),
|
||||
...(input.nonce === undefined ? {} : { nonce: input.nonce }),
|
||||
...(input.cachesDeleted === undefined
|
||||
? {}
|
||||
: { cachesDeleted: input.cachesDeleted }),
|
||||
});
|
||||
}
|
||||
|
||||
/** One-time nonce store. A nonce is consumed on first match and never reused. */
|
||||
export function createNonceRegistry(maximumEntries = 32) {
|
||||
const nonces = new Set<string>();
|
||||
return Object.freeze({
|
||||
issue(): string {
|
||||
if (nonces.size >= maximumEntries) {
|
||||
const oldest = nonces.values().next().value;
|
||||
if (oldest !== undefined) nonces.delete(oldest);
|
||||
}
|
||||
const nonce = randomId();
|
||||
nonces.add(nonce);
|
||||
return nonce;
|
||||
},
|
||||
consume(nonce: string | undefined): boolean {
|
||||
if (!nonce || !nonces.has(nonce)) return false;
|
||||
nonces.delete(nonce);
|
||||
return true;
|
||||
},
|
||||
clear(): void {
|
||||
nonces.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function reject(
|
||||
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND",
|
||||
): ParsedMessage {
|
||||
return Object.freeze({ ok: false as const, code });
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
isOwnedStaticCacheName,
|
||||
SERVICE_WORKER_SCRIPT_PATH,
|
||||
type ServiceWorkerRemovalOutcome,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
|
||||
/**
|
||||
* §17.4 / §17.17. Exact ownership check and staged removal.
|
||||
*
|
||||
* A registration is only ours when the scope matches exactly and every present
|
||||
* worker's script URL is same-origin, with at least one matching the expected
|
||||
* script and none pointing anywhere else. A scope-prefix guess is never enough:
|
||||
* a foreign registration must never be unregistered.
|
||||
*/
|
||||
|
||||
export type ServiceWorkerContainerLike = Readonly<{
|
||||
getRegistration(
|
||||
clientUrl?: string,
|
||||
): Promise<ServiceWorkerRegistration | undefined>;
|
||||
}>;
|
||||
|
||||
export type CacheStorageLike = Readonly<{
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
}>;
|
||||
|
||||
export type OwnershipInput = Readonly<{
|
||||
registration: ServiceWorkerRegistration;
|
||||
expectedScopeHref: string;
|
||||
expectedScriptHref: string;
|
||||
}>;
|
||||
|
||||
export function isOwnedRegistration(input: OwnershipInput): boolean {
|
||||
const { registration, expectedScopeHref, expectedScriptHref } = input;
|
||||
if (registration.scope !== expectedScopeHref) return false;
|
||||
|
||||
const expectedOrigin = new URL(expectedScriptHref).origin;
|
||||
const present = [
|
||||
registration.installing,
|
||||
registration.waiting,
|
||||
registration.active,
|
||||
].filter((worker): worker is ServiceWorker => worker !== null);
|
||||
if (present.length === 0) return false;
|
||||
|
||||
let matched = false;
|
||||
for (const worker of present) {
|
||||
let scriptOrigin: string;
|
||||
try {
|
||||
scriptOrigin = new URL(worker.scriptURL).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (scriptOrigin !== expectedOrigin) return false;
|
||||
if (worker.scriptURL === expectedScriptHref) {
|
||||
matched = true;
|
||||
} else {
|
||||
// A present worker running a different script means this registration is
|
||||
// not exclusively ours.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
export function expectedServiceWorkerUrls(
|
||||
routerBasePath: string,
|
||||
origin: string,
|
||||
): Readonly<{ scopeHref: string; scriptHref: string; scopePath: string }> {
|
||||
const scope = new URL(routerBasePath, origin);
|
||||
const script = new URL(SERVICE_WORKER_SCRIPT_PATH, scope);
|
||||
return Object.freeze({
|
||||
scopeHref: scope.href,
|
||||
scriptHref: script.href,
|
||||
scopePath: scope.pathname,
|
||||
});
|
||||
}
|
||||
|
||||
export type RemovalDependencies = Readonly<{
|
||||
container: ServiceWorkerContainerLike;
|
||||
caches?: CacheStorageLike;
|
||||
routerBasePath: string;
|
||||
origin: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* `REMOVE_REGISTRATION`: unregister only, caches retained so a rollback within
|
||||
* the retention window still finds its verified assets.
|
||||
*/
|
||||
export async function removeOwnedRegistration(
|
||||
dependencies: RemovalDependencies,
|
||||
): Promise<ServiceWorkerRemovalOutcome> {
|
||||
const urls = expectedServiceWorkerUrls(
|
||||
dependencies.routerBasePath,
|
||||
dependencies.origin,
|
||||
);
|
||||
let registration: ServiceWorkerRegistration | undefined;
|
||||
try {
|
||||
registration = await dependencies.container.getRegistration(urls.scopePath);
|
||||
} catch {
|
||||
return Object.freeze({ kind: "FAILED" as const, operation: "LOOKUP" as const });
|
||||
}
|
||||
if (!registration) return Object.freeze({ kind: "ABSENT" as const });
|
||||
if (
|
||||
!isOwnedRegistration({
|
||||
registration,
|
||||
expectedScopeHref: urls.scopeHref,
|
||||
expectedScriptHref: urls.scriptHref,
|
||||
})
|
||||
) {
|
||||
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
|
||||
}
|
||||
try {
|
||||
await registration.unregister();
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ kind: "UNREGISTERED" as const });
|
||||
}
|
||||
|
||||
/**
|
||||
* `PURGE_OWNED_RESOURCES`: repeat the unregister check, then delete only caches
|
||||
* whose name parses as ours. Outbox, OPFS and user file data are untouched, and
|
||||
* unregistering is never confused with cache deletion.
|
||||
*/
|
||||
export async function purgeOwnedResources(
|
||||
dependencies: RemovalDependencies,
|
||||
): Promise<ServiceWorkerRemovalOutcome> {
|
||||
const removal = await removeOwnedRegistration(dependencies);
|
||||
if (removal.kind === "OWNERSHIP_MISMATCH" || removal.kind === "FAILED") {
|
||||
return removal;
|
||||
}
|
||||
|
||||
const cacheStorage = dependencies.caches;
|
||||
if (!cacheStorage) {
|
||||
return Object.freeze({
|
||||
kind: "PURGED" as const,
|
||||
cachesDeleted: 0,
|
||||
metadataDeleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let names: readonly string[];
|
||||
try {
|
||||
names = await cacheStorage.keys();
|
||||
} catch {
|
||||
return Object.freeze({ kind: "FAILED" as const, operation: "PURGE" as const });
|
||||
}
|
||||
|
||||
let cachesDeleted = 0;
|
||||
for (const name of names) {
|
||||
if (!isOwnedStaticCacheName(name)) continue;
|
||||
try {
|
||||
if (await cacheStorage.delete(name)) cachesDeleted += 1;
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "PURGE" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "PURGED" as const,
|
||||
cachesDeleted,
|
||||
metadataDeleted: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
isOwnedStaticCacheName,
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
staticCacheName,
|
||||
type StaticAssetManifestV1,
|
||||
} from "../../contracts/service-worker.ts";
|
||||
|
||||
/**
|
||||
* §17.9 / §18. Static asset install and fetch classification.
|
||||
*
|
||||
* Only immutable hashed build assets are cached, all-or-nothing, verified at
|
||||
* install time. Navigation, runtime config, the release manifest and every API
|
||||
* response are network-only, and no runtime response is ever written into the
|
||||
* active cache.
|
||||
*/
|
||||
|
||||
export type FetchClassification =
|
||||
| "NETWORK_PASSTHROUGH"
|
||||
| "NETWORK_ONLY"
|
||||
| "VERIFIED_CACHE_FIRST";
|
||||
|
||||
export type ClassificationInput = Readonly<{
|
||||
method: string;
|
||||
requestUrl: string;
|
||||
isNavigation: boolean;
|
||||
runtimeConfigUrl: string;
|
||||
releaseManifestUrl: string;
|
||||
manifestUrls: ReadonlySet<string>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §18.5. Order matters: the exact static hit is evaluated before the generic
|
||||
* network passthrough, because an API base may legitimately be `/`.
|
||||
*/
|
||||
export function classifyFetch(input: ClassificationInput): FetchClassification {
|
||||
if (input.method !== "GET") return "NETWORK_PASSTHROUGH";
|
||||
if (input.isNavigation) return "NETWORK_ONLY";
|
||||
if (
|
||||
sameResource(input.requestUrl, input.runtimeConfigUrl) ||
|
||||
sameResource(input.requestUrl, input.releaseManifestUrl)
|
||||
) {
|
||||
return "NETWORK_ONLY";
|
||||
}
|
||||
if (input.manifestUrls.has(input.requestUrl)) return "VERIFIED_CACHE_FIRST";
|
||||
return "NETWORK_PASSTHROUGH";
|
||||
}
|
||||
|
||||
function sameResource(left: string, right: string): boolean {
|
||||
try {
|
||||
const a = new URL(left);
|
||||
const b = new URL(right, left);
|
||||
return a.origin === b.origin && a.pathname === b.pathname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallOutcome =
|
||||
| Readonly<{ kind: "INSTALLED"; cacheName: string; assets: number }>
|
||||
| Readonly<{
|
||||
kind: "REJECTED";
|
||||
code:
|
||||
| "MANIFEST_INVALID"
|
||||
| "ASSET_COUNT_EXCEEDED"
|
||||
| "ASSET_TOO_LARGE"
|
||||
| "ASSET_SET_TOO_LARGE"
|
||||
| "INSTALL_DEADLINE_EXCEEDED"
|
||||
| "FETCH_FAILED"
|
||||
| "STATUS_INVALID"
|
||||
| "CONTENT_TYPE_INVALID"
|
||||
| "BYTES_MISMATCH"
|
||||
| "INTEGRITY_MISMATCH"
|
||||
| "QUOTA_EXCEEDED";
|
||||
}>;
|
||||
|
||||
export type InstallDependencies = Readonly<{
|
||||
caches: Readonly<{
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
}>;
|
||||
fetcher: typeof fetch;
|
||||
digest(bytes: Uint8Array): Promise<string>;
|
||||
}>;
|
||||
|
||||
export function validateStaticAssetManifest(
|
||||
manifest: StaticAssetManifestV1,
|
||||
): InstallOutcome | null {
|
||||
const bounds = SERVICE_WORKER_BOUNDS;
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(manifest.setDigest)
|
||||
) {
|
||||
return rejected("MANIFEST_INVALID");
|
||||
}
|
||||
if (manifest.assets.length > bounds.assets) {
|
||||
return rejected("ASSET_COUNT_EXCEEDED");
|
||||
}
|
||||
let total = 0;
|
||||
for (const asset of manifest.assets) {
|
||||
if (
|
||||
!asset.url ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(asset.sha256) ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
asset.bytes < 0
|
||||
) {
|
||||
return rejected("MANIFEST_INVALID");
|
||||
}
|
||||
if (asset.bytes > bounds.singleAssetBytes) return rejected("ASSET_TOO_LARGE");
|
||||
total += asset.bytes;
|
||||
}
|
||||
if (total > bounds.assetSetBytes) return rejected("ASSET_SET_TOO_LARGE");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.9. A partial candidate is never used: any failure deletes the candidate
|
||||
* cache and rejects install, leaving the previous verified revision in place.
|
||||
*/
|
||||
export async function installStaticAssets(
|
||||
manifest: StaticAssetManifestV1,
|
||||
dependencies: InstallDependencies,
|
||||
): Promise<InstallOutcome> {
|
||||
const invalid = validateStaticAssetManifest(manifest);
|
||||
if (invalid) return invalid;
|
||||
|
||||
const cacheName = staticCacheName(manifest.setDigest);
|
||||
const abortController = new AbortController();
|
||||
let deadlineExceeded = false;
|
||||
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<InstallOutcome>((resolve) => {
|
||||
deadlineTimer = setTimeout(() => {
|
||||
deadlineExceeded = true;
|
||||
abortController.abort();
|
||||
resolve(rejected("INSTALL_DEADLINE_EXCEEDED"));
|
||||
}, SERVICE_WORKER_BOUNDS.installDeadlineMs);
|
||||
});
|
||||
|
||||
const installation = installCandidate(
|
||||
manifest,
|
||||
cacheName,
|
||||
dependencies,
|
||||
abortController,
|
||||
);
|
||||
const raced = await Promise.race([installation, deadline]);
|
||||
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
||||
const outcome = deadlineExceeded
|
||||
? rejected("INSTALL_DEADLINE_EXCEEDED")
|
||||
: raced;
|
||||
|
||||
if (outcome.kind === "REJECTED") {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function installCandidate(
|
||||
manifest: StaticAssetManifestV1,
|
||||
cacheName: string,
|
||||
dependencies: InstallDependencies,
|
||||
abortController: AbortController,
|
||||
): Promise<InstallOutcome> {
|
||||
const signal = abortController.signal;
|
||||
let cache: Cache;
|
||||
try {
|
||||
cache = await dependencies.caches.open(cacheName);
|
||||
} catch {
|
||||
return rejected("QUOTA_EXCEEDED");
|
||||
}
|
||||
|
||||
const queue = [...manifest.assets];
|
||||
let failure: InstallOutcome | null = null;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (failure) return;
|
||||
const asset = queue.shift();
|
||||
if (!asset) return;
|
||||
const outcome = await storeAsset(asset, cache, dependencies, signal);
|
||||
if (outcome) {
|
||||
failure ??= outcome;
|
||||
abortController.abort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: SERVICE_WORKER_BOUNDS.fetchConcurrency }, worker),
|
||||
);
|
||||
|
||||
if (failure) return failure;
|
||||
return Object.freeze({
|
||||
kind: "INSTALLED" as const,
|
||||
cacheName,
|
||||
assets: manifest.assets.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function storeAsset(
|
||||
asset: StaticAssetManifestV1["assets"][number],
|
||||
cache: Cache,
|
||||
dependencies: InstallDependencies,
|
||||
signal: AbortSignal,
|
||||
): Promise<InstallOutcome | null> {
|
||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||
let response: Response;
|
||||
try {
|
||||
const fetched = await abortable(
|
||||
dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
if (fetched === ABORTED) return rejected("FETCH_FAILED");
|
||||
response = fetched;
|
||||
} catch {
|
||||
return rejected("FETCH_FAILED");
|
||||
}
|
||||
if (response.status !== 200 || response.type === "opaque") {
|
||||
return rejected("STATUS_INVALID");
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (
|
||||
contentType.split(";", 1)[0]?.trim().toLowerCase() !==
|
||||
asset.contentType.toLowerCase()
|
||||
) {
|
||||
return rejected("CONTENT_TYPE_INVALID");
|
||||
}
|
||||
|
||||
const body = await readBoundedBody(response, asset.bytes, signal);
|
||||
if (!body.ok) return rejected(body.code);
|
||||
const bytes = body.bytes;
|
||||
|
||||
const digest = await abortable(dependencies.digest(bytes), signal);
|
||||
if (digest === ABORTED) return rejected("FETCH_FAILED");
|
||||
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
|
||||
|
||||
try {
|
||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||
await cache.put(
|
||||
asset.url,
|
||||
new Response(bytes.slice(), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return rejected("QUOTA_EXCEEDED");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ABORTED = Symbol("service-worker-install-aborted");
|
||||
|
||||
async function abortable<Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
): Promise<Value | typeof ABORTED> {
|
||||
if (signal.aborted) return ABORTED;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<typeof ABORTED>((resolve) => {
|
||||
onAbort = () => resolve(ABORTED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, aborted]);
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedBody(
|
||||
response: Response,
|
||||
expectedBytes: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<
|
||||
| Readonly<{ ok: true; bytes: Uint8Array }>
|
||||
| Readonly<{ ok: false; code: "BYTES_MISMATCH" | "FETCH_FAILED" }>
|
||||
> {
|
||||
const declaredLength = response.headers.get("content-length");
|
||||
if (
|
||||
declaredLength !== null &&
|
||||
/^\d+$/u.test(declaredLength) &&
|
||||
Number(declaredLength) !== expectedBytes
|
||||
) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
|
||||
}
|
||||
if (!response.body) {
|
||||
return expectedBytes === 0
|
||||
? Object.freeze({ ok: true as const, bytes: new Uint8Array() })
|
||||
: Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const result = await abortable(reader.read(), signal);
|
||||
if (result === ABORTED) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
|
||||
}
|
||||
if (result.done) break;
|
||||
total += result.value.byteLength;
|
||||
if (total > expectedBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "BYTES_MISMATCH" as const,
|
||||
});
|
||||
}
|
||||
chunks.push(result.value);
|
||||
}
|
||||
} catch {
|
||||
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (total !== expectedBytes) {
|
||||
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return Object.freeze({ ok: true as const, bytes });
|
||||
}
|
||||
|
||||
/**
|
||||
* §17.15. Keep the current revision plus exactly one previous verified cache.
|
||||
* A cache found outside the owned prefix is left alone; a cache holding config,
|
||||
* manifest or API data is a security violation and is deleted.
|
||||
*/
|
||||
export function selectCachesToDelete(
|
||||
names: readonly string[],
|
||||
currentCacheName: string,
|
||||
previousCacheName: string | null,
|
||||
): readonly string[] {
|
||||
return Object.freeze(
|
||||
names.filter(
|
||||
(name) =>
|
||||
isOwnedStaticCacheName(name) &&
|
||||
name !== currentCacheName &&
|
||||
name !== previousCacheName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function rejected(code: Extract<InstallOutcome, { kind: "REJECTED" }>["code"]) {
|
||||
return Object.freeze({ kind: "REJECTED" as const, code });
|
||||
}
|
||||
Reference in New Issue
Block a user