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; }>; export type CacheStorageLike = Readonly<{ keys(): Promise; delete(cacheName: string): Promise; }>; 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 { 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 { 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, }); }