The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
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);
|
|
// SW-09. A non-cooperative fetch, digest or `cache.put` started before the
|
|
// deadline cannot be cancelled, so it may recreate the candidate cache
|
|
// after that delete. The public result already closed at the deadline; a
|
|
// second exact delete is registered once the abandoned work settles. It is
|
|
// deliberately not awaited, so the public bound is not extended.
|
|
if (deadlineExceeded) {
|
|
void installation
|
|
.catch(() => undefined)
|
|
.then(async () => {
|
|
await dependencies.caches.delete(cacheName).catch(() => false);
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
}
|
|
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;
|
|
// SW-09. Once fenced, no new candidate work is started.
|
|
if (signal.aborted) {
|
|
failure ??= rejected("INSTALL_DEADLINE_EXCEEDED");
|
|
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 {
|
|
// SW-09. A non-cooperative fetch that ignores the signal still settles
|
|
// later; its body is compensated so an abandoned response is not left open.
|
|
const pending = dependencies.fetcher(asset.url, {
|
|
cache: "no-store",
|
|
credentials: "omit",
|
|
redirect: "error",
|
|
signal,
|
|
});
|
|
const fetched = await abortable(pending, signal);
|
|
if (fetched === ABORTED) {
|
|
void pending
|
|
.then(async (late) => {
|
|
await late.body?.cancel();
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
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;
|
|
|
|
// SW-09. A digest dependency that throws becomes a closed typed outcome
|
|
// rather than an escaping rejection.
|
|
let digest: string | typeof ABORTED;
|
|
try {
|
|
digest = await abortable(
|
|
Promise.resolve(dependencies.digest(bytes)),
|
|
signal,
|
|
);
|
|
} catch {
|
|
return rejected("INTEGRITY_MISMATCH");
|
|
}
|
|
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 });
|
|
}
|