import type { Context, Middleware } from "@wrnexus/core"; import { etag, notModified } from "@wrnexus/core"; import { TagCache, type CacheSetOptions } from "./memory.ts"; export interface CachedResponse { status: number; statusText: string; headers: [string, string][]; body: Uint8Array; etag: string; } export interface ResponseCacheOptions extends CacheSetOptions { cache?: TagCache; key?: (ctx: Context) => string; vary?: string[]; shouldCache?: (ctx: Context, response: Response) => boolean; /** * Optional detached revalidator used for stale-while-revalidate. Middleware * `next()` is deliberately never called after a response has been returned, * because many middleware pipelines are single-use. */ revalidate?: (ctx: Context) => Promise; onRevalidateError?: (error: unknown, ctx: Context) => void; } function defaultKey(ctx: Context, vary: string[]): string { const values = vary.map((name) => `${name.toLowerCase()}=${ctx.req.headers.get(name) ?? ""}`); return `${ctx.req.method}:${ctx.url.origin}${ctx.url.pathname}${ctx.url.search}|${values.join("|")}`; } function cacheable(ctx: Context, response: Response): boolean { if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") return false; if (response.status < 200 || response.status >= 400) return false; if (response.headers.has("set-cookie")) return false; const control = response.headers.get("cache-control") ?? ""; return !/(?:^|,)\s*(?:no-store|private)(?:\s|,|$)/i.test(control); } function fromCached(ctx: Context, cached: CachedResponse, state: "fresh" | "stale"): Response { const headers = new Headers(cached.headers); headers.set("x-wrnexus-cache", state === "fresh" ? "HIT" : "STALE"); headers.set("age", "0"); if (notModified(ctx.req, cached.etag)) { return new Response(null, { status: 304, headers }); } return new Response(ctx.req.method === "HEAD" ? null : cached.body.slice(), { status: cached.status, statusText: cached.statusText, headers, }); } async function capture(response: Response): Promise { const body = new Uint8Array(await response.clone().arrayBuffer()); const tag = response.headers.get("etag") ?? etag(body); const headers = new Headers(response.headers); headers.set("etag", tag); return { status: response.status, statusText: response.statusText, headers: [...headers.entries()], body, etag: tag, }; } export function responseCache(options: ResponseCacheOptions = {}): Middleware { const cache = options.cache ?? new TagCache(); const vary = options.vary ?? ["accept-encoding", "accept-language"]; return async (ctx, next) => { if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") return next(); const key = options.key?.(ctx) ?? defaultKey(ctx, vary); const hit = cache.lookup(key); if (hit.state === "fresh") return fromCached(ctx, hit.entry.value, "fresh"); if (hit.state === "stale") { if (options.revalidate) { void options .revalidate(ctx) .then(async (response) => { if ((options.shouldCache ?? cacheable)(ctx, response)) { cache.set(key, await capture(response), options); } }) .catch((error: unknown) => options.onRevalidateError?.(error, ctx)); } return fromCached(ctx, hit.entry.value, "stale"); } const response = await next(); if (!(options.shouldCache ?? cacheable)(ctx, response)) { try { response.headers.set("x-wrnexus-cache", "BYPASS"); } catch { // Immutable response. } return response; } const stored = await capture(response); cache.set(key, stored, options); const headers = new Headers(stored.headers); headers.set("x-wrnexus-cache", "MISS"); return new Response(ctx.req.method === "HEAD" ? null : stored.body.slice(), { status: stored.status, statusText: stored.statusText, headers, }); }; }