export const MAX_PUBLIC_CACHE_BYTES = 157_286_400; export const PUBLIC_ASSET_CACHE_NAME = "dada-public-assets-v1"; export const PUBLIC_CACHE_DATABASE_NAME = "dada-public-asset-cache-v1"; export const PUBLIC_ASSET_LRU_STORE = "public_asset_lru"; export const PUBLIC_CACHE_POLICY_STORE = "client_cache_policy"; const policyId = "public"; const resourceIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const releaseVersionPattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i; const publicAssetPathPattern = /^\/api\/v1\/assets\/public\/([a-z0-9][a-z0-9._-]{0,79})\/([0-9a-f-]{36})$/i; const allowlistedKinds = new Set(["thumbnail", "template_conversion", "font"]); export interface PublicCacheDescriptor { access_class: "public_release_asset"; cache_kind: "thumbnail" | "template_conversion" | "font"; release_version: string; resource_id: string; } export interface PublicAssetLruEntry { bytes: number; last_accessed_at: number; release_version: string; resource_id: string; } export interface ClientCachePolicy { cached_release_version: string | null; current_public_cache_bytes: number; eviction_policy: "LRU"; last_eviction_at: number | null; max_public_cache_bytes: number; } interface StoredClientCachePolicy extends ClientCachePolicy { policy_id: typeof policyId; } export interface PublicCacheWritePlan { accepted: boolean; evict: string[]; resulting_bytes: number; } type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise; export function isPublicCacheDescriptor(value: unknown): value is PublicCacheDescriptor { if (!value || typeof value !== "object") return false; const candidate = value as Record; return candidate.access_class === "public_release_asset" && typeof candidate.cache_kind === "string" && allowlistedKinds.has(candidate.cache_kind) && typeof candidate.release_version === "string" && releaseVersionPattern.test(candidate.release_version) && typeof candidate.resource_id === "string" && resourceIdPattern.test(candidate.resource_id); } export function buildPublicAssetUrl(releaseVersion: string, resourceId: string) { if (!releaseVersionPattern.test(releaseVersion)) throw new Error("release_version_invalid"); if (!resourceIdPattern.test(resourceId)) throw new Error("resource_id_invalid"); return `/api/v1/assets/public/${releaseVersion}/${resourceId}`; } export function isPublicServiceWorkerRoute(url: URL, applicationOrigin: string) { if (url.origin !== applicationOrigin || url.search !== "" || url.hash !== "") return false; const match = publicAssetPathPattern.exec(url.pathname); return Boolean(match && releaseVersionPattern.test(match[1] ?? "") && resourceIdPattern.test(match[2] ?? "")); } export function planPublicCacheWrite( entries: PublicAssetLruEntry[], incoming: PublicAssetLruEntry, maxBytes = MAX_PUBLIC_CACHE_BYTES, ): PublicCacheWritePlan { if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("max_public_cache_bytes_invalid"); const existingBytes = entries.reduce((sum, entry) => sum + entry.bytes, 0); if (!Number.isSafeInteger(incoming.bytes) || incoming.bytes <= 0 || incoming.bytes > maxBytes) { return { accepted: false, evict: [], resulting_bytes: existingBytes }; } const replaced = entries.filter((entry) => entry.resource_id === incoming.resource_id); const retained = entries.filter((entry) => entry.resource_id !== incoming.resource_id); let resultingBytes = retained.reduce((sum, entry) => sum + entry.bytes, 0) + incoming.bytes; const candidates = [...retained].sort((left, right) => { const leftOld = left.release_version === incoming.release_version ? 1 : 0; const rightOld = right.release_version === incoming.release_version ? 1 : 0; if (leftOld !== rightOld) return leftOld - rightOld; if (left.last_accessed_at !== right.last_accessed_at) return left.last_accessed_at - right.last_accessed_at; return left.resource_id.localeCompare(right.resource_id); }); const evict = replaced.map((entry) => entry.resource_id); for (const candidate of candidates) { if (resultingBytes <= maxBytes) break; evict.push(candidate.resource_id); resultingBytes -= candidate.bytes; } return { accepted: resultingBytes <= maxBytes, evict, resulting_bytes: resultingBytes }; } function requestResult(request: IDBRequest) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error("indexed_db_request_failed")); }); } function transactionComplete(transaction: IDBTransaction) { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error ?? new Error("indexed_db_transaction_aborted")); transaction.onerror = () => reject(transaction.error ?? new Error("indexed_db_transaction_failed")); }); } function openPublicCacheDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open(PUBLIC_CACHE_DATABASE_NAME, 1); request.onupgradeneeded = () => { const database = request.result; if (!database.objectStoreNames.contains(PUBLIC_ASSET_LRU_STORE)) { database.createObjectStore(PUBLIC_ASSET_LRU_STORE, { keyPath: "resource_id" }); } if (!database.objectStoreNames.contains(PUBLIC_CACHE_POLICY_STORE)) { database.createObjectStore(PUBLIC_CACHE_POLICY_STORE, { keyPath: "policy_id" }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error("public_cache_database_open_failed")); }); } async function readEntries(database: IDBDatabase) { const transaction = database.transaction(PUBLIC_ASSET_LRU_STORE, "readonly"); return requestResult(transaction.objectStore(PUBLIC_ASSET_LRU_STORE).getAll()) as Promise; } async function readStoredPolicy(database: IDBDatabase) { const transaction = database.transaction(PUBLIC_CACHE_POLICY_STORE, "readonly"); return requestResult(transaction.objectStore(PUBLIC_CACHE_POLICY_STORE).get(policyId)) as Promise; } function withoutPolicyId(policy: StoredClientCachePolicy): ClientCachePolicy { const { policy_id: _policyId, ...publicPolicy } = policy; return publicPolicy; } function parsePublicAssetUrl(url: string) { const parsed = new URL(url, location.origin); if (!isPublicServiceWorkerRoute(parsed, location.origin)) return undefined; const match = publicAssetPathPattern.exec(parsed.pathname); if (!match) return undefined; return { release_version: match[1]!, resource_id: match[2]! }; } interface LockManagerSubset { request(name: string, callback: () => Promise): Promise; } export class PublicAssetCache { private readonly fetcher: Fetcher; private readonly maxBytes: number; private readonly now: () => number; constructor(input: { fetcher?: Fetcher; maxBytes?: number; now?: () => number } = {}) { this.fetcher = input.fetcher ?? ((request, init) => fetch(request, init)); this.maxBytes = input.maxBytes ?? MAX_PUBLIC_CACHE_BYTES; this.now = input.now ?? Date.now; if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes <= 0 || this.maxBytes > MAX_PUBLIC_CACHE_BYTES) { throw new Error("max_public_cache_bytes_invalid"); } } private async withWriteLock(callback: () => Promise) { const locks = (navigator as Navigator & { locks?: LockManagerSubset }).locks; if (!locks) throw new Error("web_locks_unavailable"); return locks.request("dada-public-asset-cache-write", callback); } private defaultPolicy(entries: PublicAssetLruEntry[] = []): StoredClientCachePolicy { return { cached_release_version: null, current_public_cache_bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0), eviction_policy: "LRU", last_eviction_at: null, max_public_cache_bytes: this.maxBytes, policy_id: policyId, }; } private async reconcileUnlocked() { const database = await openPublicCacheDatabase(); try { const cache = await caches.open(PUBLIC_ASSET_CACHE_NAME); const [entries, keys, existingPolicy] = await Promise.all([ readEntries(database), cache.keys(), readStoredPolicy(database), ]); const entriesById = new Map(entries.map((entry) => [entry.resource_id, entry])); const validCacheIds = new Set(); for (const key of keys) { const parsed = parsePublicAssetUrl(key.url); const metadata = parsed ? entriesById.get(parsed.resource_id) : undefined; if (!parsed || !metadata || metadata.release_version !== parsed.release_version) await cache.delete(key); else validCacheIds.add(parsed.resource_id); } const retained = entries.filter((entry) => validCacheIds.has(entry.resource_id)); const transaction = database.transaction([PUBLIC_ASSET_LRU_STORE, PUBLIC_CACHE_POLICY_STORE], "readwrite"); const lruStore = transaction.objectStore(PUBLIC_ASSET_LRU_STORE); for (const entry of entries) if (!validCacheIds.has(entry.resource_id)) lruStore.delete(entry.resource_id); const previousPolicy = existingPolicy ?? this.defaultPolicy(); transaction.objectStore(PUBLIC_CACHE_POLICY_STORE).put({ ...previousPolicy, current_public_cache_bytes: retained.reduce((sum, entry) => sum + entry.bytes, 0), max_public_cache_bytes: this.maxBytes, } satisfies StoredClientCachePolicy); await transactionComplete(transaction); } finally { database.close(); } } async cache(candidate: unknown) { if (!isPublicCacheDescriptor(candidate)) return { status: "rejected_not_allowlisted" as const }; const url = buildPublicAssetUrl(candidate.release_version, candidate.resource_id); const response = await this.fetcher(url, { credentials: "same-origin", method: "GET" }); if (!response.ok) return { status: "fetch_failed" as const }; const blob = await response.blob(); if (blob.size <= 0 || blob.size > this.maxBytes) return { status: "rejected_capacity" as const }; return this.withWriteLock(async () => { await this.reconcileUnlocked(); const database = await openPublicCacheDatabase(); try { const entries = await readEntries(database); const accessedAt = this.now(); const incoming: PublicAssetLruEntry = { bytes: blob.size, last_accessed_at: accessedAt, release_version: candidate.release_version, resource_id: candidate.resource_id, }; const plan = planPublicCacheWrite(entries, incoming, this.maxBytes); if (!plan.accepted) return { status: "rejected_capacity" as const }; const entriesById = new Map(entries.map((entry) => [entry.resource_id, entry])); const cache = await caches.open(PUBLIC_ASSET_CACHE_NAME); // Physical cache entries are removed before the incoming body is persisted. for (const resourceId of plan.evict) { const entry = entriesById.get(resourceId); if (entry) await cache.delete(buildPublicAssetUrl(entry.release_version, entry.resource_id)); } await cache.put(url, new Response(blob, { headers: response.headers, status: response.status, statusText: response.statusText, })); const previousPolicy = await readStoredPolicy(database) ?? this.defaultPolicy(entries); const transaction = database.transaction([PUBLIC_ASSET_LRU_STORE, PUBLIC_CACHE_POLICY_STORE], "readwrite"); const lruStore = transaction.objectStore(PUBLIC_ASSET_LRU_STORE); for (const resourceId of plan.evict) lruStore.delete(resourceId); lruStore.put(incoming); transaction.objectStore(PUBLIC_CACHE_POLICY_STORE).put({ cached_release_version: candidate.release_version, current_public_cache_bytes: plan.resulting_bytes, eviction_policy: "LRU", last_eviction_at: plan.evict.length > 0 ? accessedAt : previousPolicy.last_eviction_at, max_public_cache_bytes: this.maxBytes, policy_id: policyId, } satisfies StoredClientCachePolicy); await transactionComplete(transaction); return { bytes: blob.size, evicted_resource_ids: plan.evict, status: "cached" as const, }; } finally { database.close(); } }); } async inspect() { return this.withWriteLock(async () => { await this.reconcileUnlocked(); const database = await openPublicCacheDatabase(); try { const [entries, storedPolicy, keys, cacheNames, databases] = await Promise.all([ readEntries(database), readStoredPolicy(database), caches.open(PUBLIC_ASSET_CACHE_NAME).then((cache) => cache.keys()), caches.keys(), typeof indexedDB.databases === "function" ? indexedDB.databases() : Promise.resolve([]), ]); return { cache_keys: keys.map((request) => request.url).sort(), cache_names: cacheNames.filter((name) => name.startsWith("dada-")).sort(), entries: entries.sort((left, right) => left.resource_id.localeCompare(right.resource_id)), indexed_db_names: databases.map((databaseInfo) => databaseInfo.name).filter((name): name is string => Boolean(name?.startsWith("dada-"))).sort(), policy: withoutPolicyId(storedPolicy ?? this.defaultPolicy(entries)), }; } finally { database.close(); } }); } async clear() { return this.withWriteLock(async () => { await caches.delete(PUBLIC_ASSET_CACHE_NAME); await new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase(PUBLIC_CACHE_DATABASE_NAME); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error ?? new Error("public_cache_database_delete_failed")); request.onblocked = () => reject(new Error("public_cache_database_delete_blocked")); }); }); } } export function createPublicAssetCache() { return new PublicAssetCache({ maxBytes: MAX_PUBLIC_CACHE_BYTES }); } export function createPublicAssetCacheForTest(input: { maxBytes: number; now?: () => number }) { return new PublicAssetCache(input); } export async function registerPublicAssetServiceWorker() { if (!("serviceWorker" in navigator)) throw new Error("service_worker_unavailable"); return navigator.serviceWorker.register("/public-cache-service-worker.js", { scope: "/", type: "module" }); }