Caching
Caching and the webhook
What a publish drops
A read carries a second, narrower tag beside the site's own: the entry's when it read one entry, the type's when it read a list, a search, the site settings or the page tree. barakoCMS names the content type and sends the entry's public data with every delivery, so a publish drops that type's tag and that entry's tag, and leaves every other entry of the type cached. Correcting one typo on a school with a thousand news posts re-renders that post and the lists it appears in, not the other 999.
A delivery that names no content type, or one this site renders nothing of, drops the site's own tag, which is every read it has cached. That is what every delivery did before this, so a workflow posting a body of its own keeps working.
A response barakoCMS marks Cache-Control: no-store is not cached: the class is remembered against
that path and every later read of it asks the CMS uncached, carrying no tag. That is how a type that
has to be fresh to the minute lives beside pages cached for hours.
barakoCMS 4.8 also names the class in X-Barako-Cache-Class. no-store there is read the same way.
short, long and swr are cached as every read is, under the configured backstop, and a path that
was remembered as no-store is cached again from the read after the API stops saying so. Marking a
path moves an epoch carried in the URL of its cached reads, so the copy that taught the class is never
handed back and read as no-store again. The first
no-store answer for a path is still written to Next's data cache once and never read back: Next
takes the cache options before the response exists, and asking uncached until the class is known
would make every route dynamic (#85).
Running more than one container
The kept answers, the host to tenant map, the webhook replay guard and the generation of each cache tag are in process by default, which is right for one container and wrong for two: a purge reaches one of them through the load balancer, and the others keep serving their own copies until the backstop runs out.
Pass a store on the config and they share it. It is three methods over anything the deployment
already runs:
import type { PressStore } from "barakopress";
const store: PressStore = {
async get(key) { return (await redis.get(key)) ?? null; },
async set(key, value, ttlSeconds) { await (ttlSeconds ? redis.set(key, value, { EX: ttlSeconds }) : redis.set(key, value)); },
async delete(key) { await redis.del(key); },
// Only when the key is absent, atomically. This is what honours a replayed delivery once.
async add(key, value, ttlSeconds) { return (await redis.set(key, value, { NX: true, ...(ttlSeconds ? { EX: ttlSeconds } : {}) })) !== null; },
};
A purge one container receives then reaches the rest: the honoured delivery writes a generation against each tag it dropped, and a read carries the newest generation of its own tags in the URL it asks the CMS for, so a container that was never told is asking for something it has never cached. Next's own data cache stays per container; what crosses is the knowledge that it is out of date.
What the engine does not ship
The PressStore seam and the example above are the whole contract. The engine ships no store backend
and no Next cacheHandler, and a deployment that wants either supplies it.
- No backend. The engine has one dependency, and a Redis client (or any other) is not worth the
package carrying it for every site that runs one container. You write about ten lines over what you
already run, as above.
addhas to be atomic write-if-absent, because that is what honours a replayed webhook delivery once. - No
cacheHandler. Next's data cache stays per container. A shared one would let a second container serve a warm body instead of fetching once, which saves work but is not needed for a correct answer: the generation in the read URL already stops a container serving something that was purged. If you want that saving, setcacheHandlerin your ownnext.config.
Running two servers behind one store is therefore tested as two module instances sharing one store
(src/cache.test.ts), not as two started servers. That covers the logic. The wiring of your store
client is yours to check: purge on one container, then confirm the other asks the CMS again.
Each read also carries a backstop, 300 seconds by default. That is not for correctness, it is for the deployment where nobody ever created the webhook: without it their blog would be empty forever and nothing would say why. With it, a missing webhook degrades publishing from instant to a few minutes.
The cache invalidation is configuration, not code. In barakoBrew: a workflow on each content type
the site renders, trigger Published, one Webhook action with the URL and a shared secret. Nothing is deployed
to change it.
The render cache
Reads being cached is half of it. The other half is not rendering the page again, and for a request-time site that used to be impossible: resolving the tenant meant reading the request host, reading a header makes a route dynamic, and Next never keeps a dynamic route. One container serving forty domains re-rendered every page for every visitor, with every read under it already cached.
Next keys what it keeps by the path and by nothing else. There is no Vary and no second key to
add. So whatever a render depends on has to be in the path, and three things are:
| In the segment | Why |
|---|---|
| the tenant | two tenants must never share an entry. This is the whole reason the segment exists |
| the gate | public, or shared for a request carrying a valid share session. A holding tenant answers differently for each, so each gets its own entry |
| the host | the site's origin when the tenant's settings leave Url empty. Two hosts on one tenant are two renders, not one |
The proxy writes them, as /_press/<tenant>~<gate>~<host>/<path>, and it is the only thing that
does: a path that arrives under /_press from outside is a 404, because otherwise
https://one.example/_press/other~public~other.example/ would render another tenant's site on this
tenant's domain. A host the CMS does not know is a 404 before anything renders, so an invented host
never makes an entry: only a host a tenant owns can put one there.
What is cached. The index, the pages tree, the collection indexes and the archives, per tenant
and per path. revalidateTag drops a tenant's renders along with its reads, so the webhook that was
already purging one purges both, and a publish on one tenant leaves every other tenant's renders in
place. The first few views of a path answer STALE rather than HIT while the reads under them are
still filling Next's data cache; after that it is HIT with no render and no read.
What is not cached, deliberately.
| Not cached | Why |
|---|---|
a route that reads ?preview= |
a draft must never be in a shared cache, and reading the query is what keeps the route out of it. createBlogPostPreview is such a route; createBlogPost is not |
a page binding {{query.X}} |
same reason. On a kept route the query is not handed to the blocks at all, so the binding is reported as an unbound scope and renders as nothing. A site that wants it writes createPage(config, blocks, { query: true }) and leaves generateStaticParams out of that file |
createViewerPage |
per viewer by definition. It calls connection(), and a route that does cannot be kept |
| the feed, the sitemap and robots | sitemap.ts and robots.ts have to sit at the root of the app tree, so neither can carry a tenant segment. They resolve their own tenant and stay dynamic |
/_share and the redeem route |
per visitor |
Which routes are kept is the consumer's call, in the consumer's file, the same as revalidate.
A page route that exports createSiteStaticParams() is kept; one that does not is rendered per
request. That is also the switch for the two rows above: a route that reads the query leaves it out.
Nothing downstream may keep a copy. Every rewritten answer carries Cache-Control: private, no-store, which is what a request-time site has always sent. Next's cache is the server's own and is
not that header, so the render is still kept here. What stays true is that a browser or a proxy
holding / is never holding one visitor's gate for the next visitor.
One secret
PRESS_SECRET keys everything this renderer signs: each tenant's webhook key, each tenant's binding
report key and each share session cookie. A key derived from it puts its purpose first in what it
signs (revalidate., bindings., press-share. or press-link.), so one derived for one purpose never verifies as
another. Webhook keys are derived per tenant on a request-time site, binding report keys when the
tenant is pinned or resolved per request. Elsewhere PRESS_SECRET itself is the key, so a
build-time site with no pinned tenant verifies its webhook and its binding report with the same
value. It is read per request and has one rule for every
purpose: at least 32 characters, for example openssl rand -base64 48.
When PRESS_SECRET is |
Webhooks | Share sessions | Binding reports |
|---|---|---|---|
| 32 characters or more | verified with it | signed with it | verified with it |
| set but shorter | refused with 503 | none issued or accepted | refused with 503 |
| unset | REVALIDATE_SECRET, as in 0.3.0 |
PRESS_PREVIEW_SECRET |
refused with 503, since there is no older name |
The older names are read only when PRESS_SECRET is unset, so a site that set them keeps working, and
the keys derive byte for byte as before, so a key already pasted into a tenant's workflow keeps
verifying. A REVALIDATE_SECRET shorter than 32 characters still verifies and logs a warning once,
until 1.0.0. A PRESS_PREVIEW_SECRET shorter than 32 characters opens no session, as before.
A delivery from barakoCMS 4.6 names its tenant in the signed body. After the signature verifies, a
body naming another tenant than the one the host resolved to (or the one a build-time site pins) is
answered 200 with revalidated: false and purges nothing. A body naming no tenant, from an older
API, is read as before. A build-time site that pins no tenant has nothing to compare it with.
Moving to the new name is copying the value: PRESS_SECRET set to what REVALIDATE_SECRET held
derives the same tenant keys, provided it is 32 characters or more. A shorter one has to be replaced,
and each tenant given its new key.