fix: make Service Worker cache and removal outcomes truthful

SW-URL-01: canonicalize each generated root-relative manifest URL against the
registration scope once, re-check same-origin, and share that absolute identity
across install cache keys, fetch classification and cache lookup or delete.
Previously every verified asset fell through to the network.

SW-01: serve verified static requests only from the current release cache. A
CacheStorage-wide match could return a previous release's response for the same
URL while the delete targeted a cache that was never read. The worker scope
facade no longer exposes a wide match at all.

SW-02: cache reset deletes only names that parse as owned, so a foreign cache
sharing the ca-static-v1- prefix survives.

SW-03: unregister() resolving to false is a FAILED unregister, not UNREGISTERED.

SW-04: staged removal reports what happened - ABSENT, UNREGISTERED and PURGED
map to DISABLED, OWNERSHIP_MISMATCH to INCOMPATIBLE and FAILED to FAILED - so a
later release cannot delete the worker while a registration or owned cache is
still present.

SW-05: add the runtime-neutral service-worker-static-manifest codec that owns
exact row keys, the extension and content-type allowlist, the root-relative URL
rule and the length-prefixed canonical bytes. The generator and the build gate
hash those same bytes, and the build gate now decodes and recomputes the set
digest instead of type-casting the manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 00:25:11 +09:00
co-authored by Claude Opus 5
parent cc4e875c2d
commit 58efe6ddbd
10 changed files with 722 additions and 55 deletions
@@ -40,7 +40,8 @@ const scope: WorkerScopeLike = {
open: (name) => caches.open(name),
keys: () => caches.keys(),
delete: (name) => caches.delete(name),
match: (request) => caches.match(request),
// SW-01. No CacheStorage-wide match: only the current release cache may
// answer a verified static request.
},
clients: {
matchAll: (options) =>
@@ -33,7 +33,6 @@ export type WorkerScopeLike = Readonly<{
open(cacheName: string): Promise<Cache>;
keys(): Promise<readonly string[]>;
delete(cacheName: string): Promise<boolean>;
match(request: string): Promise<Response | undefined>;
}>;
clients: Readonly<{
matchAll(
@@ -59,6 +58,34 @@ export type WorkerRuntimeConfig = Readonly<{
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;
@@ -73,8 +100,22 @@ export function createServiceWorkerRuntime(
config: WorkerRuntimeConfig,
) {
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
const manifestUrls = new Set(
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
/**
* 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<{
@@ -176,17 +217,25 @@ export function createServiceWorkerRuntime(
});
if (classification !== "VERIFIED_CACHE_FIRST") return null;
const cached = await scope.caches.match(request.url);
// 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;
let currentCache: Cache;
try {
currentCache = await scope.caches.open(currentCacheName);
} catch {
return null;
}
const cached = await currentCache.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);
}
// §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;
@@ -347,7 +396,9 @@ export function createServiceWorkerRuntime(
let cachesDeleted = 0;
const names = await scope.caches.keys();
for (const name of names) {
if (!name.startsWith("ca-static-v1-")) continue;
// 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 {
@@ -2,6 +2,7 @@ import {
SERVICE_WORKER_BOUNDS,
type InstalledServiceWorkerSelection,
type ServiceWorkerActivationOutcome,
type ServiceWorkerRemovalOutcome,
type ServiceWorkerResetOutcome,
type ServiceWorkerRuntimeHost,
type ServiceWorkerStartOutcome,
@@ -75,6 +76,29 @@ export function createServiceWorkerPageController(
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;
@@ -90,8 +114,7 @@ export function createServiceWorkerPageController(
origin: dependencies.origin,
});
observe("disable_cleanup", outcome.kind);
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
return Object.freeze({ kind: "DISABLED" as const });
return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED");
}
const selection = dependencies.selection;
@@ -108,7 +131,7 @@ export function createServiceWorkerPageController(
origin: dependencies.origin,
});
observe("remove_registration", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
return removalStartOutcome(outcome, "REMOVE_FAILED");
}
if (selection.mode === "PURGE_OWNED_RESOURCES") {
const outcome = await purgeOwnedResources({
@@ -118,7 +141,7 @@ export function createServiceWorkerPageController(
origin: dependencies.origin,
});
observe("purge_owned_resources", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
return removalStartOutcome(outcome, "PURGE_FAILED");
}
// §17.5. StrictMode's repeated effect returns the same in-flight promise
@@ -109,14 +109,24 @@ export async function removeOwnedRegistration(
) {
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
}
let unregistered: boolean;
try {
await registration.unregister();
unregistered = await registration.unregister();
} catch {
return Object.freeze({
kind: "FAILED" as const,
operation: "UNREGISTER" as const,
});
}
// SW-03. `unregister()` resolving is not success: `false` means the
// registration is still installed, so reporting UNREGISTERED would let a
// later release delete the worker source while it is still controlling.
if (!unregistered) {
return Object.freeze({
kind: "FAILED" as const,
operation: "UNREGISTER" as const,
});
}
return Object.freeze({ kind: "UNREGISTERED" as const });
}