diff --git a/docs/latest/plugins/cache.md b/docs/latest/plugins/cache.md new file mode 100644 index 00000000000..a40c4c9d1ee --- /dev/null +++ b/docs/latest/plugins/cache.md @@ -0,0 +1,120 @@ +--- +description: "Server-side response caching with the cache middleware" +--- + +The `cache()` middleware adds server-side response caching to your Fresh app +using the +[Web Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache). Routes +opt into caching by setting standard `Cache-Control` headers on their responses. + +```ts main.ts +import { App, cache, staticFiles } from "fresh"; + +const app = new App() + .use(staticFiles()) + .use(cache()) + .get("/", (ctx) => { + return new Response("hello", { + headers: { + "Cache-Control": "public, max-age=60", + }, + }); + }); +``` + +Only responses that are `public`, have a positive `max-age`, return status 200, +and don't set cookies are cached. Responses without `Cache-Control` or with +`private`/`no-store` pass through untouched. + +## Opting in from a route + +Routes control their own caching policy through standard `Cache-Control` +headers. Return them from your handler via `page()` or a raw `Response`: + +```ts routes/blog/[slug].tsx +import { page } from "fresh"; + +export const handler = define.handlers({ + GET(ctx) { + const post = getPost(ctx.params.slug); + return page({ post }, { + headers: { + "Cache-Control": "public, max-age=60", + }, + }); + }, +}); + +export default define.page(({ data }) => { + return
{data.post.title}
; +}); +``` + +## Stale-while-revalidate + +For ISR-like behavior, use the `stale-while-revalidate` directive. This serves a +cached response immediately while regenerating a fresh one in the background: + +```ts +"Cache-Control": "public, max-age=60, stale-while-revalidate=300" +``` + +This means: serve the cached version for 60 seconds, then for the next 5 minutes +serve the stale version while fetching a fresh one in the background. After the +stale window expires, the next request waits for a fresh response. + +## Scoping to specific paths + +You can scope caching to a subset of routes: + +```ts main.ts +import { App, cache } from "fresh"; + +const app = new App() + // Only cache blog pages + .use("/blog/*", cache()) + .get("/blog/:slug", blogHandler); +``` + +## Manual invalidation + +When content changes and you don't want to wait for the TTL to expire, use the +Web Cache API directly to purge entries: + +```ts routes/blog/[slug].tsx +export const handler = define.handlers({ + async POST(ctx) { + await updatePost(ctx.params.slug); + + // Purge the cached page + const store = await caches.open("fresh"); + await store.delete(new URL(`/blog/${ctx.params.slug}`, ctx.url)); + + return ctx.redirect(`/blog/${ctx.params.slug}`); + }, +}); +``` + +## Options + +| Option | Type | Default | Description | +| ------------- | ----------------------- | --------- | ------------------------------------------------------- | +| `cacheName` | `string` | `"fresh"` | Name of the Web Cache API store | +| `methods` | `string[]` | `["GET"]` | HTTP methods to cache | +| `shouldCache` | `(req, res) => boolean` | — | Custom function to override default cacheability checks | + +### Custom cacheability + +By default, only 200 responses with `Cache-Control: public` and a positive +`max-age` are cached. Override this with `shouldCache`: + +```ts +app.use(cache({ + shouldCache: (_req, res) => { + return res.headers.get("X-Cache") === "yes"; + }, +})); +``` + +When using a custom `shouldCache`, entries without a `max-age` are treated as +permanently fresh (no automatic expiry). diff --git a/docs/toc.ts b/docs/toc.ts index 4d3c5d7c451..ed3637038f9 100644 --- a/docs/toc.ts +++ b/docs/toc.ts @@ -86,6 +86,7 @@ const toc: RawTableOfContents = { title: "Plugins", link: "latest", pages: [ + ["cache", "cache", "link:latest"], ["cors", "cors", "link:latest"], ["csrf", "csrf", "link:latest"], ["csp", "csp", "link:latest"], diff --git a/packages/fresh/src/middlewares/cache.ts b/packages/fresh/src/middlewares/cache.ts new file mode 100644 index 00000000000..3f10cc9e958 --- /dev/null +++ b/packages/fresh/src/middlewares/cache.ts @@ -0,0 +1,193 @@ +import type { Middleware } from "./mod.ts"; +import { PARTIAL_SEARCH_PARAM } from "../constants.ts"; + +/** + * Options for the {@linkcode cache} middleware. + */ +export interface CacheOptions { + /** + * Name of the {@linkcode Cache} instance to use with the + * [Web Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + * @default "fresh" + */ + cacheName?: string; + /** + * HTTP methods that should be cached. + * @default ["GET"] + */ + methods?: string[]; + /** + * A function that determines whether a response should be cached. + * By default, only responses with status 200 that have a `Cache-Control` + * header containing `public` are cached. + */ + shouldCache?: (req: Request, res: Response) => boolean; +} + +interface CacheControl { + public: boolean; + noStore: boolean; + maxAge: number; + staleWhileRevalidate: number; +} + +function parseCacheControl(header: string | null): CacheControl { + const cc: CacheControl = { + public: false, + noStore: false, + maxAge: 0, + staleWhileRevalidate: 0, + }; + if (header === null) return cc; + + const directives = header.split(","); + for (let i = 0; i < directives.length; i++) { + const part = directives[i].trim().toLowerCase(); + if (part === "public") { + cc.public = true; + } else if (part === "no-store" || part === "private") { + cc.noStore = true; + } else if (part.startsWith("max-age=")) { + cc.maxAge = parseInt(part.slice(8), 10) || 0; + } else if (part.startsWith("stale-while-revalidate=")) { + cc.staleWhileRevalidate = parseInt(part.slice(23), 10) || 0; + } + } + return cc; +} + +const CACHED_AT_HEADER = "X-Fresh-Cached-At"; + +function isCacheableResponse(req: Request, res: Response): boolean { + if (res.status !== 200) return false; + + const cc = parseCacheControl(res.headers.get("Cache-Control")); + if (!cc.public || cc.noStore || cc.maxAge <= 0) return false; + + // Don't cache responses that set cookies + if (res.headers.has("Set-Cookie")) return false; + + // Don't cache partial requests + const url = new URL(req.url); + if (url.searchParams.has(PARTIAL_SEARCH_PARAM)) return false; + + return true; +} + +/** + * Fresh middleware for server-side response caching using the + * [Web Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + * + * Routes opt into caching by setting standard `Cache-Control` headers on their + * responses. Only responses with `public` and a positive `max-age` are cached. + * + * Supports `stale-while-revalidate` for ISR-like behavior: stale responses + * are served immediately while a fresh response is generated in the background. + * + * ```ts + * import { App, cache } from "fresh"; + * + * const app = new App() + * .use(cache()) + * .get("/", (ctx) => { + * return new Response("hello", { + * headers: { + * "Cache-Control": "public, max-age=60, stale-while-revalidate=300", + * }, + * }); + * }); + * ``` + */ +export function cache(options?: CacheOptions): Middleware { + const cacheName = options?.cacheName ?? "fresh"; + const methods = new Set(options?.methods ?? ["GET"]); + const shouldCache = options?.shouldCache ?? isCacheableResponse; + + return async function freshCache(ctx) { + const { req } = ctx; + + // Only cache configured methods + if (!methods.has(req.method)) { + return ctx.next(); + } + + // Don't cache partial requests + if (ctx.url.searchParams.has(PARTIAL_SEARCH_PARAM)) { + return ctx.next(); + } + + const store = await caches.open(cacheName); + const cached = await store.match(req); + + if (cached !== undefined) { + const cachedAtStr = cached.headers.get(CACHED_AT_HEADER); + if (cachedAtStr !== null) { + const cachedAt = parseInt(cachedAtStr, 10); + const ageSeconds = (Date.now() - cachedAt) / 1000; + const cc = parseCacheControl(cached.headers.get("Cache-Control")); + + // If no max-age was set the entry was stored via a custom + // shouldCache — treat it as permanently fresh. + if (cc.maxAge <= 0 || ageSeconds < cc.maxAge) { + return cached; + } + + if ( + cc.staleWhileRevalidate > 0 && + ageSeconds < cc.maxAge + cc.staleWhileRevalidate + ) { + // Stale but within SWR window — serve stale and revalidate + revalidate(ctx.req.clone(), ctx.next, store, shouldCache); + return cached; + } + + // Expired — discard cached response + await cached.body?.cancel(); + } + } + + // Cache miss or expired — get fresh response + const res = await ctx.next(); + + if (shouldCache(req, res)) { + const toCache = res.clone(); + const headers = new Headers(toCache.headers); + headers.set(CACHED_AT_HEADER, String(Date.now())); + const cachedResponse = new Response(toCache.body, { + status: toCache.status, + statusText: toCache.statusText, + headers, + }); + await store.put(req, cachedResponse); + } + + return res; + }; +} + +function revalidate( + req: Request, + next: () => Promise, + store: Cache, + shouldCache: (req: Request, res: Response) => boolean, +): void { + // Fire-and-forget background revalidation + queueMicrotask(async () => { + try { + const res = await next(); + if (shouldCache(req, res)) { + const headers = new Headers(res.headers); + headers.set(CACHED_AT_HEADER, String(Date.now())); + const cachedResponse = new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }); + await store.put(req, cachedResponse); + } + } catch { + // Revalidation failures are silent — the stale response was already + // served, so there's nothing to do. + } + }); +} diff --git a/packages/fresh/src/middlewares/cache_test.ts b/packages/fresh/src/middlewares/cache_test.ts new file mode 100644 index 00000000000..dcde0ecfb21 --- /dev/null +++ b/packages/fresh/src/middlewares/cache_test.ts @@ -0,0 +1,353 @@ +// deno-lint-ignore-file require-await +import { cache } from "./cache.ts"; +import { expect } from "@std/expect"; +import { serveMiddleware } from "../test_utils.ts"; + +const TEST_CACHE = "fresh-test-cache"; + +async function clearCache() { + await caches.delete(TEST_CACHE); +} + +function cacheableResponse(body: string, headers?: HeadersInit): Response { + return new Response(body, { + headers: { + "Cache-Control": "public, max-age=60", + ...Object.fromEntries( + headers instanceof Headers + ? headers.entries() + : Array.isArray(headers) + ? headers + : Object.entries(headers ?? {}), + ), + }, + }); +} + +Deno.test("cache - miss then hit", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return cacheableResponse("hello"); + }, + }); + + // First request — cache miss + const res1 = await server.get("/test"); + expect(await res1.text()).toBe("hello"); + expect(callCount).toBe(1); + + // Second request — cache hit + const res2 = await server.get("/test"); + expect(await res2.text()).toBe("hello"); + expect(callCount).toBe(1); + + await clearCache(); +}); + +Deno.test("cache - skips non-GET methods", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return cacheableResponse("ok"); + }, + }); + + await server.post("/test", "body"); + await server.post("/test", "body"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - skips responses without Cache-Control", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return new Response("no cache headers"); + }, + }); + + await server.get("/test"); + await server.get("/test"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - skips private responses", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return new Response("private", { + headers: { "Cache-Control": "private, max-age=60" }, + }); + }, + }); + + await server.get("/test"); + await server.get("/test"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - skips no-store responses", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return new Response("no-store", { + headers: { "Cache-Control": "public, no-store, max-age=60" }, + }); + }, + }); + + await server.get("/test"); + await server.get("/test"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - skips responses with Set-Cookie", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return cacheableResponse("cookie", { + "Set-Cookie": "session=abc", + }); + }, + }); + + await server.get("/test"); + await server.get("/test"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - skips non-200 responses", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return new Response("not found", { + status: 404, + headers: { "Cache-Control": "public, max-age=60" }, + }); + }, + }); + + await server.get("/test"); + await server.get("/test"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - skips partial requests", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return cacheableResponse("partial"); + }, + }); + + await server.get("/test?fresh-partial"); + await server.get("/test?fresh-partial"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - different paths are cached separately", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return cacheableResponse(`response-${callCount}`); + }, + }); + + const res1 = await server.get("/a"); + expect(await res1.text()).toBe("response-1"); + + const res2 = await server.get("/b"); + expect(await res2.text()).toBe("response-2"); + + // Both should be cached independently + const res3 = await server.get("/a"); + expect(await res3.text()).toBe("response-1"); + + const res4 = await server.get("/b"); + expect(await res4.text()).toBe("response-2"); + + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - expired entry triggers re-fetch", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return cacheableResponse(`v${callCount}`); + }, + }); + + // Prime the cache + const prime = await server.get("/test"); + await prime.body?.cancel(); + expect(callCount).toBe(1); + + // Manually insert an expired entry + const store = await caches.open(TEST_CACHE); + const expiredResponse = new Response("stale", { + headers: { + "Cache-Control": "public, max-age=60", + "X-Fresh-Cached-At": String(Date.now() - 120_000), // 2 min ago + }, + }); + await store.put(new Request("http://localhost/expired"), expiredResponse); + + // Request should re-fetch since max-age=60 and it was cached 120s ago + const res = await server.get("/expired"); + expect(await res.text()).toBe("v2"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - stale-while-revalidate serves stale and revalidates", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ cacheName: TEST_CACHE }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return new Response(`v${callCount}`, { + headers: { + "Cache-Control": "public, max-age=10, stale-while-revalidate=300", + }, + }); + }, + }); + + // Prime the cache + await server.get("/swr"); + expect(callCount).toBe(1); + + // Insert a stale-but-within-SWR entry (15s old, max-age=10, swr=300) + const store = await caches.open(TEST_CACHE); + const staleResponse = new Response("stale-content", { + headers: { + "Cache-Control": "public, max-age=10, stale-while-revalidate=300", + "X-Fresh-Cached-At": String(Date.now() - 15_000), + }, + }); + await store.put(new Request("http://localhost/swr"), staleResponse); + + // Should serve the stale content immediately + const res = await server.get("/swr"); + expect(await res.text()).toBe("stale-content"); + + // Background revalidation should have fired + // Give it a moment to complete + await new Promise((r) => setTimeout(r, 100)); + + // Now the cache should have the fresh content + const res2 = await server.get("/swr"); + expect(await res2.text()).toBe("v2"); + expect(callCount).toBe(2); + + await clearCache(); +}); + +Deno.test("cache - custom shouldCache function", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ + cacheName: TEST_CACHE, + shouldCache: (_req, res) => res.headers.get("X-Cache-Me") === "yes", + }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return new Response("custom", { + headers: { "X-Cache-Me": "yes" }, + }); + }, + }); + + const res1 = await server.get("/test"); + await res1.body?.cancel(); + const res2 = await server.get("/test"); + await res2.body?.cancel(); + expect(callCount).toBe(1); + + await clearCache(); +}); + +Deno.test("cache - custom methods", async () => { + await clearCache(); + let callCount = 0; + + const middleware = cache({ + cacheName: TEST_CACHE, + methods: ["GET", "HEAD"], + }); + const server = serveMiddleware(middleware, { + next: async () => { + callCount++; + return cacheableResponse("ok"); + }, + }); + + const res1 = await server.get("/test"); + await res1.body?.cancel(); + const res2 = await server.get("/test"); + await res2.body?.cancel(); + expect(callCount).toBe(1); + + await clearCache(); +}); diff --git a/packages/fresh/src/mod.ts b/packages/fresh/src/mod.ts index 58ed6019cd3..3922d8367a6 100644 --- a/packages/fresh/src/mod.ts +++ b/packages/fresh/src/mod.ts @@ -18,6 +18,7 @@ export { type IpFilterOptions, type IpFilterRules, } from "./middlewares/ip_filter.ts"; +export { cache, type CacheOptions } from "./middlewares/cache.ts"; export { csp, type CSPOptions } from "./middlewares/csp.ts"; export type { FreshConfig, ResolvedFreshConfig } from "./config.ts"; export type {