Skip to content

Commit c3501ea

Browse files
committed
fix(http)!: honor no-cache, zero lifetimes and Vary:* as storage opt-outs
s-maxage takes precedence over max-age, and {maxAge: 0} is no longer stored.
1 parent 14f8019 commit c3501ea

9 files changed

Lines changed: 711 additions & 41 deletions

File tree

β€ŽAGENTS.mdβ€Ž

Lines changed: 6 additions & 4 deletions
Large diffs are not rendered by default.

β€ŽREADME.mdβ€Ž

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ reaches the handler untouched and its response passes straight through. Of the
174174
responses, only `200`, `203`, `301` and `308` are stored β€” and a status that isn't
175175
stored is never advertised with a synthesized `Cache-Control` either.
176176
177+
A response that opts itself out is returned to the caller but never stored:
178+
`Cache-Control: no-store`, `private`, `no-cache`, a zero shared lifetime (`s-maxage` if
179+
present, else `max-age`), or `Vary: *`. `must-revalidate` is not an opt-out β€” such a
180+
response is stored and served fresh, but never served stale (it revalidates in the
181+
foreground once expired).
182+
177183
**Parameters:**
178184
179185
- **`handler`** β€” The event handler to cache.

β€Ždocs/1.guide/5.handler.mdβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,5 +258,5 @@ See [ISR β†’ on-demand revalidation](/guide/isr#on-demand-revalidation) for the
258258
259259
- [Query Parameters](/guide/query-params) β€” control which params vary the cache.
260260
- [Cookies](/guide/cookies) β€” the secure default and how to opt cookies back in.
261-
- [Cache-Control & Eligibility](/guide/cache-control) β€” `no-store`/`private`, `sendCacheControl`, `shouldCache`.
261+
- [Cache-Control & Eligibility](/guide/cache-control) β€” response-side opt-outs (`no-store`/`private`/`no-cache`/`max-age=0`), `must-revalidate`, `sendCacheControl`, `shouldCache`.
262262
- [Incremental Static Regeneration (ISR)](/guide/isr) β€” serve stale, revalidate in the background.

β€Ždocs/1.guide/8.cache-control.mdβ€Ž

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,34 @@ title: Cache-Control & Eligibility
1212
Before storing a response, ocache applies these built-in checks. A response failing **any** of them is still returned to the caller, but is never stored or served from the cache:
1313

1414
- **Any status outside `200`, `203`, `301`, `308`** β€” the storable statuses are an explicit allowlist, not "anything that isn't an error". Those four are the ones whose response is a complete, reusable representation of what was requested. Everything else is refused, including `4xx`/`5xx` (an error is not a representation), `201`/`202`/`300` (the outcome of an operation, or an unresolved choice), `204`/`205`/`304` (nothing to replay β€” a `304` in particular answers one client's conditional request), `206` (a partial body, valid only for the range that was asked for) and the temporary redirects `302`/`303`/`307` (a per-request answer: an auth redirect to `/login` stored under an anonymous key would be served to everyone else hitting that URL).
15-
- **`Cache-Control: no-store` or `private`** β€” an explicit opt-out from the handler.
15+
- **A `Cache-Control` opt-out from the handler** β€” `no-store`, `private`, `no-cache`, or a zero **shared** lifetime (`s-maxage` when present, `max-age` otherwise). See [Private / non-cacheable responses](#private--non-cacheable-responses).
16+
- **`Vary: *`** β€” the strongest "do not share this" signal short of `no-store`: no stored response may ever match such a request (RFC 9111 Β§4.1). ocache keeps one entry per cache key, so the only honest handling is to refuse the entry.
1617
- **Empty body** β€” nothing to replay.
1718
- **Missing `etag` / `last-modified`** β€” required for conditional requests (ocache adds these itself when the handler omits them, so this mainly guards handler-set placeholder values).
1819

1920
These checks always apply and cannot be turned off.
2021

21-
A non-storable **status** also suppresses `Cache-Control` synthesis: ocache never advertises a lifetime for a response it will not store itself. A `500` is returned to the caller with no synthesized `Cache-Control` at all, so a shared cache in front of your handler doesn't pin the error for `maxAge + staleMaxAge` while ocache re-runs the handler on every request. (A `Cache-Control` the handler set itself is always preserved, whatever the status.)
22+
A response the built-in checks refuse also gets **no synthesized `Cache-Control`** β€” with one deliberate exception, `shouldCache`, below. A `500` is returned to the caller with no synthesized `Cache-Control` at all, so a shared cache in front of your handler doesn't pin the error for `maxAge + staleMaxAge` while ocache re-runs the handler on every request; the same goes for a `Vary: *` response. The `Cache-Control` opt-outs need no special handling here: they _are_ a `Cache-Control` the handler set, and ocache never overwrites one (whatever the status), so its directive is what reaches the client.
23+
24+
The exception is [`shouldCache`](#custom-eligibility-shouldcache): a response your own hook rejects is still advertised with the synthesized lifetime. That is on purpose β€” "ocache doesn't keep this, but a CDN may" is a legitimate setup, and `sendCacheControl: false` is its inverse ("ocache keeps this, but nobody downstream may"). Reach for that one, or set a `Cache-Control` on the response, if you want the advertisement to go away too.
2225

2326
## Private / non-cacheable responses
2427

25-
To keep a specific response out of the cache from inside the handler, set `no-store` or `private`:
28+
To keep a specific response out of the cache from inside the handler, set a `Cache-Control` that says so. All of these are honored β€” the response is returned to the caller with its header intact, but never stored and never served from the cache:
29+
30+
| Directive | Meaning |
31+
| -------------------------- | ---------------------------------------------- |
32+
| `no-store` | Never store this anywhere. |
33+
| `private` | Not in a shared cache β€” and ocache is one. |
34+
| `no-cache` | Must not be reused without revalidating first. |
35+
| `max-age=0` / `s-maxage=0` | Stale the moment it was produced. |
36+
37+
Directives are parsed properly, not substring-matched: `max-age=0600` is 600 seconds, and a `0` in some other directive's value (`stale-while-revalidate=0`) is not a zero lifetime.
38+
39+
`s-maxage` overrides `max-age` for shared caches (RFC 9111 Β§5.2.2.10), and ocache is one β€” so when both are set, only `s-maxage` decides. `public, max-age=0, s-maxage=600` ("browsers revalidate, shared caches keep it for 10 minutes") is cached for 600 seconds, and `public, s-maxage=0, max-age=600` is not cached at all.
40+
41+
> [!NOTE]
42+
> The same rule applies to the header ocache synthesizes for itself: configuring `maxAge: 0` advertises a zero lifetime (`max-age=0`, or `s-maxage=0` under `swr`) and therefore stores nothing. That is what a zero lifetime means β€” such an entry would be expired the moment it was written, so the handler runs on every request either way.
2643
2744
```ts
2845
const handler = defineCachedHandler(
@@ -46,11 +63,31 @@ const handler = defineCachedHandler(
4663
Any **other** `Cache-Control` the handler sets is preserved verbatim β€” ocache only synthesizes `max-age` / `s-maxage` / `stale-while-revalidate` when the handler didn't set a `Cache-Control` of its own.
4764

4865
> [!IMPORTANT]
49-
> `no-store` / `private` only controls what gets **stored** β€” it does not partition the cache key. Concurrent requests are still coalesced by cache key, so responses that differ per user must be keyed correctly (via [`varies`](/guide/handler) or [`allowCookies`](/guide/cookies)) or a personalized body could be shared.
66+
> An opt-out only controls what gets **stored** β€” it does not partition the cache key. Concurrent requests are still coalesced by cache key, so responses that differ per user must be keyed correctly (via [`varies`](/guide/handler) or [`allowCookies`](/guide/cookies)) or a personalized body could be shared.
67+
68+
> [!NOTE]
69+
> `no-cache` is currently rejected outright rather than stored-and-always-revalidated. RFC 9111 Β§5.2.2.4 permits storing such a response as long as it is revalidated with the origin before every reuse; ocache has no foreground-revalidation path today, so "stored but always revalidated" would buy nothing over not storing.
70+
71+
## Never serve this stale (`must-revalidate`)
72+
73+
`must-revalidate` is **not** an opt-out: it constrains stale serving, not storage. A response carrying it is stored and served from cache normally while it is fresh β€” but it is never served stale, even with `swr: true` and a wide `staleMaxAge`. Once the entry expires, the next request revalidates in the **foreground** and waits for the fresh response:
74+
75+
```ts
76+
const handler = defineCachedHandler(
77+
async () => {
78+
return new Response(await renderPrices(), {
79+
headers: { "cache-control": "public, max-age=60, must-revalidate" },
80+
});
81+
},
82+
{ maxAge: 60, swr: true, staleMaxAge: 600 }, // SWR everywhere else, never for this response
83+
);
84+
```
85+
86+
Internally this is a per-entry `staleMaxAge: 0` β€” the same mechanism [`getMaxAge`](/guide/api) uses to give one entry its own lifetime β€” so it applies to that response only, not to the whole handler.
5087

5188
## Server-only caching (`sendCacheControl`)
5289

53-
Sometimes you want to cache a response **in your own storage** (to save recomputing it) while telling browsers and CDNs _not_ to cache it β€” for example a page that is expensive to build but must always be revalidated downstream. Reaching for `no-store` / `private` doesn't work here: those also disqualify the response from storage.
90+
Sometimes you want to cache a response **in your own storage** (to save recomputing it) while telling browsers and CDNs _not_ to cache it β€” for example a page that is expensive to build but must always be revalidated downstream. Reaching for `no-store` / `private` doesn't work here: those (and every other opt-out above) also disqualify the response from storage.
5491

5592
Set `sendCacheControl: false` to decouple the two. The response is still stored and served from cache (SWR, `etag`, and `last-modified` all still apply), but no `Cache-Control` header is emitted:
5693

β€Ždocs/1.guide/9.isr.mdβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Two options make it ISR-like:
3030
With this config the handler also emits `Cache-Control: s-maxage=60, stale-while-revalidate`, so any shared/CDN cache in front of it revalidates on the same schedule. (Set `staleMaxAge` and the directive becomes `s-maxage=60, stale-while-revalidate=<staleMaxAge>`.)
3131

3232
> [!NOTE]
33-
> If your handler sets its own `Cache-Control`, ocache leaves it untouched and won't synthesize one. See [Cache-Control](/guide/cache-control).
33+
> If your handler sets its own `Cache-Control`, ocache leaves it untouched and won't synthesize one β€” and that header is also read: an opt-out (`no-store`, `private`, `no-cache`, `max-age=0`) keeps the page out of the cache entirely, and `must-revalidate` keeps it cached but never served stale, which switches ISR off for that page. See [Cache-Control](/guide/cache-control).
3434
3535
## On-demand revalidation
3636

0 commit comments

Comments
Β (0)