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,170 @@
/// <reference lib="webworker" />
import { OFFLINE_SYNC_TAG } from "../../contracts/offline-command.ts";
import type {
ServiceWorkerHandlerId,
ServiceWorkerProtocolIdentity,
StaticAssetManifestV1,
} from "../../contracts/service-worker.ts";
import {
createServiceWorkerRuntime,
type WorkerScopeLike,
} from "./service-worker-lifecycle.ts";
import { parseServiceWorkerMessage } from "./service-worker-protocol.ts";
/**
* §17.1. The one physical worker entry for this scope.
*
* PWA lifecycle, verified static asset fetch, Web Push and the optional sync
* wake-up are all handler factories inside this single entry. A second
* registration for any of them is prohibited.
*
* This module is compiled only by `vite.service-worker.config.ts` when the
* static selection is `ACTIVE`; it is never part of the page bundle.
*/
declare const self: ServiceWorkerGlobalScope;
// Build-time virtual modules (§18.3). They resolve through the Service Worker
// Vite config only, so the page bundle can never import a worker asset list.
declare const __CA_SERVICE_WORKER_BUILD_INFO__: ServiceWorkerProtocolIdentity;
declare const __CA_SERVICE_WORKER_ASSETS__: StaticAssetManifestV1 | null;
declare const __CA_SERVICE_WORKER_HANDLERS__: readonly ServiceWorkerHandlerId[];
declare const __CA_RUNTIME_CONFIG_URL__: string;
declare const __CA_RELEASE_MANIFEST_URL__: string;
const identity = __CA_SERVICE_WORKER_BUILD_INFO__;
const handlers = __CA_SERVICE_WORKER_HANDLERS__;
const scope: WorkerScopeLike = {
caches: {
open: (name) => caches.open(name),
keys: () => caches.keys(),
delete: (name) => caches.delete(name),
match: (request) => caches.match(request),
},
clients: {
matchAll: (options) =>
self.clients.matchAll(
options as { type?: "window"; includeUncontrolled?: boolean },
) as Promise<
readonly {
id: string;
url: string;
postMessage(m: unknown): void;
}[]
>,
},
registrationScope: self.registration.scope,
skipWaiting: () => self.skipWaiting(),
fetcher: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
async digest(bytes) {
const buffer = await crypto.subtle.digest(
"SHA-256",
bytes.slice().buffer as ArrayBuffer,
);
let hex = "";
for (const byte of new Uint8Array(buffer)) {
hex += byte.toString(16).padStart(2, "0");
}
return `sha256:${hex}`;
},
};
const runtime = createServiceWorkerRuntime(scope, {
identity,
handlers,
manifest: __CA_SERVICE_WORKER_ASSETS__,
runtimeConfigUrl: __CA_RUNTIME_CONFIG_URL__,
releaseManifestUrl: __CA_RELEASE_MANIFEST_URL__,
});
self.addEventListener("install", (event) => {
// §17.10. Install never calls skipWaiting(); activation is a page handshake.
event.waitUntil(runtime.onInstall());
});
self.addEventListener("activate", (event) => {
// §17.12. No clients.claim() in the baseline.
event.waitUntil(runtime.onActivate());
});
self.addEventListener("fetch", (event) => {
const request = event.request;
event.respondWith(
runtime
.onFetch({
method: request.method,
url: request.url,
mode: request.mode,
})
.then((cached) => cached ?? fetch(request)),
);
});
self.addEventListener("message", (event) => {
const parsed = parseServiceWorkerMessage(event.data);
if (!parsed.ok) return;
if (parsed.message.kind === "ACTIVATE_REQUEST") {
event.waitUntil(runtime.onActivateRequest(event.data));
return;
}
if (
parsed.message.kind === "CLIENT_DRAINED" ||
parsed.message.kind === "ACTIVATE_REJECTED"
) {
const source = event.source;
if (source && "id" in source && typeof source.id === "string") {
runtime.onClientMessage(event.data, source.id);
}
return;
}
if (parsed.message.kind === "CACHE_RESET_REQUEST") {
const source = event.source;
if (
source &&
"id" in source &&
typeof source.id === "string" &&
"postMessage" in source &&
typeof source.postMessage === "function"
) {
event.waitUntil(runtime.onCacheResetRequest(event.data, source));
}
}
});
// §17.1 WEB_PUSH composition point.
//
// The Web Push runtime is a handler factory inside this one entry, never a
// second registration. It is not wired here because the template cannot supply
// the two product-owned inputs it needs: a PushAssociationFenceStore over the
// product push control repository, and a WebPushNotificationRegistry of exact
// notification types with the same-origin routes their clicks may open
// (§21.11). Selecting WEB_PUSH means adding, inside a
// `handlers.includes("WEB_PUSH")` guard: import
// createWebPushServiceWorkerRuntime and createServiceWorkerScopeHost from the
// sibling web-push adapter, then call the runtime with the scope host built
// from `self` plus the product fence store and notification registry.
//
// Keeping the import out of the baseline entry is also what lets the realtime
// and Web Push runtime be removed as a pure file deletion (§24.12).
if (handlers.includes("OFFLINE_SYNC_WAKEUP")) {
// §19.18. Wake-up only: the handler records that a sync fired and notifies
// controlled clients. It never sends an authenticated command (§19.20).
self.addEventListener("sync", (rawEvent: Event) => {
const event = rawEvent as ExtendableEvent & { tag?: string };
if (event.tag !== OFFLINE_SYNC_TAG) return;
event.waitUntil(
self.clients.matchAll({ type: "window" }).then((clients) => {
for (const client of clients) {
client.postMessage({
protocolVersion: 1,
kind: "SYNC_WAKE_OBSERVED",
messageId: crypto.randomUUID(),
sourceBuildId: identity.buildId,
});
}
}),
);
});
}
@@ -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;
}
}
@@ -0,0 +1,399 @@
import {
SERVICE_WORKER_BOUNDS,
type InstalledServiceWorkerSelection,
type ServiceWorkerActivationOutcome,
type ServiceWorkerResetOutcome,
type ServiceWorkerRuntimeHost,
type ServiceWorkerStartOutcome,
} from "../../contracts/service-worker.ts";
import {
createNonceRegistry,
createServiceWorkerMessage,
parseServiceWorkerMessage,
} from "./service-worker-protocol.ts";
import {
expectedServiceWorkerUrls,
isOwnedRegistration,
purgeOwnedResources,
removeOwnedRegistration,
} from "./service-worker-removal.ts";
/**
* §17.5–§17.16. The page-side controller.
*
* Registration happens after Runtime Config, release and contract set have all
* validated and the first React effect has committed. The controller never
* calls `skipWaiting()` blindly and never calls `clients.claim()`.
*/
export type ActivationBlocker = () => boolean;
export type PageControllerDependencies = Readonly<{
selection: InstalledServiceWorkerSelection | null;
/** True when static selection is ACTIVE but Runtime Config disabled it. */
disabledCleanup: boolean;
routerBasePath: string;
origin: string;
buildId: string;
container?: ServiceWorkerContainer;
caches?: CacheStorage;
/** §17.10. Any blocker returning true rejects automatic activation. */
blockers?: readonly ActivationBlocker[];
now?: () => number;
observe?: (observation: Readonly<{ event: string; outcome: string }>) => void;
}>;
export function createServiceWorkerPageController(
dependencies: PageControllerDependencies,
): ServiceWorkerRuntimeHost {
const nonces = createNonceRegistry();
const now = dependencies.now ?? (() => Date.now());
const urls = expectedServiceWorkerUrls(
dependencies.routerBasePath,
dependencies.origin,
);
let registrationPromise: Promise<ServiceWorkerRegistration> | null = null;
let registration: ServiceWorkerRegistration | null = null;
let messageListener: ((event: MessageEvent) => void) | null = null;
let updateTimer: ReturnType<typeof setInterval> | null = null;
let stopped = false;
const pendingStops = new Set<() => void>();
const observe = (event: string, outcome: string) =>
dependencies.observe?.({ event, outcome });
function isBlocked(): boolean {
for (const blocker of dependencies.blockers ?? []) {
try {
if (blocker()) return true;
} catch {
// A defective blocker is treated as blocking: never activate on doubt.
return true;
}
}
return false;
}
async function start(): Promise<ServiceWorkerStartOutcome> {
if (stopped) return failed("STOPPED");
const container = dependencies.container;
// §3.6 / §17.6. Static ACTIVE plus runtime DISABLED performs exactly one
// owned-registration lookup and at most one unregister. No new register, no
// cache deletion, no message or update timer.
if (dependencies.disabledCleanup) {
if (!container) return Object.freeze({ kind: "DISABLED" as const });
const outcome = await removeOwnedRegistration({
container,
routerBasePath: dependencies.routerBasePath,
origin: dependencies.origin,
});
observe("disable_cleanup", outcome.kind);
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
return Object.freeze({ kind: "DISABLED" as const });
}
const selection = dependencies.selection;
// §3.6 / §17.3 `null`: zero registration lookups and zero Cache Storage
// access. The controller must not even probe.
if (!selection) return Object.freeze({ kind: "DISABLED" as const });
if (!container) return Object.freeze({ kind: "INCOMPATIBLE" as const });
if (selection.mode === "REMOVE_REGISTRATION") {
const outcome = await removeOwnedRegistration({
container,
routerBasePath: dependencies.routerBasePath,
origin: dependencies.origin,
});
observe("remove_registration", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
}
if (selection.mode === "PURGE_OWNED_RESOURCES") {
const outcome = await purgeOwnedResources({
container,
...(dependencies.caches ? { caches: dependencies.caches } : {}),
routerBasePath: dependencies.routerBasePath,
origin: dependencies.origin,
});
observe("purge_owned_resources", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
}
// §17.5. StrictMode's repeated effect returns the same in-flight promise
// instead of issuing a second registration.
registrationPromise ??= container.register(urls.scriptHref, {
scope: urls.scopePath,
type: "module",
updateViaCache: "none",
});
let installedRegistration: ServiceWorkerRegistration;
try {
installedRegistration = await registrationPromise;
} catch {
registrationPromise = null;
observe("register", "FAILED");
return failed("REGISTRATION_FAILED");
}
if (stopped) return failed("STOPPED");
registration = installedRegistration;
if (
!isOwnedRegistration({
registration,
expectedScopeHref: urls.scopeHref,
expectedScriptHref: urls.scriptHref,
})
) {
observe("register", "OWNERSHIP_MISMATCH");
return Object.freeze({ kind: "INCOMPATIBLE" as const });
}
attachMessageListener(container);
scheduleUpdateChecks();
if (registration.waiting) {
observe("register", "UPDATE_WAITING");
return Object.freeze({ kind: "UPDATE_WAITING" as const });
}
// §17.13. Without `clients.claim()` the first install leaves this page
// uncontrolled. That is reported, never silently reloaded.
if (registration.active && !container.controller) {
observe("register", "RELOAD_TO_ENABLE");
return Object.freeze({ kind: "RELOAD_TO_ENABLE" as const });
}
observe("register", "ACTIVE");
return Object.freeze({
kind: "ACTIVE" as const,
buildId: dependencies.buildId,
});
}
function attachMessageListener(container: ServiceWorkerContainer): void {
if (messageListener) return;
messageListener = (event: MessageEvent) => {
if (event.origin && event.origin !== dependencies.origin) return;
const parsed = parseServiceWorkerMessage(event.data);
if (!parsed.ok) {
observe("message", parsed.code);
return;
}
if (
parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== dependencies.buildId
) {
observe("message", "TARGET_BUILD_MISMATCH");
return;
}
if (parsed.message.kind === "CLIENT_DRAIN_REQUEST") {
const nonce = parsed.message.nonce;
const source = event.source;
if (!nonce || !canPostMessage(source)) {
observe("client_drain", "MALFORMED");
return;
}
const rejected = isBlocked();
source.postMessage(
createServiceWorkerMessage({
kind: rejected ? "ACTIVATE_REJECTED" : "CLIENT_DRAINED",
sourceBuildId: dependencies.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
observe("client_drain", rejected ? "BLOCKED" : "DRAINED");
return;
}
observe("message", parsed.message.kind);
};
container.addEventListener("message", messageListener);
}
function scheduleUpdateChecks(): void {
// §17.14. At most one check per 6 hours, and none while the page is hidden.
if (updateTimer) return;
updateTimer = setInterval(() => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return;
}
void registration?.update().catch(() => {
// A failed update check never fails a product flow.
});
}, SERVICE_WORKER_BOUNDS.updateCheckIntervalMs);
}
/**
* §17.11. Activation is a handshake: every controlled client must close new
* admission and acknowledge within 30s. One missing client rejects it.
*/
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
const waiting = registration?.waiting;
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
if (isBlocked()) {
observe("activation", "BLOCKED_DIRTY_CLIENT");
return Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const });
}
const nonce = nonces.issue();
const deadline = now() + SERVICE_WORKER_BOUNDS.clientDrainMs;
const accepted = await new Promise<ServiceWorkerActivationOutcome>(
(resolve) => {
const container = dependencies.container;
if (!container) {
resolve(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
return;
}
let settled = false;
const finish = (outcome: ServiceWorkerActivationOutcome) => {
if (settled) return;
settled = true;
nonces.consume(nonce);
clearTimeout(timer);
container.removeEventListener("message", onMessage);
pendingStops.delete(onStop);
resolve(outcome);
};
const onMessage = (event: MessageEvent) => {
const parsed = parseServiceWorkerMessage(event.data);
if (!parsed.ok) return;
if (
parsed.message.targetBuildId !== undefined &&
parsed.message.targetBuildId !== dependencies.buildId
) {
return;
}
if (
parsed.message.kind === "ACTIVATE_REJECTED" &&
parsed.message.nonce === nonce
) {
finish(Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const }));
return;
}
if (
parsed.message.kind === "ACTIVATED_RELOAD_REQUIRED" &&
parsed.message.nonce === nonce
) {
finish(
Object.freeze({ kind: "ACTIVATED_RELOAD_REQUIRED" as const }),
);
}
};
const onStop = () =>
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
const timer = setTimeout(
() => finish(Object.freeze({ kind: "CLIENT_DRAIN_TIMEOUT" as const })),
Math.max(0, deadline - now()),
);
pendingStops.add(onStop);
container.addEventListener("message", onMessage);
try {
waiting.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_REQUEST",
sourceBuildId: dependencies.buildId,
nonce,
}),
);
} catch {
finish(Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }));
}
},
);
observe("activation", accepted.kind);
return accepted;
}
/** §18.10. Static caches only; the registration itself is left in place. */
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
const container = dependencies.container;
if (!container?.controller) {
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
}
const nonce = nonces.issue();
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
let settled = false;
const finish = (outcome: ServiceWorkerResetOutcome) => {
if (settled) return;
settled = true;
nonces.consume(nonce);
clearTimeout(timer);
container.removeEventListener("message", onMessage);
pendingStops.delete(onStop);
resolve(outcome);
};
const onMessage = (event: MessageEvent) => {
if (event.origin && event.origin !== dependencies.origin) return;
const parsed = parseServiceWorkerMessage(event.data);
if (
!parsed.ok ||
parsed.message.kind !== "CACHE_RESET_RESULT" ||
parsed.message.targetBuildId !== dependencies.buildId ||
parsed.message.nonce !== nonce
) {
return;
}
finish(
Object.freeze({
kind: "RESET" as const,
cachesDeleted: parsed.message.cachesDeleted ?? 0,
}),
);
};
const onStop = () =>
finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" }));
const timer = setTimeout(
() =>
finish(
Object.freeze({ kind: "FAILED" as const, code: "RESET_TIMEOUT" }),
),
SERVICE_WORKER_BOUNDS.clientDrainMs,
);
pendingStops.add(onStop);
container.addEventListener("message", onMessage);
try {
container.controller?.postMessage(
createServiceWorkerMessage({
kind: "CACHE_RESET_REQUEST",
sourceBuildId: dependencies.buildId,
nonce,
}),
);
observe("cache_reset", "REQUESTED");
} catch {
finish(
Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }),
);
}
});
}
/** §17.16. Ordinary shutdown removes listeners and timers; it never unregisters. */
async function stop(): Promise<void> {
stopped = true;
for (const stopPending of [...pendingStops]) stopPending();
pendingStops.clear();
if (updateTimer) {
clearInterval(updateTimer);
updateTimer = null;
}
if (messageListener && dependencies.container) {
dependencies.container.removeEventListener("message", messageListener);
messageListener = null;
}
nonces.clear();
}
return Object.freeze({ start, requestActivation, resetOwnedCaches, stop });
}
function canPostMessage(
source: MessageEventSource | null,
): source is MessageEventSource & { postMessage(message: unknown): void } {
return !!source && typeof source.postMessage === "function";
}
function failed(code: string): ServiceWorkerStartOutcome {
return Object.freeze({ kind: "FAILED" as const, code });
}
@@ -0,0 +1,178 @@
import {
SERVICE_WORKER_PROTOCOL_VERSION,
type ServiceWorkerMessage,
type ServiceWorkerMessageKind,
} from "../../contracts/service-worker.ts";
/**
* §17.8. Message protocol shared by the page controller and the worker entry.
*
* Only structural types are used here: this module is compiled into both the
* DOM realm and the WebWorker realm, so it must not reference a global from
* either one.
*/
const MESSAGE_KINDS: ReadonlySet<string> = new Set<ServiceWorkerMessageKind>([
"PAGE_HELLO",
"WORKER_HELLO_ACK",
"UPDATE_READY",
"ACTIVATE_REQUEST",
"ACTIVATE_ACCEPTED",
"ACTIVATE_REJECTED",
"CLIENT_DRAIN_REQUEST",
"CLIENT_DRAINED",
"ACTIVATED_RELOAD_REQUIRED",
"CACHE_RESET_REQUEST",
"CACHE_RESET_RESULT",
"SYNC_WAKE_OBSERVED",
]);
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
export type ParsedMessage =
| Readonly<{ ok: true; message: ServiceWorkerMessage }>
| Readonly<{
ok: false;
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND";
}>;
/**
* Exact key set, exact protocol version, bounded identifiers. Anything else is
* rejected rather than partially interpreted: a postMessage payload is an
* untrusted runtime input (§21.1).
*/
export function parseServiceWorkerMessage(value: unknown): ParsedMessage {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return reject("MALFORMED");
}
const candidate = value as Record<string, unknown>;
const allowed = new Set([
"protocolVersion",
"kind",
"messageId",
"sourceBuildId",
"targetBuildId",
"nonce",
"cachesDeleted",
]);
for (const key of Object.keys(candidate)) {
if (!allowed.has(key)) return reject("MALFORMED");
}
if (candidate.protocolVersion !== SERVICE_WORKER_PROTOCOL_VERSION) {
return reject("PROTOCOL_MISMATCH");
}
if (typeof candidate.kind !== "string" || !MESSAGE_KINDS.has(candidate.kind)) {
return reject("UNKNOWN_KIND");
}
if (
typeof candidate.messageId !== "string" ||
!ID.test(candidate.messageId) ||
typeof candidate.sourceBuildId !== "string" ||
!ID.test(candidate.sourceBuildId)
) {
return reject("MALFORMED");
}
if (
candidate.cachesDeleted !== undefined &&
(!Number.isSafeInteger(candidate.cachesDeleted) ||
(candidate.cachesDeleted as number) < 0 ||
(candidate.cachesDeleted as number) > 1_024)
) {
return reject("MALFORMED");
}
if (
(candidate.kind === "CACHE_RESET_RESULT") !==
(candidate.cachesDeleted !== undefined)
) {
return reject("MALFORMED");
}
if (
candidate.targetBuildId !== undefined &&
(typeof candidate.targetBuildId !== "string" ||
!ID.test(candidate.targetBuildId))
) {
return reject("MALFORMED");
}
if (
candidate.nonce !== undefined &&
(typeof candidate.nonce !== "string" || !ID.test(candidate.nonce))
) {
return reject("MALFORMED");
}
return Object.freeze({
ok: true as const,
message: Object.freeze({
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
kind: candidate.kind as ServiceWorkerMessageKind,
messageId: candidate.messageId,
sourceBuildId: candidate.sourceBuildId,
...(candidate.targetBuildId === undefined
? {}
: { targetBuildId: candidate.targetBuildId }),
...(candidate.nonce === undefined ? {} : { nonce: candidate.nonce }),
...(candidate.cachesDeleted === undefined
? {}
: { cachesDeleted: candidate.cachesDeleted as number }),
}),
});
}
export function createServiceWorkerMessage(
input: Readonly<{
kind: ServiceWorkerMessageKind;
sourceBuildId: string;
targetBuildId?: string;
nonce?: string;
cachesDeleted?: number;
messageId?: string;
}>,
): ServiceWorkerMessage {
return Object.freeze({
protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION,
kind: input.kind,
messageId: input.messageId ?? randomId(),
sourceBuildId: input.sourceBuildId,
...(input.targetBuildId === undefined
? {}
: { targetBuildId: input.targetBuildId }),
...(input.nonce === undefined ? {} : { nonce: input.nonce }),
...(input.cachesDeleted === undefined
? {}
: { cachesDeleted: input.cachesDeleted }),
});
}
/** One-time nonce store. A nonce is consumed on first match and never reused. */
export function createNonceRegistry(maximumEntries = 32) {
const nonces = new Set<string>();
return Object.freeze({
issue(): string {
if (nonces.size >= maximumEntries) {
const oldest = nonces.values().next().value;
if (oldest !== undefined) nonces.delete(oldest);
}
const nonce = randomId();
nonces.add(nonce);
return nonce;
},
consume(nonce: string | undefined): boolean {
if (!nonce || !nonces.has(nonce)) return false;
nonces.delete(nonce);
return true;
},
clear(): void {
nonces.clear();
},
});
}
function randomId(): string {
return crypto.randomUUID();
}
function reject(
code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND",
): ParsedMessage {
return Object.freeze({ ok: false as const, code });
}
@@ -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,
});
}
@@ -0,0 +1,359 @@
import {
isOwnedStaticCacheName,
SERVICE_WORKER_BOUNDS,
staticCacheName,
type StaticAssetManifestV1,
} from "../../contracts/service-worker.ts";
/**
* §17.9 / §18. Static asset install and fetch classification.
*
* Only immutable hashed build assets are cached, all-or-nothing, verified at
* install time. Navigation, runtime config, the release manifest and every API
* response are network-only, and no runtime response is ever written into the
* active cache.
*/
export type FetchClassification =
| "NETWORK_PASSTHROUGH"
| "NETWORK_ONLY"
| "VERIFIED_CACHE_FIRST";
export type ClassificationInput = Readonly<{
method: string;
requestUrl: string;
isNavigation: boolean;
runtimeConfigUrl: string;
releaseManifestUrl: string;
manifestUrls: ReadonlySet<string>;
}>;
/**
* §18.5. Order matters: the exact static hit is evaluated before the generic
* network passthrough, because an API base may legitimately be `/`.
*/
export function classifyFetch(input: ClassificationInput): FetchClassification {
if (input.method !== "GET") return "NETWORK_PASSTHROUGH";
if (input.isNavigation) return "NETWORK_ONLY";
if (
sameResource(input.requestUrl, input.runtimeConfigUrl) ||
sameResource(input.requestUrl, input.releaseManifestUrl)
) {
return "NETWORK_ONLY";
}
if (input.manifestUrls.has(input.requestUrl)) return "VERIFIED_CACHE_FIRST";
return "NETWORK_PASSTHROUGH";
}
function sameResource(left: string, right: string): boolean {
try {
const a = new URL(left);
const b = new URL(right, left);
return a.origin === b.origin && a.pathname === b.pathname;
} catch {
return false;
}
}
export type InstallOutcome =
| Readonly<{ kind: "INSTALLED"; cacheName: string; assets: number }>
| Readonly<{
kind: "REJECTED";
code:
| "MANIFEST_INVALID"
| "ASSET_COUNT_EXCEEDED"
| "ASSET_TOO_LARGE"
| "ASSET_SET_TOO_LARGE"
| "INSTALL_DEADLINE_EXCEEDED"
| "FETCH_FAILED"
| "STATUS_INVALID"
| "CONTENT_TYPE_INVALID"
| "BYTES_MISMATCH"
| "INTEGRITY_MISMATCH"
| "QUOTA_EXCEEDED";
}>;
export type InstallDependencies = Readonly<{
caches: Readonly<{
open(cacheName: string): Promise<Cache>;
delete(cacheName: string): Promise<boolean>;
}>;
fetcher: typeof fetch;
digest(bytes: Uint8Array): Promise<string>;
}>;
export function validateStaticAssetManifest(
manifest: StaticAssetManifestV1,
): InstallOutcome | null {
const bounds = SERVICE_WORKER_BOUNDS;
if (
manifest.schemaVersion !== 1 ||
!/^sha256:[0-9a-f]{64}$/.test(manifest.setDigest)
) {
return rejected("MANIFEST_INVALID");
}
if (manifest.assets.length > bounds.assets) {
return rejected("ASSET_COUNT_EXCEEDED");
}
let total = 0;
for (const asset of manifest.assets) {
if (
!asset.url ||
!/^sha256:[0-9a-f]{64}$/.test(asset.sha256) ||
!Number.isSafeInteger(asset.bytes) ||
asset.bytes < 0
) {
return rejected("MANIFEST_INVALID");
}
if (asset.bytes > bounds.singleAssetBytes) return rejected("ASSET_TOO_LARGE");
total += asset.bytes;
}
if (total > bounds.assetSetBytes) return rejected("ASSET_SET_TOO_LARGE");
return null;
}
/**
* §17.9. A partial candidate is never used: any failure deletes the candidate
* cache and rejects install, leaving the previous verified revision in place.
*/
export async function installStaticAssets(
manifest: StaticAssetManifestV1,
dependencies: InstallDependencies,
): Promise<InstallOutcome> {
const invalid = validateStaticAssetManifest(manifest);
if (invalid) return invalid;
const cacheName = staticCacheName(manifest.setDigest);
const abortController = new AbortController();
let deadlineExceeded = false;
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<InstallOutcome>((resolve) => {
deadlineTimer = setTimeout(() => {
deadlineExceeded = true;
abortController.abort();
resolve(rejected("INSTALL_DEADLINE_EXCEEDED"));
}, SERVICE_WORKER_BOUNDS.installDeadlineMs);
});
const installation = installCandidate(
manifest,
cacheName,
dependencies,
abortController,
);
const raced = await Promise.race([installation, deadline]);
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
const outcome = deadlineExceeded
? rejected("INSTALL_DEADLINE_EXCEEDED")
: raced;
if (outcome.kind === "REJECTED") {
await dependencies.caches.delete(cacheName).catch(() => false);
}
return outcome;
}
async function installCandidate(
manifest: StaticAssetManifestV1,
cacheName: string,
dependencies: InstallDependencies,
abortController: AbortController,
): Promise<InstallOutcome> {
const signal = abortController.signal;
let cache: Cache;
try {
cache = await dependencies.caches.open(cacheName);
} catch {
return rejected("QUOTA_EXCEEDED");
}
const queue = [...manifest.assets];
let failure: InstallOutcome | null = null;
const worker = async (): Promise<void> => {
for (;;) {
if (failure) return;
const asset = queue.shift();
if (!asset) return;
const outcome = await storeAsset(asset, cache, dependencies, signal);
if (outcome) {
failure ??= outcome;
abortController.abort();
return;
}
}
};
await Promise.all(
Array.from({ length: SERVICE_WORKER_BOUNDS.fetchConcurrency }, worker),
);
if (failure) return failure;
return Object.freeze({
kind: "INSTALLED" as const,
cacheName,
assets: manifest.assets.length,
});
}
async function storeAsset(
asset: StaticAssetManifestV1["assets"][number],
cache: Cache,
dependencies: InstallDependencies,
signal: AbortSignal,
): Promise<InstallOutcome | null> {
if (signal.aborted) return rejected("FETCH_FAILED");
let response: Response;
try {
const fetched = await abortable(
dependencies.fetcher(asset.url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
signal,
}),
signal,
);
if (fetched === ABORTED) return rejected("FETCH_FAILED");
response = fetched;
} catch {
return rejected("FETCH_FAILED");
}
if (response.status !== 200 || response.type === "opaque") {
return rejected("STATUS_INVALID");
}
const contentType = response.headers.get("content-type") ?? "";
if (
contentType.split(";", 1)[0]?.trim().toLowerCase() !==
asset.contentType.toLowerCase()
) {
return rejected("CONTENT_TYPE_INVALID");
}
const body = await readBoundedBody(response, asset.bytes, signal);
if (!body.ok) return rejected(body.code);
const bytes = body.bytes;
const digest = await abortable(dependencies.digest(bytes), signal);
if (digest === ABORTED) return rejected("FETCH_FAILED");
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
try {
if (signal.aborted) return rejected("FETCH_FAILED");
await cache.put(
asset.url,
new Response(bytes.slice(), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
);
} catch {
return rejected("QUOTA_EXCEEDED");
}
return null;
}
const ABORTED = Symbol("service-worker-install-aborted");
async function abortable<Value>(
operation: Promise<Value>,
signal: AbortSignal,
): Promise<Value | typeof ABORTED> {
if (signal.aborted) return ABORTED;
let onAbort: (() => void) | undefined;
const aborted = new Promise<typeof ABORTED>((resolve) => {
onAbort = () => resolve(ABORTED);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function readBoundedBody(
response: Response,
expectedBytes: number,
signal: AbortSignal,
): Promise<
| Readonly<{ ok: true; bytes: Uint8Array }>
| Readonly<{ ok: false; code: "BYTES_MISMATCH" | "FETCH_FAILED" }>
> {
const declaredLength = response.headers.get("content-length");
if (
declaredLength !== null &&
/^\d+$/u.test(declaredLength) &&
Number(declaredLength) !== expectedBytes
) {
await response.body?.cancel().catch(() => {});
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
}
if (!response.body) {
return expectedBytes === 0
? Object.freeze({ ok: true as const, bytes: new Uint8Array() })
: Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const result = await abortable(reader.read(), signal);
if (result === ABORTED) {
await reader.cancel().catch(() => {});
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
}
if (result.done) break;
total += result.value.byteLength;
if (total > expectedBytes) {
await reader.cancel().catch(() => {});
return Object.freeze({
ok: false as const,
code: "BYTES_MISMATCH" as const,
});
}
chunks.push(result.value);
}
} catch {
return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const });
} finally {
reader.releaseLock();
}
if (total !== expectedBytes) {
return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const });
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return Object.freeze({ ok: true as const, bytes });
}
/**
* §17.15. Keep the current revision plus exactly one previous verified cache.
* A cache found outside the owned prefix is left alone; a cache holding config,
* manifest or API data is a security violation and is deleted.
*/
export function selectCachesToDelete(
names: readonly string[],
currentCacheName: string,
previousCacheName: string | null,
): readonly string[] {
return Object.freeze(
names.filter(
(name) =>
isOwnedStaticCacheName(name) &&
name !== currentCacheName &&
name !== previousCacheName,
),
);
}
function rejected(code: Extract<InstallOutcome, { kind: "REJECTED" }>["code"]) {
return Object.freeze({ kind: "REJECTED" as const, code });
}