Files
clean-architecture-frontend…/src/adapters/service-worker/service-worker-lifecycle.ts
T
DongHyeonkaandClaude Opus 5 efc577de63 fix: bound Service Worker marker reads and attribute native effects
SW-RR-01. The activation marker was read with response.text() whenever no
Content-Length was present, so a large or non-terminating body could consume
the whole activation step. It now reads through a bounded reader that stops one
byte past the ceiling, cancels its reader, applies a read deadline and decodes
UTF-8 fatally.

SW-RR-02. A matching nonce is not identity. An activation or reset result whose
event.source is null can no longer stand in for the expected worker; only a
strict identity match is admitted.

SW-RR-03. The build generator and the shared manifest decoder now read one
exported extension table, so .mjs and .png stop being emitted-then-refused.
.json is deliberately outside it: every JSON file in a build output is a control
document the generator already excludes, not a cacheable asset.

SW-RR-04. Both caches.open and cache.match are closed as a miss. Letting a
match rejection propagate rejected respondWith itself, so the entry never
reached its network fallback.

WP-RR-01. focus and openWindow now carry the certainty phase showNotification
already had — NOT_APPLIED, MAYBE_APPLIED, CONFIRMED — and an effect that lands
after the handler deadline is observed exactly once. The evidence never
authorizes a retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:10:41 +09:00

587 lines
18 KiB
TypeScript

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>;
}>;
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;
}>;
/**
* SW-URL-01. Root-relative generated URLs become absolute same-origin URLs
* exactly once. Anything that escapes the scope origin is dropped rather than
* silently classified.
*/
function canonicalManifestUrls(
assets: readonly Readonly<{ url: string }>[],
scopeHref: string,
): readonly string[] {
let base: URL;
try {
base = new URL(scopeHref);
} catch {
return [];
}
const canonical: string[] = [];
for (const asset of assets) {
try {
const absolute = new URL(asset.url, base);
if (absolute.origin !== base.origin) continue;
canonical.push(absolute.href);
} catch {
// A manifest URL that cannot be canonicalized is never classified.
}
}
return canonical;
}
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");
/**
* SW-URL-01. The generated manifest stores root-relative URLs while `Request`
* exposes absolute ones, so comparing the two directly classified every
* verified asset as a network fallback. Canonicalize once against the
* registration scope, re-check same-origin, and share that identity across
* install cache keys, fetch classification and cache lookup or delete.
*/
const manifestUrls: ReadonlySet<string> = Object.freeze(
new Set(
staticEnabled
? canonicalManifestUrls(
config.manifest?.assets ?? [],
scope.registrationScope,
)
: [],
),
);
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;
// SW-01. Only the current release cache may answer. A CacheStorage-wide
// match could return a previous release's response for the same URL, and
// the subsequent delete would then target a cache that was never read.
const currentCacheName = config.manifest
? staticCacheName(config.manifest.setDigest)
: null;
if (!currentCacheName) return null;
// SW-RR-04. Both `open` and `match` are storage calls that can throw
// synchronously or reject. Either one escaping here rejects `respondWith`
// itself, so the entry never reaches its network fallback and the page
// gets a network error instead of the live response.
let cached: Response | undefined;
let currentCache: Cache;
try {
currentCache = await scope.caches.open(currentCacheName);
cached = await currentCache.match(request.url);
} catch {
return null;
}
if (!cached) return null;
if (cached.status !== 200 || cached.type === "opaque") {
// §18.6. An invalid hit is deleted from the cache it was read from and
// treated as a release mismatch.
await currentCache.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) {
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
return "REJECTED";
}
// SW-08. `skipWaiting()` is the activation commit. It must succeed before
// any client is told the activation was accepted, and its failure is a
// rejection rather than an accepted-then-failed activation.
try {
await scope.skipWaiting();
} catch {
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
return "REJECTED";
}
// Post-commit notifications are per-client best effort.
notifyClients(clients, "ACTIVATE_ACCEPTED", parsed.message.sourceBuildId, nonce);
notifyClients(
clients,
"ACTIVATED_RELOAD_REQUIRED",
parsed.message.sourceBuildId,
nonce,
);
return "ACCEPTED";
}
/**
* SW-08. One client's `postMessage()` throwing must not break the whole
* activation event; delivery is isolated per client.
*/
function notifyClients(
clients: readonly WorkerClientLike[],
kind: "ACTIVATE_REJECTED" | "ACTIVATE_ACCEPTED" | "ACTIVATED_RELOAD_REQUIRED",
targetBuildId: string,
nonce: string,
): void {
for (const client of clients) {
try {
client.postMessage(
createServiceWorkerMessage({
kind,
sourceBuildId: config.identity.buildId,
targetBuildId,
nonce,
}),
);
} catch {
// A dead client cannot change the already committed activation.
}
}
}
async function drainClients(
clients: readonly WorkerClientLike[],
nonce: string,
requesterBuildId: string,
): Promise<boolean> {
// SW-07. No in-scope client means nothing dirty to drain, so the set is
// vacuously drained. A `clients.matchAll()` failure still rejects upstream.
if (clients.length === 0) return true;
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,
}),
);
});
// SW-08. A client that cannot receive the drain request can never
// acknowledge it, so it fails immediately instead of holding the pending
// state until the timeout.
for (const client of clients) {
try {
client.postMessage(
createServiceWorkerMessage({
kind: "CLIENT_DRAIN_REQUEST",
sourceBuildId: config.identity.buildId,
targetBuildId: requesterBuildId,
nonce,
}),
);
} catch {
const pending = pendingActivations.get(nonce);
if (pending) settlePendingActivation(nonce, pending, false);
return await drained;
}
}
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) {
// SW-02. Exact ownership only: a prefix match would also delete
// `ca-static-v1-not-owned` and any longer-suffixed foreign cache.
if (!isOwnedStaticCacheName(name)) 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;
}
}
/**
* SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the
* reader as soon as that byte arrives, and fails closed on invalid UTF-8. The
* reader is also raced against a deadline so a stream that never produces a
* chunk cannot hold `activate` open.
*/
const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000;
async function readBoundedMarkerText(
response: Response,
): Promise<string | null> {
if (!response.body) {
try {
const text = await response.text();
return new TextEncoder().encode(text).byteLength >
ACTIVATION_MARKER_MAX_BYTES
? null
: text;
} catch {
return null;
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<"DEADLINE">((resolve) => {
timer = setTimeout(
() => resolve("DEADLINE"),
ACTIVATION_MARKER_READ_DEADLINE_MS,
);
});
try {
for (;;) {
const next = await Promise.race([reader.read(), deadline]);
if (next === "DEADLINE") return null;
if (next.done) break;
if (!next.value) continue;
total += next.value.byteLength;
if (total > ACTIVATION_MARKER_MAX_BYTES) return null;
chunks.push(next.value);
}
} catch {
return null;
} finally {
if (timer !== undefined) clearTimeout(timer);
// Never awaited: cancelling a stream whose source ignores cancellation can
// itself hang, and the marker read already has its answer.
void reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
}
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;
}
// SW-RR-01. A Content-Length is a claim, not a bound. Without one the
// previous `response.text()` read the whole body, so a large or
// non-terminating stream could consume the activation step indefinitely.
const text = await readBoundedMarkerText(response);
if (text === null) 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;
}
}