feat: complete TASK-WP0-06 public cache
This commit is contained in:
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { ToolchainProbe } from "./toolchain-probe.js";
|
||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
@@ -9,6 +10,9 @@ if (!root) {
|
||||
throw new Error("Dada web root element is missing.");
|
||||
}
|
||||
|
||||
// Cache failure leaves public assets network-backed and must not create alternate persistence.
|
||||
void registerPublicAssetServiceWorker().catch(() => undefined);
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ToolchainProbe />
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
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<Response>;
|
||||
|
||||
export function isPublicCacheDescriptor(value: unknown): value is PublicCacheDescriptor {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
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<T>(request: IDBRequest<T>) {
|
||||
return new Promise<T>((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<void>((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<IDBDatabase>((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<PublicAssetLruEntry[]>;
|
||||
}
|
||||
|
||||
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<StoredClientCachePolicy | undefined>;
|
||||
}
|
||||
|
||||
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<T>(name: string, callback: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
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<T>(callback: () => Promise<T>) {
|
||||
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<string>();
|
||||
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<void>((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" });
|
||||
}
|
||||
Reference in New Issue
Block a user