SW-RR-01. The activation marker was read with response.text() whenever no Content-Length was present, so a large or non-terminating body could consume the whole activation step. It now reads through a bounded reader that stops one byte past the ceiling, cancels its reader, applies a read deadline and decodes UTF-8 fatally. SW-RR-02. A matching nonce is not identity. An activation or reset result whose event.source is null can no longer stand in for the expected worker; only a strict identity match is admitted. SW-RR-03. The build generator and the shared manifest decoder now read one exported extension table, so .mjs and .png stop being emitted-then-refused. .json is deliberately outside it: every JSON file in a build output is a control document the generator already excludes, not a cacheable asset. SW-RR-04. Both caches.open and cache.match are closed as a miss. Letting a match rejection propagate rejected respondWith itself, so the entry never reached its network fallback. WP-RR-01. focus and openWindow now carry the certainty phase showNotification already had — NOT_APPLIED, MAYBE_APPLIED, CONFIRMED — and an effect that lands after the handler deadline is observed exactly once. The evidence never authorizes a retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
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>;
|
|
}>;
|
|
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;
|
|
}>;
|
|
|
|
/**
|
|
* SW-URL-01. Root-relative generated URLs become absolute same-origin URLs
|
|
* exactly once. Anything that escapes the scope origin is dropped rather than
|
|
* silently classified.
|
|
*/
|
|
function canonicalManifestUrls(
|
|
assets: readonly Readonly<{ url: string }>[],
|
|
scopeHref: string,
|
|
): readonly string[] {
|
|
let base: URL;
|
|
try {
|
|
base = new URL(scopeHref);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const canonical: string[] = [];
|
|
for (const asset of assets) {
|
|
try {
|
|
const absolute = new URL(asset.url, base);
|
|
if (absolute.origin !== base.origin) continue;
|
|
canonical.push(absolute.href);
|
|
} catch {
|
|
// A manifest URL that cannot be canonicalized is never classified.
|
|
}
|
|
}
|
|
return canonical;
|
|
}
|
|
|
|
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");
|
|
/**
|
|
* SW-URL-01. The generated manifest stores root-relative URLs while `Request`
|
|
* exposes absolute ones, so comparing the two directly classified every
|
|
* verified asset as a network fallback. Canonicalize once against the
|
|
* registration scope, re-check same-origin, and share that identity across
|
|
* install cache keys, fetch classification and cache lookup or delete.
|
|
*/
|
|
const manifestUrls: ReadonlySet<string> = Object.freeze(
|
|
new Set(
|
|
staticEnabled
|
|
? canonicalManifestUrls(
|
|
config.manifest?.assets ?? [],
|
|
scope.registrationScope,
|
|
)
|
|
: [],
|
|
),
|
|
);
|
|
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;
|
|
|
|
// SW-01. Only the current release cache may answer. A CacheStorage-wide
|
|
// match could return a previous release's response for the same URL, and
|
|
// the subsequent delete would then target a cache that was never read.
|
|
const currentCacheName = config.manifest
|
|
? staticCacheName(config.manifest.setDigest)
|
|
: null;
|
|
if (!currentCacheName) return null;
|
|
// SW-RR-04. Both `open` and `match` are storage calls that can throw
|
|
// synchronously or reject. Either one escaping here rejects `respondWith`
|
|
// itself, so the entry never reaches its network fallback and the page
|
|
// gets a network error instead of the live response.
|
|
let cached: Response | undefined;
|
|
let currentCache: Cache;
|
|
try {
|
|
currentCache = await scope.caches.open(currentCacheName);
|
|
cached = await currentCache.match(request.url);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!cached) return null;
|
|
if (cached.status !== 200 || cached.type === "opaque") {
|
|
// §18.6. An invalid hit is deleted from the cache it was read from and
|
|
// treated as a release mismatch.
|
|
await currentCache.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) {
|
|
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
|
return "REJECTED";
|
|
}
|
|
|
|
// SW-08. `skipWaiting()` is the activation commit. It must succeed before
|
|
// any client is told the activation was accepted, and its failure is a
|
|
// rejection rather than an accepted-then-failed activation.
|
|
try {
|
|
await scope.skipWaiting();
|
|
} catch {
|
|
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
|
return "REJECTED";
|
|
}
|
|
// Post-commit notifications are per-client best effort.
|
|
notifyClients(clients, "ACTIVATE_ACCEPTED", parsed.message.sourceBuildId, nonce);
|
|
notifyClients(
|
|
clients,
|
|
"ACTIVATED_RELOAD_REQUIRED",
|
|
parsed.message.sourceBuildId,
|
|
nonce,
|
|
);
|
|
return "ACCEPTED";
|
|
}
|
|
|
|
/**
|
|
* SW-08. One client's `postMessage()` throwing must not break the whole
|
|
* activation event; delivery is isolated per client.
|
|
*/
|
|
function notifyClients(
|
|
clients: readonly WorkerClientLike[],
|
|
kind: "ACTIVATE_REJECTED" | "ACTIVATE_ACCEPTED" | "ACTIVATED_RELOAD_REQUIRED",
|
|
targetBuildId: string,
|
|
nonce: string,
|
|
): void {
|
|
for (const client of clients) {
|
|
try {
|
|
client.postMessage(
|
|
createServiceWorkerMessage({
|
|
kind,
|
|
sourceBuildId: config.identity.buildId,
|
|
targetBuildId,
|
|
nonce,
|
|
}),
|
|
);
|
|
} catch {
|
|
// A dead client cannot change the already committed activation.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function drainClients(
|
|
clients: readonly WorkerClientLike[],
|
|
nonce: string,
|
|
requesterBuildId: string,
|
|
): Promise<boolean> {
|
|
// SW-07. No in-scope client means nothing dirty to drain, so the set is
|
|
// vacuously drained. A `clients.matchAll()` failure still rejects upstream.
|
|
if (clients.length === 0) return true;
|
|
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,
|
|
}),
|
|
);
|
|
});
|
|
// SW-08. A client that cannot receive the drain request can never
|
|
// acknowledge it, so it fails immediately instead of holding the pending
|
|
// state until the timeout.
|
|
for (const client of clients) {
|
|
try {
|
|
client.postMessage(
|
|
createServiceWorkerMessage({
|
|
kind: "CLIENT_DRAIN_REQUEST",
|
|
sourceBuildId: config.identity.buildId,
|
|
targetBuildId: requesterBuildId,
|
|
nonce,
|
|
}),
|
|
);
|
|
} catch {
|
|
const pending = pendingActivations.get(nonce);
|
|
if (pending) settlePendingActivation(nonce, pending, false);
|
|
return await drained;
|
|
}
|
|
}
|
|
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) {
|
|
// SW-02. Exact ownership only: a prefix match would also delete
|
|
// `ca-static-v1-not-owned` and any longer-suffixed foreign cache.
|
|
if (!isOwnedStaticCacheName(name)) 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the
|
|
* reader as soon as that byte arrives, and fails closed on invalid UTF-8. The
|
|
* reader is also raced against a deadline so a stream that never produces a
|
|
* chunk cannot hold `activate` open.
|
|
*/
|
|
const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000;
|
|
|
|
async function readBoundedMarkerText(
|
|
response: Response,
|
|
): Promise<string | null> {
|
|
if (!response.body) {
|
|
try {
|
|
const text = await response.text();
|
|
return new TextEncoder().encode(text).byteLength >
|
|
ACTIVATION_MARKER_MAX_BYTES
|
|
? null
|
|
: text;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let total = 0;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const deadline = new Promise<"DEADLINE">((resolve) => {
|
|
timer = setTimeout(
|
|
() => resolve("DEADLINE"),
|
|
ACTIVATION_MARKER_READ_DEADLINE_MS,
|
|
);
|
|
});
|
|
try {
|
|
for (;;) {
|
|
const next = await Promise.race([reader.read(), deadline]);
|
|
if (next === "DEADLINE") return null;
|
|
if (next.done) break;
|
|
if (!next.value) continue;
|
|
total += next.value.byteLength;
|
|
if (total > ACTIVATION_MARKER_MAX_BYTES) return null;
|
|
chunks.push(next.value);
|
|
}
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
// Never awaited: cancelling a stream whose source ignores cancellation can
|
|
// itself hang, and the marker read already has its answer.
|
|
void reader.cancel().catch(() => {});
|
|
}
|
|
const bytes = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
bytes.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
try {
|
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
// SW-RR-01. A Content-Length is a claim, not a bound. Without one the
|
|
// previous `response.text()` read the whole body, so a large or
|
|
// non-terminating stream could consume the activation step indefinitely.
|
|
const text = await readBoundedMarkerText(response);
|
|
if (text === null) 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;
|
|
}
|
|
}
|