of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️
@point0/cache-control sets the Cache-Control header. Point0's handlers leave
it unset, so browsers and CDNs are left to guess how long a response may live.
cacheControl(options) returns a Point0 middleware: mount it on
your root and every response variant gets a correct value.
import { Point0 } from '@point0/core'
import { cacheControl } from '@point0/cache-control'
export const root = Point0.lets
.root()
.middleware(cacheControl()) // ← every response gets a correct Cache-Control
// ...
.root()The bare cacheControl() already does the right thing:
| Variant | Default | Why |
|---|---|---|
asset | public, max-age=31536000, immutable | content-hashed URLs (build chunks, /_point0/assets/*) can never serve different bytes — cache forever |
publicdir | public, max-age=3600 | stable names, mutable content (favicons, robots.txt, …) — cache briefly, revalidate |
page | private, no-store | SSR HTML embeds the dehydrated store — the current user's data — a shared cache must never store it |
error | private, no-store | error HTML is rendered the same way |
endpoint | — (untouched) | API caching is the endpoint's own decision — set it in the handler when you want it |
The page/error default is a safety property, not a taste: a misconfigured
CDN in front of an app whose HTML has no Cache-Control can cache one user's
server-rendered page — with their data dehydrated into it — and serve it to the
next user. private, no-store closes that door by default.
asset / publicdir splitThe engine itself classifies every statically served file into two
request variants by what the URL promises. An asset
is a file whose name carries a content hash — a bundler chunk of the built
client (including the entry) or the asset pipeline's
/_point0/assets/<hash>.<ext>. The build persists the exact list of those files
into _point0/<scope>/build-assets.json, and the server classifies against it —
so the cache-forever set is exact, never guessed from file name patterns.
Everything else the publicdir serves keeps a stable name and stays publicdir.
That's why the middleware needs no path regexes and no isImmutable callback:
the framework already knows which of its files are immutable.
cacheControl() never overwrites a Cache-Control the app already set — an
auth action's or a download's no-store survives untouched:
.loader(({ set }) => {
set.headers('Cache-Control', 'no-store') // ← cacheControl() will not touch this response
// ...
})Every option slot takes a header string (set it) or undefined (skip —
leave the response's header untouched). The built-in default applies only when
you leave the slot out; passing the slot — even as undefined — means
you're taking control:
cacheControl({
page: 'public, max-age=60', // a fully public site may let its HTML be cached
publicdir: undefined, // skip static files — don't set any Cache-Control
})Deleting a header (false) is not a slot value — that's an
override capability, because it would
overrule a header the app set. Slots stay safe: they set-or-skip, and always
defer to an app-set header (above).
A callback decides per response. It receives the same detailed result object the
middleware got back from next() — response, request, variant (narrowed
to the slot's variant), scope, error — and returns a string (set) or
undefined (skip). Note undefined skips; it does not fall back to the
default — to keep the default from a callback, return it explicitly:
import { cacheControl, cacheControlValues } from '@point0/cache-control'
cacheControl({
publicdir: ({ request }) =>
request.location.pathname.endsWith('.pdf')
? 'public, max-age=604800' // PDFs are versioned by hand — cache a week
: cacheControlValues.revalidate, // everything else: keep the default explicitly
})cacheControlValues exports the three default strings (immutable,
revalidate, noStore) so callbacks don't retype header values.
override — the top-level escape hatchThe slots only cover the five variants above, and they always defer to a
Cache-Control the app already set. override sits above both: it runs for
every response and above the existing-header rule. It returns a string
(set it, overwriting any existing value), false (delete the header), or
undefined (fall through to the normal per-variant procedure).
cacheControl({
// maintenance window: force no-store on everything, even responses that set their own header
override: () => 'no-store',
})Two things only override can do: reach variants without a slot (middleware,
options), and overrule a header a handler set. Because it bypasses the
existing-header rule, an override that wants to respect an app-set header
checks result.response.headers itself:
cacheControl({
override: ({ response, request }) =>
request.location.pathname.startsWith('/downloads/') &&
!response.headers.has('cache-control')
? 'private, max-age=0'
: undefined, // everything else: normal procedure
})cacheControl() and compress() compose in either order —
compress copies response headers onto the compressed response, so the
Cache-Control rides along:
.middleware(compress())
.middleware(cacheControl())cacheControl(options)Every option is optional; cacheControl() with no argument is the correct
default for a typical app. Each slot is
string | undefined | (result) => string | undefined | Promise<...> (set / skip
— no false; deletion is override-only).
| Option | Default | Applies to |
|---|---|---|
asset | public, max-age=31536000, immutable | content-hashed build files |
publicdir | public, max-age=3600 | stable-name static files |
page | private, no-store | server-rendered page HTML |
error | private, no-store | error responses |
endpoint | untouched (unconfigured) | query/mutation/action endpoints |
override | — (runs for every variant) | top-level escape hatch: string sets (overwriting), false deletes, undefined defers |
| Aspect | Behavior |
|---|---|
| Side | cut from the client bundle — the .middleware(...) argument and its imports are removed, so it never ships to the browser |
| Existing header | a Cache-Control already on the response always wins — unless override overrules it |
| Other variants | middleware / options have no slot; only override can set them |
| Slot value | string sets, undefined skips; the built-in default applies only when the slot is left unconfigured |
Callback undefined | skips (does not fall back to the default — return the default explicitly to keep it) |
| Streamed endpoints | a defer()-streamed NDJSON response already carries private, no-store from the framework, so the existing-header rule leaves it alone — an endpoint policy only ever caches the non-streamed variant |