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>
180 lines
5.2 KiB
TypeScript
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,
|
|
});
|
|
}
|