Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions docs/latest/plugins/cache.md
Original file line number Diff line number Diff line change
@@ -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<typeof handler>(({ data }) => {
return <article>{data.post.title}</article>;
});
```

## 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).
1 change: 1 addition & 0 deletions docs/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
193 changes: 193 additions & 0 deletions packages/fresh/src/middlewares/cache.ts
Original file line number Diff line number Diff line change
@@ -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<State>(options?: CacheOptions): Middleware<State> {
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<Response>,
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.
}
});
}
Loading
Loading