Files
clean-architecture-frontend…/src/adapters/service-worker/service-worker-removal.ts
T
DongHyeonkaandClaude Opus 5 58efe6ddbd 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>
2026-08-14 00:25:11 +09:00

180 lines
5.2 KiB
TypeScript

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 });
}
let unregistered: boolean;
try {
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 });
}
/**
* `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,
});
}