chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user