Files
clean-architecture-frontend…/scripts/lib/service-worker-build-input.ts
T
DongHyeonkaandClaude Opus 5 58efe6ddbd 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>
2026-08-14 00:25:11 +09:00

118 lines
3.9 KiB
TypeScript

import { createHash } from "node:crypto";
import {
canonicalStaticManifestBytes,
decodeStaticAssetManifest,
} from "../../src/contracts/service-worker-static-manifest.ts";
import type {
InstalledServiceWorkerSelection,
ServiceWorkerHandlerId,
StaticAssetManifestV1,
} from "../../src/contracts/service-worker.ts";
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
export type ServiceWorkerBuildInput = Readonly<{
assets: StaticAssetManifestV1;
handlers: readonly ServiceWorkerHandlerId[];
contractSetDigest: string;
releaseManifestUrl: string;
}>;
/**
* ACTIVE worker compilation is a release-integrity boundary. Missing generated
* modules, stale identities and placeholder digests are fatal build defects;
* they must never be converted into a worker that merely degrades at runtime.
*/
export function resolveServiceWorkerBuildInput(input: Readonly<{
selection: InstalledServiceWorkerSelection | null;
assets: unknown;
contractSet: unknown;
runtimeConfig: unknown;
buildId: string;
releaseId: string;
}>): ServiceWorkerBuildInput {
if (input.selection?.mode !== "ACTIVE") {
throw new TypeError(
"Service Worker build requires an ACTIVE static selection.",
);
}
if (!Array.isArray(input.selection.handlers)) {
throw new TypeError("Service Worker handlers must be an array.");
}
const handlers = new Set<ServiceWorkerHandlerId>();
for (const handler of input.selection.handlers) {
if (
handler !== "PWA_STATIC_ASSETS" &&
handler !== "OFFLINE_SYNC_WAKEUP" &&
handler !== "WEB_PUSH"
) {
throw new TypeError(`Unknown Service Worker handler: ${String(handler)}.`);
}
if (handlers.has(handler)) {
throw new TypeError(`Duplicate Service Worker handler: ${handler}.`);
}
handlers.add(handler);
}
if (handlers.has("WEB_PUSH")) {
throw new TypeError(
"WEB_PUSH requires an installed product-owned worker contribution.",
);
}
const assets = parseAssets(input.assets);
if (assets.buildId !== input.buildId || assets.releaseId !== input.releaseId) {
throw new TypeError("Generated Service Worker asset identity is stale.");
}
const contractSet = record(input.contractSet);
const contractSetDigest = contractSet?.setDigest;
if (typeof contractSetDigest !== "string" || !DIGEST.test(contractSetDigest)) {
throw new TypeError("Generated contract set digest is invalid.");
}
const runtimeConfig = record(input.runtimeConfig);
const releaseManifestUrl = runtimeConfig?.RELEASE_MANIFEST_URL;
if (
typeof releaseManifestUrl !== "string" ||
releaseManifestUrl.length === 0 ||
releaseManifestUrl.length > 2_048
) {
throw new TypeError("Runtime release manifest URL is invalid.");
}
return Object.freeze({
assets,
handlers: Object.freeze([...handlers]),
contractSetDigest,
releaseManifestUrl,
});
}
/**
* SW-05. The build gate no longer type-casts the manifest. It decodes every row
* through the shared runtime-neutral codec and recomputes the set digest from
* the same canonical bytes the generator hashed, so a tampered row, a reordered
* set or a stale digest fails admission instead of shipping.
*/
function parseAssets(value: unknown): StaticAssetManifestV1 {
const decoded = decodeStaticAssetManifest(value);
if (!decoded.ok) {
throw new TypeError(
`Generated Service Worker asset manifest is invalid: ${decoded.error.reason}`,
);
}
const expected = `sha256:${createHash("sha256")
.update(canonicalStaticManifestBytes(decoded.manifest.assets))
.digest("hex")}`;
if (expected !== decoded.manifest.setDigest) {
throw new TypeError(
"Generated Service Worker asset manifest set digest does not match its assets.",
);
}
return decoded.manifest as unknown as StaticAssetManifestV1;
}
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}