Skip to content

Commit 825492b

Browse files
committed
Add cache status
1 parent 90da8f9 commit 825492b

File tree

8 files changed

+420
-232
lines changed

8 files changed

+420
-232
lines changed

packages/cache-handlers/README.md

Lines changed: 109 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,76 @@
11
# cache-handlers
22

3-
Unified, modern HTTP caching + invalidation + conditional requests built directly on standard Web APIs (`Request`, `Response`, `CacheStorage`). One small API: `createCacheHandler` – works on Cloudflare Workers, Netlify Edge, Deno, workerd, and Node 20+ (with Undici polyfills).
3+
Fully-featured, modern, standards-based HTTP caching library designed for server-side rendered web apps. Get the features of a modern CDN built into your app.
44

5-
## Highlights
5+
Modern CDNs such as Cloudflare, Netlify and Fastly include powerful features that allow you to cache responses, serve stale content while revalidating in the background, and invalidate cached content by tags or paths. This library brings those capabilities to your server-side code with a simple API. This is particularly useful if you're not running your app behind a modern caching CDN. Ironically, this includes Cloudflare, because Workers run in front of the cache.
66

7-
- Single handler: read -> serve (fresh/stale) -> optional background revalidate (SWR) -> write
8-
- Uses only standard headers for core caching logic: `Cache-Control` (+ `stale-while-revalidate`), `CDN-Cache-Control`, `Cache-Tag`, `Vary`, `ETag`, `Last-Modified`
9-
- Optional custom extension header: `Cache-Vary` (library-defined – lets your backend declare specific header/cookie/query components for key derivation without bloating the standard `Vary` header)
10-
- Stale-While-Revalidate implemented purely via directives (no custom headers)
11-
- Tag & path invalidation helpers (`invalidateByTag`, `invalidateByPath`, `invalidateAll` + stats)
12-
- Optional automatic ETag generation & conditional 304 responses
13-
- Backend-driven Vary via custom `Cache-Vary` (header= / cookie= / query=)
14-
- Zero runtime dependencies, ESM only, fully typed
15-
- Same code everywhere (Edge runtimes, Deno, Node + Undici)
7+
## How it works
8+
9+
Set standard HTTP headers in your SSR pages or API responses, and this library will handle caching. It will cache responses as needed, and return cached data if available. It supports standard headers like `Cache-Control`, `CDN-Cache-Control`, `Cache-Tag`, `Vary`, `ETag`, and `Last-Modified`. It can handle conditional requests using `If-Modified-Since` and `If-None-Match`. This also supports a custom `Cache-Vary` header (inspired by [`Netlify-Vary`](https://www.netlify.com/blog/netlify-cache-key-variations/)) that allows you to specify which headers, cookies, or query parameters should be used for caching.
10+
11+
## Supported runtimes
12+
13+
This library uses the web stqndard [`CacheStorage`](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage) API for storage, which is available in modern runtimes like Cloudflare Workers, Netlify Edge and Deno. It can also be used in Node.js using the Node.js [`undici`](https://undici.nodejs.org/) polyfill.
1614

1715
## Install
1816

1917
```bash
20-
pnpm add cache-handlers
18+
pnpm i cache-handlers
2119
# or
2220
npm i cache-handlers
2321
```
2422

2523
## Quick Start
2624

2725
```ts
28-
import { createCacheHandler } from "cache-handlers";
29-
30-
async function upstream(req: Request) {
26+
// handleRequest.ts
27+
export async function handleRequest(_req: Request) {
3128
return new Response("Hello World", {
3229
headers: {
3330
// Fresh for 60s, allow serving stale for 5m while background refresh runs
34-
"cache-control": "public, max-age=60, stale-while-revalidate=300",
31+
"cdn-cache-control": "public, max-age=60, stale-while-revalidate=300",
3532
// Tag for later invalidation
3633
"cache-tag": "home, content",
3734
},
3835
});
3936
}
4037

41-
const handle = createCacheHandler({
42-
cacheName: "app-cache",
43-
handler: upstream,
44-
features: { conditionalRequests: { etag: "generate" } },
45-
});
38+
// route.ts
39+
import { createCacheHandler } from "cache-handlers";
40+
import { handleRequest } from "./handleRequest.js";
4641

47-
addEventListener("fetch", (event: FetchEvent) => {
48-
event.respondWith(handle(event.request));
42+
export const GET = createCacheHandler({
43+
handler: handleRequest,
44+
features: { conditionalRequests: { etag: "generate" } },
4945
});
5046
```
5147

52-
### Lifecycle
53-
54-
1. Request arrives; cache checked (GET only is cached)
55-
2. Miss -> `handler` runs, response cached
56-
3. Hit & still fresh -> served instantly
57-
4. Expired but inside `stale-while-revalidate` window -> stale response served, background revalidation queued
58-
5. Conditional client request (If-None-Match / If-Modified-Since) may yield a 304
59-
6048
## Node 20+ Usage (Undici Polyfill)
6149

62-
Node 20 ships `fetch` et al, but _not_ `caches` yet. Use `undici` to polyfill CacheStorage.
50+
Node.js ships `CacheStorage` as part of `Undici`, but it is not available by default. To use it, you need to install the `undici` polyfill and set it up in your code:
51+
52+
```bash
53+
pnpm i undici
54+
# or
55+
npm i undici
56+
```
6357

6458
```ts
6559
import { createServer } from "node:http";
66-
import { caches, install } from "undici"; // polyfills
60+
import { caches, install } from "undici";
6761
import { createCacheHandler } from "cache-handlers";
62+
// Use Unidici for Request, Response, Headers, etc.
63+
install();
6864

69-
if (!globalThis.caches) {
70-
// @ts-ignore
71-
globalThis.caches = caches as unknown as CacheStorage;
72-
}
73-
install(); // idempotent
74-
65+
import { handleRequest } from "./handleRequest.js";
7566
const handle = createCacheHandler({
76-
cacheName: "node-cache",
77-
handler: (req) => fetch(req),
67+
handler: handleRequest,
7868
features: { conditionalRequests: { etag: "generate" } },
7969
});
8070

8171
createServer(async (req, res) => {
82-
const request = new Request(`http://localhost:3000${req.url}`, {
72+
const url = new URL(req.url ?? "/", "http://localhost:3000");
73+
const request = new Request(url, {
8374
method: req.method,
8475
headers: req.headers as HeadersInit,
8576
});
@@ -93,7 +84,7 @@ createServer(async (req, res) => {
9384
} else {
9485
res.end();
9586
}
96-
}).listen(3000, () => console.log("Listening on :3000"));
87+
}).listen(3000, () => console.log("Listening on http://localhost:3000"));
9788
```
9889

9990
## Other Runtimes
@@ -102,27 +93,25 @@ createServer(async (req, res) => {
10293

10394
```ts
10495
import { createCacheHandler } from "cache-handlers";
105-
96+
import handler from "./handler.js"; // Your ssr handler function
10697
const handle = createCacheHandler({
107-
cacheName: "cf-cache",
108-
handler: (req) => fetch(req),
98+
handler,
10999
});
110-
111-
export default { fetch: (req: Request) => handle(req) };
112-
```
113-
114-
### Netlify Edge
115-
116-
```ts
117-
import { createCacheHandler } from "cache-handlers";
118-
export default createCacheHandler({ handler: (r) => fetch(r) });
100+
export default {
101+
async fetch(request, env, ctx) {
102+
return handle(request, {
103+
runInBackground: ctx.waitUntil,
104+
});
105+
},
106+
};
119107
```
120108

121109
### Deno / Deploy
122110

123111
```ts
124-
import { createCacheHandler } from "cache-handlers";
125-
const handle = createCacheHandler({ handler: (r) => fetch(r) });
112+
import { createCacheHandler } from "jsr:@ascorbic/cache-handlers";
113+
import { handleRequest } from "./handleRequest.ts";
114+
const handle = createCacheHandler({ handler: handleRequest });
126115
Deno.serve((req) => handle(req));
127116
```
128117

@@ -131,18 +120,20 @@ Deno.serve((req) => handle(req));
131120
Just send the directive in your upstream response:
132121

133122
```http
134-
Cache-Control: public, max-age=30, stale-while-revalidate=300
123+
CDN-Cache-Control: public, max-age=30, stale-while-revalidate=300
135124
```
136125

137-
No custom headers are added. While inside the SWR window the _stale_ cached response is returned immediately and a background revalidation run is triggered (if a `handler` was supplied).
126+
While inside the SWR window the _stale_ cached response is returned immediately and a background revalidation run is triggered.
138127

139128
To use a runtime scheduler (eg Workers' `event.waitUntil`):
140129

141130
```ts
131+
import handler from "./handler.js";
132+
142133
addEventListener("fetch", (event) => {
143134
const handle = createCacheHandler({
144-
handler: (r) => fetch(r),
145-
runInBackground: (p) => event.waitUntil(p),
135+
handler: handleRequest,
136+
runInBackground: event.waitUntil,
146137
});
147138
event.respondWith(handle(event.request));
148139
});
@@ -167,36 +158,40 @@ const stats = await getCacheStats();
167158
console.log(stats.totalEntries, stats.entriesByTag);
168159
```
169160

170-
## Configuration Overview (`CreateCacheHandlerOptions`)
171-
172-
| Option | Purpose |
173-
| ------------------------------------------- | -------------------------------------------------------- |
174-
| `cacheName` | Named cache to open (default `cache-primitives-default`) |
175-
| `cache` | Provide a `Cache` instance directly |
176-
| `handler` | Function invoked on misses / background revalidation |
177-
| `defaultTtl` | Fallback TTL (seconds) when no cache headers present |
178-
| `maxTtl` | Upper bound to clamp any TTL (seconds) |
179-
| `getCacheKey` | Custom key generator `(request) => string` |
180-
| `runInBackground` | Scheduler for SWR tasks (eg `waitUntil`) |
181-
| `features.conditionalRequests` | `true`, `false` or config object (ETag, Last-Modified) |
182-
| `features.cacheTags` | Enable `Cache-Tag` parsing (default true) |
183-
| `features.cacheVary` | Enable `Cache-Vary` parsing (default true) |
184-
| `features.vary` | Respect standard `Vary` header (default true) |
185-
| `features.cacheControl` / `cdnCacheControl` | Header support toggles |
161+
## Configuration Overview (`CacheConfig`)
162+
163+
| Option | Purpose |
164+
| ------------------------------------------- | ---------------------------------------------------------------------------------------------- |
165+
| `cacheName` | Named cache to open (defaults to `caches.default` if present, else `cache-primitives-default`) |
166+
| `cache` | Provide a `Cache` instance directly |
167+
| `handler` | Function invoked on misses / background revalidation |
168+
| `swr` | SWR policy: `background` (default), `blocking`, or `off` |
169+
| `defaultTtl` | Fallback TTL (seconds) when no cache headers present |
170+
| `maxTtl` | Upper bound to clamp any TTL (seconds) |
171+
| `getCacheKey` | Custom key generator `(request) => string` |
172+
| `runInBackground` | Scheduler for SWR tasks (eg `waitUntil`) |
173+
| `features.conditionalRequests` | `true`, `false` or config object (ETag, Last-Modified) |
174+
| `features.cacheTags` | Enable `Cache-Tag` parsing (default true) |
175+
| `features.cacheVary` | Enable `Cache-Vary` parsing (default true) |
176+
| `features.vary` | Respect standard `Vary` header (default true) |
177+
| `features.cacheControl` / `cdnCacheControl` | Header support toggles |
178+
| `features.cacheStatusHeader` | Emit `Cache-Status` header (boolean = default name, string = custom name) |
186179

187180
Minimal example:
188181

189182
```ts
190-
createCacheHandler({ handler: (r) => fetch(r) });
183+
import { handleRequest } from "./handleRequest.js";
184+
createCacheHandler({ handler: handleRequest });
191185
```
192186

193187
## Conditional Requests (ETag / Last-Modified)
194188

195189
Enable with auto ETag generation:
196190

197191
```ts
192+
import { handleRequest } from "./handleRequest.js";
198193
createCacheHandler({
199-
handler: (r) => fetch(r),
194+
handler: handleRequest,
200195
features: { conditionalRequests: { etag: "generate", lastModified: true } },
201196
});
202197
```
@@ -240,19 +235,52 @@ Cache-Vary: header=Accept-Language, cookie=session_id, query=version
240235

241236
Each listed dimension becomes part of the derived cache key. Standard `Vary` remains fully respected; `Cache-Vary` is additive and internal – safe to use even if unknown to intermediaries.
242237

238+
## Cache-Status Header (optional)
239+
240+
You can opt-in to emitting the [RFC 9211 `Cache-Status`](https://www.rfc-editor.org/rfc/rfc9211) response header to aid debugging and observability.
241+
242+
Enable it with a boolean (uses the default cache name `cache-handlers`) or provide a custom cache identifier string:
243+
244+
```ts
245+
import { handleRequest } from "./handleRequest.js";
246+
createCacheHandler({
247+
handler: handleRequest,
248+
features: { cacheStatusHeader: true }, // => Cache-Status: cache-handlers; miss; ttl=59
249+
});
250+
251+
createCacheHandler({
252+
handler: handleRequest,
253+
features: { cacheStatusHeader: "edge-cache" }, // => Cache-Status: edge-cache; hit; ttl=42
254+
});
255+
```
256+
257+
Format emitted:
258+
259+
```
260+
Cache-Status: <name>; miss; ttl=123
261+
Cache-Status: <name>; hit; ttl=120
262+
Cache-Status: <name>; hit; stale; ttl=0
263+
```
264+
265+
Notes:
266+
* `ttl` is derived from the `Expires` header if present.
267+
* `stale` appears when within the `stale-while-revalidate` window.
268+
* Header is omitted entirely when the feature flag is disabled (default).
269+
243270
## Types
244271

245272
```ts
246273
import type {
247274
CacheConfig,
248275
CacheHandle,
249-
CacheHandleOptions,
276+
CacheInvokeOptions,
250277
ConditionalRequestConfig,
251-
CreateCacheHandlerOptions,
278+
ConditionalValidationResult,
252279
HandlerFunction,
253280
HandlerInfo,
254281
HandlerMode,
255282
InvalidationOptions,
283+
SWRPolicy,
256284
} from "cache-handlers";
257285
```
258286

@@ -264,26 +292,6 @@ import type {
264292
4. Generate or preserve ETags to leverage client 304s.
265293
5. Keep cache keys stable & explicit if customizing via `getCacheKey`.
266294

267-
## Troubleshooting
268-
269-
| Symptom | Check |
270-
| --------------------- | ---------------------------------------------------------------------------------------------------------- |
271-
| Response never cached | Ensure it's a GET and has `Cache-Control`/`CDN-Cache-Control` permitting caching (no `no-store`/`private`) |
272-
| Invalidation no-op | Response needs a `Cache-Tag` matching the tag you pass |
273-
| SWR not triggering | Make sure `stale-while-revalidate` directive is present and entry has expired `max-age` |
274-
| 304s never served | Enable `conditionalRequests` and return `ETag` or `Last-Modified` |
275-
276-
## Changelog (Summary)
277-
278-
### 0.1.0
279-
280-
- Unified `createCacheHandler` (replaces separate read/write/middleware APIs)
281-
- Directive-based SWR (no custom headers)
282-
- Tag & path invalidation + stats
283-
- Conditional requests (ETag / Last-Modified / 304 generation)
284-
- Backend-driven variation via `Cache-Vary`
285-
- Cross-runtime compatibility (Workers / Netlify / Deno / Node+Undici / workerd)
286-
287295
## License
288296

289297
MIT

0 commit comments

Comments
 (0)