refactor: 리펙토링
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user