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,442 @@
import {
SERVICE_WORKER_BOUNDS,
isOwnedStaticCacheName,
staticCacheName,
type ServiceWorkerHandlerId,
type ServiceWorkerProtocolIdentity,
type StaticAssetManifestV1,
} from "../../contracts/service-worker.ts";
import {
createServiceWorkerMessage,
parseServiceWorkerMessage,
} from "./service-worker-protocol.ts";
import {
classifyFetch,
installStaticAssets,
selectCachesToDelete,
} from "./service-worker-static-assets.ts";
/**
* §17.9–§17.15. Worker-side lifecycle, expressed against structural types so it
* can be unit-tested outside a real Service Worker global and compiled under
* `tsconfig.service-worker.json` without pulling in DOM globals.
*/
export type WorkerClientLike = Readonly<{
id: string;
url: string;
postMessage(message: unknown): void;
}>;
export type WorkerScopeLike = Readonly<{
caches: Readonly<{
open(cacheName: string): Promise<Cache>;
keys(): Promise<readonly string[]>;
delete(cacheName: string): Promise<boolean>;
match(request: string): Promise<Response | undefined>;
}>;
clients: Readonly<{
matchAll(
options?: Readonly<{
type?: "window";
includeUncontrolled?: boolean;
}>,
): Promise<
readonly WorkerClientLike[]
>;
}>;
registrationScope: string;
skipWaiting(): Promise<void>;
fetcher: typeof fetch;
digest(bytes: Uint8Array): Promise<string>;
}>;
export type WorkerRuntimeConfig = Readonly<{
identity: ServiceWorkerProtocolIdentity;
handlers: readonly ServiceWorkerHandlerId[];
manifest: StaticAssetManifestV1 | null;
runtimeConfigUrl: string;
releaseManifestUrl: string;
}>;
const ACTIVATION_MARKER_URL =
"https://clean-architecture.invalid/__service-worker-activation-v1__";
const ACTIVATION_MARKER_MAX_BYTES = 256;
type ActivationMarker = Readonly<{
cacheName: string;
activationSequence: number;
}>;
export function createServiceWorkerRuntime(
scope: WorkerScopeLike,
config: WorkerRuntimeConfig,
) {
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
const manifestUrls = new Set(
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
);
const consumedNonces = new Set<string>();
type PendingActivation = Readonly<{
requesterBuildId: string;
expectedClientIds: ReadonlySet<string>;
acknowledgedClientIds: Set<string>;
resolve(drained: boolean): void;
timer: ReturnType<typeof setTimeout>;
}>;
const pendingActivations = new Map<string, PendingActivation>();
/**
* §17.9. Without the static asset handler the install step opens zero caches;
* it only registers lifecycle, push and sync handlers.
*/
async function onInstall(): Promise<void> {
if (!staticEnabled || !config.manifest) return;
const outcome = await installStaticAssets(config.manifest, {
caches: scope.caches,
fetcher: scope.fetcher,
digest: scope.digest,
});
if (outcome.kind === "REJECTED") {
throw new Error(`STATIC_INSTALL_REJECTED:${outcome.code}`);
}
}
/**
* §17.15. Delete only owned caches outside the current and one previous
* revision. `clients.claim()` is never called (§17.12).
*/
async function onActivate(): Promise<number> {
if (!staticEnabled || !config.manifest) return 0;
const current = staticCacheName(config.manifest.setDigest);
const names = await scope.caches.keys();
const owned = names.filter(isOwnedStaticCacheName);
const currentCache = await scope.caches.open(current);
const oldCaches = owned.filter((name) => name !== current);
const markers: ActivationMarker[] = [];
for (const name of oldCaches) {
const marker = await readActivationMarker(await scope.caches.open(name), name);
if (marker) markers.push(marker);
}
const currentMarker = await readActivationMarker(currentCache, current);
const highestOld = markers.reduce<ActivationMarker | null>(
(highest, marker) =>
!highest || marker.activationSequence >= highest.activationSequence
? marker
: highest,
null,
);
const previous = highestOld?.cacheName ?? oldCaches.at(-1) ?? null;
if (
!currentMarker ||
currentMarker.activationSequence < (highestOld?.activationSequence ?? 0)
) {
const nextSequence = (highestOld?.activationSequence ?? 0) + 1;
if (!Number.isSafeInteger(nextSequence)) {
throw new Error("SERVICE_WORKER_ACTIVATION_SEQUENCE_EXHAUSTED");
}
await currentCache.put(
ACTIVATION_MARKER_URL,
new Response(
JSON.stringify({
schemaVersion: 1,
cacheName: current,
activationSequence: nextSequence,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
}
const stale = selectCachesToDelete(names, current, previous);
let deleted = 0;
for (const name of stale) {
if (await scope.caches.delete(name)) deleted += 1;
}
void SERVICE_WORKER_BOUNDS.retainedPreviousCaches;
return deleted;
}
/**
* §18.5–§18.7. A verified cache hit is returned; anything else goes to the
* network and is never written back into the active cache at runtime.
*/
async function onFetch(
request: Readonly<{ method: string; url: string; mode?: string }>,
): Promise<Response | null> {
const classification = classifyFetch({
method: request.method,
requestUrl: request.url,
isNavigation: request.mode === "navigate",
runtimeConfigUrl: config.runtimeConfigUrl,
releaseManifestUrl: config.releaseManifestUrl,
manifestUrls,
});
if (classification !== "VERIFIED_CACHE_FIRST") return null;
const cached = await scope.caches.match(request.url);
if (!cached) return null;
if (cached.status !== 200 || cached.type === "opaque") {
// §18.6. An invalid hit is deleted and treated as a release mismatch.
const current = config.manifest
? staticCacheName(config.manifest.setDigest)
: null;
if (current) {
const cache = await scope.caches.open(current);
await cache.delete(request.url).catch(() => false);
}
return null;
}
return cached;
}
/**
* §17.11. The waiting worker validates the request, drains every controlled
* client, and only then calls `skipWaiting()`.
*/
async function onActivateRequest(
data: unknown,
): Promise<"ACCEPTED" | "REJECTED" | "IGNORED"> {
const parsed = parseServiceWorkerMessage(data);
if (!parsed.ok || parsed.message.kind !== "ACTIVATE_REQUEST") return "IGNORED";
const nonce = parsed.message.nonce;
if (!nonce || consumedNonces.has(nonce)) return "REJECTED";
if (
parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== config.identity.buildId
) {
return "REJECTED";
}
consumedNonces.add(nonce);
if (consumedNonces.size > 64) {
const oldest = consumedNonces.values().next().value;
if (oldest !== undefined) consumedNonces.delete(oldest);
}
const candidates = await scope.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
const clients = candidates.filter((client) =>
isClientWithinRegistrationScope(client.url, scope.registrationScope),
);
const drained = await drainClients(
clients,
nonce,
parsed.message.sourceBuildId,
);
if (!drained) {
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_REJECTED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
return "REJECTED";
}
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_ACCEPTED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
await scope.skipWaiting();
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATED_RELOAD_REQUIRED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
return "ACCEPTED";
}
async function drainClients(
clients: readonly WorkerClientLike[],
nonce: string,
requesterBuildId: string,
): Promise<boolean> {
if (clients.length === 0) return false;
const drained = new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
pendingActivations.delete(nonce);
resolve(false);
}, SERVICE_WORKER_BOUNDS.clientDrainMs);
pendingActivations.set(
nonce,
Object.freeze({
requesterBuildId,
expectedClientIds: new Set(clients.map((client) => client.id)),
acknowledgedClientIds: new Set<string>(),
resolve,
timer,
}),
);
});
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "CLIENT_DRAIN_REQUEST",
sourceBuildId: config.identity.buildId,
targetBuildId: requesterBuildId,
nonce,
}),
);
}
return drained;
}
function onClientMessage(data: unknown, sourceClientId: string): void {
const parsed = parseServiceWorkerMessage(data);
if (!parsed.ok || !parsed.message.nonce) return;
if (
parsed.message.targetBuildId !== config.identity.buildId ||
(parsed.message.kind !== "CLIENT_DRAINED" &&
parsed.message.kind !== "ACTIVATE_REJECTED")
) {
return;
}
const pending = pendingActivations.get(parsed.message.nonce);
if (
!pending ||
parsed.message.sourceBuildId !== pending.requesterBuildId ||
!pending.expectedClientIds.has(sourceClientId)
) {
return;
}
if (parsed.message.kind === "ACTIVATE_REJECTED") {
settlePendingActivation(parsed.message.nonce, pending, false);
return;
}
pending.acknowledgedClientIds.add(sourceClientId);
if (
pending.acknowledgedClientIds.size === pending.expectedClientIds.size
) {
settlePendingActivation(parsed.message.nonce, pending, true);
}
}
async function onCacheResetRequest(
data: unknown,
source: WorkerClientLike,
): Promise<void> {
const parsed = parseServiceWorkerMessage(data);
if (
!parsed.ok ||
parsed.message.kind !== "CACHE_RESET_REQUEST" ||
!parsed.message.nonce ||
(parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== config.identity.buildId)
) {
return;
}
let cachesDeleted = 0;
const names = await scope.caches.keys();
for (const name of names) {
if (!name.startsWith("ca-static-v1-")) continue;
try {
if (await scope.caches.delete(name)) cachesDeleted += 1;
} catch {
return;
}
}
source.postMessage(
createServiceWorkerMessage({
kind: "CACHE_RESET_RESULT",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce: parsed.message.nonce,
cachesDeleted,
}),
);
}
return Object.freeze({
onInstall,
onActivate,
onFetch,
onActivateRequest,
onClientMessage,
onCacheResetRequest,
manifestUrls: manifestUrls as ReadonlySet<string>,
});
function settlePendingActivation(
nonce: string,
pending: PendingActivation,
drained: boolean,
): void {
clearTimeout(pending.timer);
pendingActivations.delete(nonce);
pending.resolve(drained);
}
}
function isClientWithinRegistrationScope(
clientUrl: string,
registrationScope: string,
): boolean {
try {
const client = new URL(clientUrl);
const scope = new URL(registrationScope);
return client.origin === scope.origin && client.href.startsWith(scope.href);
} catch {
return false;
}
}
async function readActivationMarker(
cache: Cache,
expectedCacheName: string,
): Promise<ActivationMarker | null> {
try {
const response = await cache.match(ACTIVATION_MARKER_URL);
if (!response || response.status !== 200) return null;
const declaredLength = response.headers.get("content-length");
if (
declaredLength !== null &&
(!/^\d+$/u.test(declaredLength) ||
Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES)
) {
return null;
}
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
return null;
}
const value: unknown = JSON.parse(text);
if (
value === null ||
typeof value !== "object" ||
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
(value as { cacheName?: unknown }).cacheName !== expectedCacheName ||
!Number.isSafeInteger(
(value as { activationSequence?: unknown }).activationSequence,
) ||
((value as { activationSequence: number }).activationSequence ?? 0) < 1
) {
return null;
}
return Object.freeze({
cacheName: expectedCacheName,
activationSequence: (value as { activationSequence: number })
.activationSequence,
});
} catch {
return null;
}
}