Files
tech-log-frontend/src/adapters/service-worker/service-worker-static-assets.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
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>
2026-08-15 12:04:58 +09:00

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 });
}