fix: make Service Worker cache and removal outcomes truthful
SW-URL-01: canonicalize each generated root-relative manifest URL against the registration scope once, re-check same-origin, and share that absolute identity across install cache keys, fetch classification and cache lookup or delete. Previously every verified asset fell through to the network. SW-01: serve verified static requests only from the current release cache. A CacheStorage-wide match could return a previous release's response for the same URL while the delete targeted a cache that was never read. The worker scope facade no longer exposes a wide match at all. SW-02: cache reset deletes only names that parse as owned, so a foreign cache sharing the ca-static-v1- prefix survives. SW-03: unregister() resolving to false is a FAILED unregister, not UNREGISTERED. SW-04: staged removal reports what happened - ABSENT, UNREGISTERED and PURGED map to DISABLED, OWNERSHIP_MISMATCH to INCOMPATIBLE and FAILED to FAILED - so a later release cannot delete the worker while a registration or owned cache is still present. SW-05: add the runtime-neutral service-worker-static-manifest codec that owns exact row keys, the extension and content-type allowlist, the root-relative URL rule and the length-prefixed canonical bytes. The generator and the build gate hash those same bytes, and the build gate now decodes and recomputes the set digest instead of type-casting the manifest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc4e875c2d
commit
58efe6ddbd
@@ -40,7 +40,8 @@ const scope: WorkerScopeLike = {
|
||||
open: (name) => caches.open(name),
|
||||
keys: () => caches.keys(),
|
||||
delete: (name) => caches.delete(name),
|
||||
match: (request) => caches.match(request),
|
||||
// SW-01. No CacheStorage-wide match: only the current release cache may
|
||||
// answer a verified static request.
|
||||
},
|
||||
clients: {
|
||||
matchAll: (options) =>
|
||||
|
||||
@@ -33,7 +33,6 @@ export type WorkerScopeLike = Readonly<{
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
match(request: string): Promise<Response | undefined>;
|
||||
}>;
|
||||
clients: Readonly<{
|
||||
matchAll(
|
||||
@@ -59,6 +58,34 @@ export type WorkerRuntimeConfig = Readonly<{
|
||||
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;
|
||||
@@ -73,8 +100,22 @@ export function createServiceWorkerRuntime(
|
||||
config: WorkerRuntimeConfig,
|
||||
) {
|
||||
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
|
||||
const manifestUrls = new Set(
|
||||
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
|
||||
/**
|
||||
* 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<{
|
||||
@@ -176,17 +217,25 @@ export function createServiceWorkerRuntime(
|
||||
});
|
||||
if (classification !== "VERIFIED_CACHE_FIRST") return null;
|
||||
|
||||
const cached = await scope.caches.match(request.url);
|
||||
// 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;
|
||||
let currentCache: Cache;
|
||||
try {
|
||||
currentCache = await scope.caches.open(currentCacheName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const cached = await currentCache.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);
|
||||
}
|
||||
// §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;
|
||||
@@ -347,7 +396,9 @@ export function createServiceWorkerRuntime(
|
||||
let cachesDeleted = 0;
|
||||
const names = await scope.caches.keys();
|
||||
for (const name of names) {
|
||||
if (!name.startsWith("ca-static-v1-")) continue;
|
||||
// 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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
type InstalledServiceWorkerSelection,
|
||||
type ServiceWorkerActivationOutcome,
|
||||
type ServiceWorkerRemovalOutcome,
|
||||
type ServiceWorkerResetOutcome,
|
||||
type ServiceWorkerRuntimeHost,
|
||||
type ServiceWorkerStartOutcome,
|
||||
@@ -75,6 +76,29 @@ export function createServiceWorkerPageController(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-04. Staged removal reports what actually happened.
|
||||
*
|
||||
* Returning DISABLED for every outcome let a later release delete the worker
|
||||
* source and handlers while a registration or an owned cache was still
|
||||
* present, or while the registration belonged to someone else.
|
||||
*/
|
||||
function removalStartOutcome(
|
||||
outcome: ServiceWorkerRemovalOutcome,
|
||||
failureReason: string,
|
||||
): ServiceWorkerStartOutcome {
|
||||
switch (outcome.kind) {
|
||||
case "ABSENT":
|
||||
case "UNREGISTERED":
|
||||
case "PURGED":
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
case "OWNERSHIP_MISMATCH":
|
||||
return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
||||
case "FAILED":
|
||||
return failed(failureReason);
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<ServiceWorkerStartOutcome> {
|
||||
if (stopped) return failed("STOPPED");
|
||||
const container = dependencies.container;
|
||||
@@ -90,8 +114,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("disable_cleanup", outcome.kind);
|
||||
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED");
|
||||
}
|
||||
|
||||
const selection = dependencies.selection;
|
||||
@@ -108,7 +131,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("remove_registration", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "REMOVE_FAILED");
|
||||
}
|
||||
if (selection.mode === "PURGE_OWNED_RESOURCES") {
|
||||
const outcome = await purgeOwnedResources({
|
||||
@@ -118,7 +141,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("purge_owned_resources", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "PURGE_FAILED");
|
||||
}
|
||||
|
||||
// §17.5. StrictMode's repeated effect returns the same in-flight promise
|
||||
|
||||
@@ -109,14 +109,24 @@ export async function removeOwnedRegistration(
|
||||
) {
|
||||
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
|
||||
}
|
||||
let unregistered: boolean;
|
||||
try {
|
||||
await registration.unregister();
|
||||
unregistered = await registration.unregister();
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
// SW-03. `unregister()` resolving is not success: `false` means the
|
||||
// registration is still installed, so reporting UNREGISTERED would let a
|
||||
// later release delete the worker source while it is still controlling.
|
||||
if (!unregistered) {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ kind: "UNREGISTERED" as const });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { SERVICE_WORKER_BOUNDS } from "./service-worker.ts";
|
||||
|
||||
/**
|
||||
* SW-05. Runtime-neutral static manifest codec.
|
||||
*
|
||||
* The generator, the Node build gate and the Service Worker all need the same
|
||||
* answer to "is this manifest exactly the one that was generated?". This module
|
||||
* owns the exact row keys, the content-type and extension allowlist, the
|
||||
* root-relative URL rule and the length-prefixed canonical byte serialization.
|
||||
*
|
||||
* It deliberately contains no digest implementation: the generator and build
|
||||
* gate hash these bytes with Node SHA-256 while the worker hashes the very same
|
||||
* bytes with injected WebCrypto, so `node:crypto` never reaches worker code and
|
||||
* the algorithm is never written twice.
|
||||
*/
|
||||
|
||||
export type StaticAssetRow = Readonly<{
|
||||
url: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
contentType: string;
|
||||
}>;
|
||||
|
||||
export type StaticAssetManifest = Readonly<{
|
||||
schemaVersion: 1;
|
||||
buildId: string;
|
||||
releaseId: string;
|
||||
setDigest: string;
|
||||
assets: readonly StaticAssetRow[];
|
||||
}>;
|
||||
|
||||
export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1";
|
||||
|
||||
/** Exact allowed extension → content type pairs for a cacheable asset. */
|
||||
export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly<
|
||||
Record<string, string>
|
||||
> = Object.freeze({
|
||||
".css": "text/css",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".woff2": "font/woff2",
|
||||
});
|
||||
|
||||
const MANIFEST_KEYS = Object.freeze([
|
||||
"assets",
|
||||
"buildId",
|
||||
"releaseId",
|
||||
"schemaVersion",
|
||||
"setDigest",
|
||||
] as const);
|
||||
const ASSET_ROW_KEYS = Object.freeze([
|
||||
"bytes",
|
||||
"contentType",
|
||||
"sha256",
|
||||
"url",
|
||||
] as const);
|
||||
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
||||
/** Root-relative, hashed, no dot segments, no query and no fragment. */
|
||||
const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u;
|
||||
|
||||
export type StaticManifestDecodeFailure = Readonly<{
|
||||
reason: string;
|
||||
}>;
|
||||
|
||||
export type StaticManifestDecodeResult =
|
||||
| Readonly<{ ok: true; manifest: StaticAssetManifest }>
|
||||
| Readonly<{ ok: false; error: StaticManifestDecodeFailure }>;
|
||||
|
||||
function exactKeys(
|
||||
value: unknown,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Object.getOwnPropertySymbols(record).length > 0) return null;
|
||||
const keys = Object.keys(record).sort();
|
||||
return keys.length === allowed.length &&
|
||||
keys.every((key, index) => key === allowed[index])
|
||||
? record
|
||||
: null;
|
||||
}
|
||||
|
||||
function extensionOf(url: string): string {
|
||||
const lastSlash = url.lastIndexOf("/");
|
||||
const base = url.slice(lastSlash + 1);
|
||||
const dot = base.lastIndexOf(".");
|
||||
return dot < 0 ? "" : base.slice(dot).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a generated manifest with every row rule applied. It does not verify
|
||||
* `setDigest`; callers pair it with their own digest implementation over
|
||||
* `canonicalStaticManifestBytes`.
|
||||
*/
|
||||
export function decodeStaticAssetManifest(
|
||||
value: unknown,
|
||||
): StaticManifestDecodeResult {
|
||||
const record = exactKeys(value, MANIFEST_KEYS);
|
||||
if (!record) return failure("manifest keys are not exact");
|
||||
if (record.schemaVersion !== 1) return failure("schemaVersion must be 1");
|
||||
if (
|
||||
typeof record.buildId !== "string" ||
|
||||
!IDENTITY.test(record.buildId) ||
|
||||
typeof record.releaseId !== "string" ||
|
||||
!IDENTITY.test(record.releaseId)
|
||||
) {
|
||||
return failure("buildId or releaseId is invalid");
|
||||
}
|
||||
if (typeof record.setDigest !== "string" || !DIGEST.test(record.setDigest)) {
|
||||
return failure("setDigest is not a lower-hex sha256");
|
||||
}
|
||||
if (!Array.isArray(record.assets)) return failure("assets must be an array");
|
||||
if (record.assets.length > SERVICE_WORKER_BOUNDS.assets) {
|
||||
return failure("asset count exceeds its bound");
|
||||
}
|
||||
|
||||
const rows: StaticAssetRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
let previousUrl: string | null = null;
|
||||
for (const candidate of record.assets) {
|
||||
const row = exactKeys(candidate, ASSET_ROW_KEYS);
|
||||
if (!row) return failure("asset row keys are not exact");
|
||||
const { url, sha256, bytes, contentType } = row;
|
||||
if (typeof url !== "string" || !ASSET_URL.test(url)) {
|
||||
return failure("asset url must be root-relative without dot segments");
|
||||
}
|
||||
if (url.includes("/../") || url.includes("/./")) {
|
||||
return failure("asset url must not contain dot segments");
|
||||
}
|
||||
if (seen.has(url)) return failure("asset urls must be unique");
|
||||
// A sorted set makes the canonical bytes independent of directory order.
|
||||
if (previousUrl !== null && url <= previousUrl) {
|
||||
return failure("asset urls must be sorted");
|
||||
}
|
||||
if (typeof sha256 !== "string" || !DIGEST.test(sha256)) {
|
||||
return failure("asset sha256 is not a lower-hex sha256");
|
||||
}
|
||||
if (
|
||||
typeof bytes !== "number" ||
|
||||
!Number.isSafeInteger(bytes) ||
|
||||
bytes < 0 ||
|
||||
bytes > SERVICE_WORKER_BOUNDS.singleAssetBytes
|
||||
) {
|
||||
return failure("asset byte length is invalid");
|
||||
}
|
||||
if (typeof contentType !== "string") {
|
||||
return failure("asset content type is invalid");
|
||||
}
|
||||
const expectedContentType =
|
||||
CACHEABLE_ASSET_CONTENT_TYPES[extensionOf(url)];
|
||||
if (!expectedContentType || expectedContentType !== contentType) {
|
||||
return failure("asset extension and content type do not match");
|
||||
}
|
||||
totalBytes += bytes;
|
||||
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
|
||||
return failure("asset set exceeds its byte bound");
|
||||
}
|
||||
seen.add(url);
|
||||
previousUrl = url;
|
||||
rows.push(Object.freeze({ url, sha256, bytes, contentType }));
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
manifest: Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
buildId: record.buildId,
|
||||
releaseId: record.releaseId,
|
||||
setDigest: record.setDigest,
|
||||
assets: Object.freeze(rows),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact bytes both the Node generator and the worker hash. A reordered
|
||||
* directory listing, a renamed field or a changed byte length all change these
|
||||
* bytes; nothing else does.
|
||||
*/
|
||||
export function canonicalStaticManifestBytes(
|
||||
assets: readonly StaticAssetRow[],
|
||||
): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [encoder.encode(`${STATIC_ASSET_SET_DOMAIN}\0`)];
|
||||
for (const asset of assets) {
|
||||
parts.push(lengthPrefixed(encoder, asset.url));
|
||||
parts.push(lengthPrefixed(encoder, asset.sha256));
|
||||
parts.push(lengthPrefixed(encoder, String(asset.bytes)));
|
||||
parts.push(lengthPrefixed(encoder, asset.contentType));
|
||||
}
|
||||
let total = 0;
|
||||
for (const part of parts) total += part.byteLength;
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
bytes.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function lengthPrefixed(encoder: TextEncoder, value: string): Uint8Array {
|
||||
const encoded = encoder.encode(value);
|
||||
const prefix = encoder.encode(`${encoded.byteLength}:`);
|
||||
const combined = new Uint8Array(prefix.byteLength + encoded.byteLength);
|
||||
combined.set(prefix, 0);
|
||||
combined.set(encoded, prefix.byteLength);
|
||||
return combined;
|
||||
}
|
||||
|
||||
function failure(reason: string): StaticManifestDecodeResult {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
error: Object.freeze({ reason }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user