/** * Caching primitives: * - `TTLCache` — a small in-memory time-to-live cache with `getOrLoad`, for * memoising expensive data (query results, computed pages). * - HTTP helpers — `cacheControl` to build a directive, `withCacheControl` to * apply it, and `etag` / `notModified` for conditional requests (304s). */ // --- In-memory TTL cache --------------------------------------------------- interface Entry { value: V; expiresAt: number; } export class TTLCache { private store = new Map>(); private loading = new Map>(); private revisions = new Map(); private generation = 0; constructor(private readonly ttlMs = 60_000) {} get(key: string): V | undefined { const entry = this.store.get(key); if (!entry) return undefined; if (entry.expiresAt <= Date.now()) { this.store.delete(key); return undefined; } return entry.value; } set(key: string, value: V, ttlMs = this.ttlMs): void { this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1); this.store.set(key, { value, expiresAt: Date.now() + ttlMs }); } /** Return the cached value or compute, cache, and return it. */ async getOrLoad(key: string, loader: () => Promise | V, ttlMs = this.ttlMs): Promise { const hit = this.get(key); if (hit !== undefined) return hit; const pending = this.loading.get(key); if (pending) return pending; const revision = this.revisions.get(key) ?? 0; const generation = this.generation; const promise = Promise.resolve().then(loader); this.loading.set(key, promise); try { const value = await promise; if (this.generation === generation && (this.revisions.get(key) ?? 0) === revision) { this.store.set(key, { value, expiresAt: Date.now() + ttlMs }); } return value; } finally { if (this.loading.get(key) === promise) this.loading.delete(key); } } delete(key: string): void { this.store.delete(key); this.loading.delete(key); this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1); } clear(): void { this.store.clear(); this.loading.clear(); this.revisions.clear(); this.generation++; } get size(): number { return this.store.size; } } // --- HTTP caching ---------------------------------------------------------- export interface CacheControlOptions { /** max-age in seconds. */ maxAge?: number; /** s-maxage (shared/CDN cache) in seconds. */ sMaxAge?: number; /** Mark private (per-user) rather than public. */ private?: boolean; /** no-store: never cache. Overrides other directives. */ noStore?: boolean; /** no-cache: revalidate before use. */ noCache?: boolean; /** stale-while-revalidate window in seconds. */ staleWhileRevalidate?: number; immutable?: boolean; } /** Build a Cache-Control header value from options. */ export function cacheControl(options: CacheControlOptions): string { if (options.noStore) return "no-store"; const parts: string[] = [options.private ? "private" : "public"]; if (options.noCache) parts.push("no-cache"); if (options.maxAge !== undefined) parts.push(`max-age=${Math.max(0, Math.floor(options.maxAge))}`); if (options.sMaxAge !== undefined) parts.push(`s-maxage=${Math.max(0, Math.floor(options.sMaxAge))}`); if (options.staleWhileRevalidate !== undefined) { parts.push(`stale-while-revalidate=${Math.max(0, Math.floor(options.staleWhileRevalidate))}`); } if (options.immutable) parts.push("immutable"); return parts.join(", "); } /** Apply a Cache-Control header to a response (returns the same response). */ export function withCacheControl(res: Response, options: CacheControlOptions): Response { try { res.headers.set("Cache-Control", cacheControl(options)); } catch { /* immutable response — skip */ } return res; } /** A stable, quoted ETag for a string/bytes body (FNV-1a, weak by default). */ export function etag(body: string | ArrayBuffer | Uint8Array, weak = true): string { const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body instanceof Uint8Array ? body : new Uint8Array(body); let hash = 0x811c9dc5; for (let i = 0; i < bytes.length; i++) { hash ^= bytes[i]!; hash = Math.imul(hash, 0x01000193); } const tag = `"${(hash >>> 0).toString(16)}-${bytes.length.toString(16)}"`; return weak ? `W/${tag}` : tag; } /** True when the request's If-None-Match matches the given ETag (send a 304). */ export function notModified(req: Request, tag: string): boolean { const inm = req.headers.get("if-none-match"); if (!inm) return false; const normalize = (t: string) => t.trim().replace(/^W\//, ""); const target = normalize(tag); return inm.split(",").some((candidate) => normalize(candidate) === target); }