refactor: 리펙토링
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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 });
|
||||
}
|
||||
try {
|
||||
await registration.unregister();
|
||||
} catch {
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user