feat: complete TASK-WP0-06 public cache
This commit is contained in:
@@ -223,7 +223,9 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
: undefined;
|
||||
if (!resource) return reply.code(404).send();
|
||||
reply.type(resource.mimeType);
|
||||
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
reply.header("Content-Disposition", "inline");
|
||||
reply.header("ETag", `"sha256-${resource.sha256}"`);
|
||||
return resource.bytes;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -89,6 +89,7 @@ export interface PublicAssetPayload {
|
||||
bytes: Buffer;
|
||||
mimeType: string;
|
||||
resourceVersion: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface PublicAssetResolver {
|
||||
@@ -333,6 +334,7 @@ export function createPublicAssetResolver(input: {
|
||||
bytes,
|
||||
mimeType: entry.mimeType,
|
||||
resourceVersion: entry.resourceVersion,
|
||||
sha256,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const CACHE_NAME = "dada-public-assets-v1";
|
||||
const DATABASE_NAME = "dada-public-asset-cache-v1";
|
||||
const LRU_STORE = "public_asset_lru";
|
||||
const POLICY_STORE = "client_cache_policy";
|
||||
const PUBLIC_ROUTE = /^\/api\/v1\/assets\/public\/([a-z0-9][a-z0-9._-]{0,79})\/([0-9a-f-]{36})$/i;
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(LRU_STORE)) database.createObjectStore(LRU_STORE, { keyPath: "resource_id" });
|
||||
if (!database.objectStoreNames.contains(POLICY_STORE)) database.createObjectStore(POLICY_STORE, { keyPath: "policy_id" });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("public_cache_database_open_failed"));
|
||||
});
|
||||
}
|
||||
|
||||
async function readMetadata(resourceId) {
|
||||
const database = await openDatabase();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(LRU_STORE, "readonly").objectStore(LRU_STORE).get(resourceId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("public_cache_metadata_read_failed"));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function touchMetadata(resourceId, releaseVersion) {
|
||||
const database = await openDatabase();
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(LRU_STORE, "readwrite");
|
||||
const store = transaction.objectStore(LRU_STORE);
|
||||
const request = store.get(resourceId);
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result;
|
||||
if (entry?.release_version === releaseVersion) store.put({ ...entry, last_accessed_at: Date.now() });
|
||||
};
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error("public_cache_touch_aborted"));
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error("public_cache_touch_failed"));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin !== self.location.origin || url.search || url.hash) return;
|
||||
const route = PUBLIC_ROUTE.exec(url.pathname);
|
||||
if (!route) return;
|
||||
|
||||
event.respondWith((async () => {
|
||||
const releaseVersion = route[1];
|
||||
const resourceId = route[2];
|
||||
const metadata = await readMetadata(resourceId);
|
||||
if (!metadata || metadata.release_version !== releaseVersion) return fetch(event.request);
|
||||
const cached = await (await caches.open(CACHE_NAME)).match(event.request);
|
||||
if (!cached) return fetch(event.request);
|
||||
event.waitUntil(touchMetadata(resourceId, releaseVersion));
|
||||
return cached;
|
||||
})());
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
+4
-2
@@ -14,7 +14,7 @@
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts --config playwright.config.ts",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts --config playwright.config.ts",
|
||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||
@@ -30,7 +30,9 @@
|
||||
"test:wp0-04": "node scripts/run-wp0-04-validation.mjs",
|
||||
"test:wp0-04:red": "node scripts/run-wp0-04-validation.mjs --phase red",
|
||||
"test:wp0-05": "node scripts/run-wp0-05-validation.mjs",
|
||||
"test:wp0-05:red": "node scripts/run-wp0-05-validation.mjs --phase red"
|
||||
"test:wp0-05:red": "node scripts/run-wp0-05-validation.mjs --phase red",
|
||||
"test:wp0-06": "node scripts/run-wp0-06-validation.mjs",
|
||||
"test:wp0-06:red": "node scripts/run-wp0-06-validation.mjs --phase red"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const phaseIndex = process.argv.indexOf("--phase");
|
||||
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||
const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-06-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||
const caseId = "TDD-WP0-CACHE-001-public-lru";
|
||||
const caseDirectory = resolve(runDirectory, "cases", caseId);
|
||||
const playwrightDirectory = resolve(runDirectory, "playwright");
|
||||
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||
mkdirSync(caseDirectory, { recursive: true });
|
||||
|
||||
const redCommands = [
|
||||
["pnpm exec vitest run tests/unit/wp0-06-public-cache.test.ts", ["exec", "vitest", "run", "tests/unit/wp0-06-public-cache.test.ts"]],
|
||||
["pnpm exec playwright test tests/e2e/public-asset-cache.spec.ts --config playwright.config.ts", ["exec", "playwright", "test", "tests/e2e/public-asset-cache.spec.ts", "--config", "playwright.config.ts"]],
|
||||
];
|
||||
const greenCommands = [
|
||||
["pnpm test:unit", ["test:unit"]],
|
||||
["pnpm test:e2e", ["test:e2e"]],
|
||||
["pnpm validate:tdd-trace", ["validate:tdd-trace"]],
|
||||
];
|
||||
const environment = {
|
||||
...process.env,
|
||||
DADA_EVIDENCE_DIR_CACHE: caseDirectory,
|
||||
DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory,
|
||||
};
|
||||
const startedAt = new Date().toISOString();
|
||||
const commands = [];
|
||||
for (const [command, args] of phase === "red" ? redCommands : greenCommands) {
|
||||
const started_at = new Date().toISOString();
|
||||
const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm";
|
||||
const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${args.join(" ")}`] : args;
|
||||
const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment });
|
||||
if (execution.stdout) process.stdout.write(execution.stdout);
|
||||
if (execution.stderr) process.stderr.write(execution.stderr);
|
||||
commands.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at });
|
||||
}
|
||||
|
||||
function find(root, target) {
|
||||
if (!existsSync(root)) return [];
|
||||
return readdirSync(root).flatMap((entry) => {
|
||||
const child = resolve(root, entry);
|
||||
return statSync(child).isDirectory() ? find(child, target) : entry === target ? [child] : [];
|
||||
});
|
||||
}
|
||||
if (phase === "green") {
|
||||
const trace = find(playwrightDirectory, "trace.zip").find((path) => path.replaceAll("\\", "/").includes("public-asset-cache"));
|
||||
if (trace) copyFileSync(trace, resolve(caseDirectory, "trace.zip"));
|
||||
}
|
||||
|
||||
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId, schema_version: "1.0" }, null, 2)}\n`);
|
||||
const expectedEvidence = ["cache-enumeration.json", "lru-trace.json", "trace.zip"];
|
||||
const missingEvidence = phase === "green" ? expectedEvidence.filter((path) => !existsSync(resolve(caseDirectory, path))) : [];
|
||||
const commandState = phase === "red" ? commands.every((item) => item.exit_code !== 0) : commands.every((item) => item.exit_code === 0);
|
||||
const status = phase === "red" ? (commandState ? "red_confirmed" : "failed") : (commandState && missingEvidence.length === 0 ? "passed" : "failed");
|
||||
const result = {
|
||||
acceptance_criteria: ["AC-46", "AC-48"],
|
||||
automation: ["automated"],
|
||||
commit: spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(),
|
||||
environment: { arch: process.arch, node: process.version.slice(1), os: process.platform },
|
||||
evidence_refs: expectedEvidence,
|
||||
finished_at: new Date().toISOString(),
|
||||
layer: ["UNIT", "E2E"],
|
||||
manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() },
|
||||
missing_evidence: missingEvidence,
|
||||
parent_family: "TDD-WP0-CACHE-001",
|
||||
phase,
|
||||
release_gate: ["work_package:WP-0", "release:P0-A"],
|
||||
requirements: ["NFR-07", "17.14"],
|
||||
run_id: runId,
|
||||
schema_version: "1.0",
|
||||
started_at: startedAt,
|
||||
status,
|
||||
task_id: "TASK-WP0-06",
|
||||
test_id: caseId,
|
||||
work_package: "WP-0",
|
||||
worktree_under_test: spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim() ? "uncommitted implementation" : "clean committed implementation",
|
||||
};
|
||||
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||
const summary = { cases: [{ missing_evidence: missingEvidence, status, test_id: caseId }], phase, run_id: runId, status };
|
||||
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (status !== (phase === "red" ? "red_confirmed" : "passed")) process.exit(1);
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Dada public cache probe</title></head>
|
||||
<body><main><h1>Public cache probe</h1><output id="status">starting</output></main><script type="module" src="/tests/e2e/fixtures/public-asset-cache.ts"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
createPublicAssetCacheForTest,
|
||||
registerPublicAssetServiceWorker,
|
||||
} from "../../../apps/web/src/public-asset-cache.js";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dadaCacheProbe: {
|
||||
cache: ReturnType<typeof createPublicAssetCacheForTest>;
|
||||
register(): Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let tick = 1_000;
|
||||
const cache = createPublicAssetCacheForTest({ maxBytes: 20, now: () => ++tick });
|
||||
window.dadaCacheProbe = {
|
||||
cache,
|
||||
async register() {
|
||||
await registerPublicAssetServiceWorker();
|
||||
await navigator.serviceWorker.ready;
|
||||
document.querySelector("#status")!.textContent = "ready";
|
||||
},
|
||||
};
|
||||
void window.dadaCacheProbe.register();
|
||||
@@ -0,0 +1,121 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
const resources = new Map([
|
||||
["123e4567-e89b-42d3-a456-426614174001", "aaaaaaaa"],
|
||||
["123e4567-e89b-42d3-a456-426614174002", "bbbbbbbb"],
|
||||
["123e4567-e89b-42d3-a456-426614174003", "cccccccc"],
|
||||
]);
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
let publicFetchCount = 0;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({
|
||||
configFile: false,
|
||||
plugins: [{
|
||||
name: "wp0-06-public-assets",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const match = request.url?.match(/^\/api\/v1\/assets\/public\/[^/]+\/([0-9a-f-]+)$/i);
|
||||
if (!match) return next();
|
||||
const body = resources.get(match[1] ?? "");
|
||||
if (!body) { response.statusCode = 404; response.end(); return; }
|
||||
publicFetchCount += 1;
|
||||
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
response.setHeader("Content-Type", "application/octet-stream");
|
||||
response.end(body);
|
||||
});
|
||||
},
|
||||
}],
|
||||
publicDir: resolve("apps/web/public"),
|
||||
root: process.cwd(),
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
});
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
test("TDD-WP0-CACHE-001-public-lru enumerates isolated browser persistence", async ({ page }) => {
|
||||
publicFetchCount = 0;
|
||||
await page.goto(`${webUrl}/tests/e2e/fixtures/public-asset-cache.html`);
|
||||
await expect(page.locator("#status")).toHaveText("ready");
|
||||
await page.reload();
|
||||
await expect(page.locator("#status")).toHaveText("ready");
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const ids = [
|
||||
"123e4567-e89b-42d3-a456-426614174001",
|
||||
"123e4567-e89b-42d3-a456-426614174002",
|
||||
"123e4567-e89b-42d3-a456-426614174003",
|
||||
];
|
||||
const descriptor = (resource_id: string, release_version: string) => ({
|
||||
access_class: "public_release_asset" as const,
|
||||
cache_kind: "thumbnail" as const,
|
||||
release_version,
|
||||
resource_id,
|
||||
});
|
||||
const cache = window.dadaCacheProbe.cache;
|
||||
await cache.clear();
|
||||
const first = await cache.cache(descriptor(ids[0]!, "asset-20260727.1"));
|
||||
const second = await cache.cache(descriptor(ids[1]!, "asset-20260727.1"));
|
||||
await fetch(`/api/v1/assets/public/asset-20260727.1/${ids[0]}`);
|
||||
const third = await cache.cache(descriptor(ids[2]!, "asset-20260727.2"));
|
||||
const rejected = [];
|
||||
for (const [access_class, cache_kind] of [
|
||||
["internal_preview_asset", "thumbnail"], ["private_user_asset", "thumbnail"],
|
||||
["public_release_asset", "project"], ["public_release_asset", "prompt"],
|
||||
["public_release_asset", "verification_code"], ["public_release_asset", "session"],
|
||||
["public_release_asset", "unsaved_edit"], ["public_release_asset", "user_download"],
|
||||
]) {
|
||||
rejected.push(await cache.cache({ access_class, cache_kind, release_version: "asset-20260727.2", resource_id: ids[0] }));
|
||||
}
|
||||
const enumeration = await cache.inspect();
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return {
|
||||
enumeration,
|
||||
local_storage_keys: Object.keys(localStorage),
|
||||
rejected,
|
||||
results: [first, second, third],
|
||||
service_workers: registrations.map((item) => ({ active: item.active?.state, scope: item.scope, script_url: item.active?.scriptURL })),
|
||||
session_storage_keys: Object.keys(sessionStorage),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.results[2]).toMatchObject({ evicted_resource_ids: ["123e4567-e89b-42d3-a456-426614174002"], status: "cached" });
|
||||
expect(result.enumeration.policy.current_public_cache_bytes).toBe(16);
|
||||
expect(result.enumeration.policy.max_public_cache_bytes).toBe(20);
|
||||
expect(result.enumeration.entries.map((item: { resource_id: string }) => item.resource_id).sort()).toEqual([
|
||||
"123e4567-e89b-42d3-a456-426614174001",
|
||||
"123e4567-e89b-42d3-a456-426614174003",
|
||||
]);
|
||||
expect(result.enumeration.cache_keys).toHaveLength(2);
|
||||
expect(result.enumeration.indexed_db_names).toEqual(["dada-public-asset-cache-v1"]);
|
||||
expect(result.rejected).toEqual(Array.from({ length: 8 }, () => ({ status: "rejected_not_allowlisted" })));
|
||||
expect(result.local_storage_keys).toEqual([]);
|
||||
expect(result.session_storage_keys).toEqual([]);
|
||||
expect(result.service_workers).toHaveLength(1);
|
||||
expect(publicFetchCount).toBe(3);
|
||||
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_CACHE;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeFileSync(resolve(evidenceDirectory, "cache-enumeration.json"), `${JSON.stringify({
|
||||
business_database_calls: 0,
|
||||
public_network_fetches: publicFetchCount,
|
||||
...result,
|
||||
}, null, 2)}\n`);
|
||||
writeFileSync(resolve(evidenceDirectory, "lru-trace.json"), `${JSON.stringify({
|
||||
capacity_driver: { production_max_bytes: 157_286_400, test_max_bytes: 20 },
|
||||
prewrite_eviction: true,
|
||||
results: result.results,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import {
|
||||
MAX_PUBLIC_CACHE_BYTES,
|
||||
buildPublicAssetUrl,
|
||||
isPublicCacheDescriptor,
|
||||
isPublicServiceWorkerRoute,
|
||||
planPublicCacheWrite,
|
||||
type PublicAssetLruEntry,
|
||||
} from "../../apps/web/src/public-asset-cache.js";
|
||||
|
||||
const resourceIds = [
|
||||
"123e4567-e89b-42d3-a456-426614174001",
|
||||
"123e4567-e89b-42d3-a456-426614174002",
|
||||
"123e4567-e89b-42d3-a456-426614174003",
|
||||
"123e4567-e89b-42d3-a456-426614174004",
|
||||
] as const;
|
||||
|
||||
describe("TDD-WP0-CACHE-001 public allowlist LRU", () => {
|
||||
it("freezes the 150 MiB policy and allows exact equality", () => {
|
||||
expect(MAX_PUBLIC_CACHE_BYTES).toBe(157_286_400);
|
||||
const plan = planPublicCacheWrite([], {
|
||||
bytes: MAX_PUBLIC_CACHE_BYTES,
|
||||
last_accessed_at: 4,
|
||||
release_version: "asset-20260727.2",
|
||||
resource_id: resourceIds[0],
|
||||
}, MAX_PUBLIC_CACHE_BYTES);
|
||||
expect(plan).toEqual({ accepted: true, evict: [], resulting_bytes: MAX_PUBLIC_CACHE_BYTES });
|
||||
expect(planPublicCacheWrite([], {
|
||||
bytes: MAX_PUBLIC_CACHE_BYTES + 1,
|
||||
last_accessed_at: 4,
|
||||
release_version: "asset-20260727.2",
|
||||
resource_id: resourceIds[0],
|
||||
}, MAX_PUBLIC_CACHE_BYTES)).toEqual({ accepted: false, evict: [], resulting_bytes: 0 });
|
||||
});
|
||||
|
||||
it("evicts old releases first and then the least recently used entry before writing", () => {
|
||||
const entries: PublicAssetLruEntry[] = [
|
||||
{ bytes: 40, last_accessed_at: 30, release_version: "asset-20260727.1", resource_id: resourceIds[0] },
|
||||
{ bytes: 40, last_accessed_at: 10, release_version: "asset-20260727.1", resource_id: resourceIds[1] },
|
||||
{ bytes: 40, last_accessed_at: 1, release_version: "asset-20260727.2", resource_id: resourceIds[2] },
|
||||
];
|
||||
const plan = planPublicCacheWrite(entries, {
|
||||
bytes: 40,
|
||||
last_accessed_at: 40,
|
||||
release_version: "asset-20260727.2",
|
||||
resource_id: resourceIds[3],
|
||||
}, 120);
|
||||
expect(plan).toEqual({ accepted: true, evict: [resourceIds[1]], resulting_bytes: 120 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ access_class: "internal_preview_asset", cache_kind: "thumbnail" },
|
||||
{ access_class: "private_user_asset", cache_kind: "thumbnail" },
|
||||
{ access_class: "public_release_asset", cache_kind: "sticker_original" },
|
||||
{ access_class: "public_release_asset", cache_kind: "generation" },
|
||||
{ access_class: "public_release_asset", cache_kind: "export" },
|
||||
{ access_class: "public_release_asset", cache_kind: "project" },
|
||||
{ access_class: "public_release_asset", cache_kind: "prompt" },
|
||||
{ access_class: "public_release_asset", cache_kind: "verification_code" },
|
||||
{ access_class: "public_release_asset", cache_kind: "session" },
|
||||
{ access_class: "public_release_asset", cache_kind: "unsaved_edit" },
|
||||
{ access_class: "public_release_asset", cache_kind: "user_download" },
|
||||
])("rejects non-allowlisted candidate $access_class/$cache_kind", (candidate) => {
|
||||
expect(isPublicCacheDescriptor({
|
||||
...candidate,
|
||||
release_version: "asset-20260727.2",
|
||||
resource_id: resourceIds[0],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["thumbnail", "template_conversion", "font"] as const)("allows public %s only", (cacheKind) => {
|
||||
expect(isPublicCacheDescriptor({
|
||||
access_class: "public_release_asset",
|
||||
cache_kind: cacheKind,
|
||||
release_version: "asset-20260727.2",
|
||||
resource_id: resourceIds[0],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("constructs and intercepts only same-origin versioned public asset routes", () => {
|
||||
const url = buildPublicAssetUrl("asset-20260727.2", resourceIds[0]);
|
||||
expect(url).toBe(`/api/v1/assets/public/asset-20260727.2/${resourceIds[0]}`);
|
||||
expect(isPublicServiceWorkerRoute(new URL(url, "http://127.0.0.1:43121"), "http://127.0.0.1:43121")).toBe(true);
|
||||
for (const path of [
|
||||
`/api/v1/assets/preview/asset-20260727.2/${resourceIds[0]}`,
|
||||
`/api/v1/private-assets/${resourceIds[0]}`,
|
||||
`/api/v1/projects/${resourceIds[0]}`,
|
||||
"/api/v1/support/check",
|
||||
"file:///C:/private.png",
|
||||
]) {
|
||||
expect(isPublicServiceWorkerRoute(new URL(path, "http://127.0.0.1:43121"), "http://127.0.0.1:43121")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves existing public binaries as immutable versioned responses with a strong ETag", { timeout: 15_000 }, async () => {
|
||||
const bytes = Buffer.from("public-thumbnail");
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const app = await createApp({
|
||||
browserGate: false,
|
||||
publicAssets: {
|
||||
read: () => ({ assetId: resourceIds[0], bytes, mimeType: "image/png", resourceVersion: "asset-20260727.2", sha256 }),
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
headers: { host: "127.0.0.1:43121" },
|
||||
method: "GET",
|
||||
url: `/api/v1/assets/public/asset-20260727.2/${resourceIds[0]}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
||||
expect(response.headers.etag).toBe(`"sha256-${sha256}"`);
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user