refactor: 리펙토링

This commit is contained in:
DongHyeonka
2026-08-01 19:39:59 +09:00
parent 9c959ea2a5
commit c6da03369c
171 changed files with 20329 additions and 782 deletions
@@ -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,
});
}