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:
co-authored by
Claude Opus 5
parent
cc4e875c2d
commit
58efe6ddbd
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user