@canner-ca/astro-cache
Canner caching for Astro. Three things in one zero-dependency package:
- A native Astro 7 cache provider —
cacheCanner()plugs Canner into Astro's built-in route caching, the same API you'd use on Netlify/Vercel/Cloudflare. - Draft mode — let editors preview unpublished content on the real URL without purging what everyone else sees.
- One-line cache headers —
cache(Astro.response, …)setsCache-Control+Surrogate-Keycorrectly if you'd rather do it per route.
Works with any CMS (DatoCMS, Contentful, Sanity, Storyblak, …) — tags are just strings.
Install
npm install @canner-ca/astro-cache
Requires Node.js 20 or later. Zero runtime dependencies.
Option A — the cache provider (Astro 7+)
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import { cacheCanner } from '@canner-ca/astro-cache';
export default defineConfig({
adapter: node({ mode: 'standalone' }),
cache: {
provider: cacheCanner({
slug: 'my-project',
token: process.env.CANNER_CACHE_TOKEN, // only for invalidate()
}),
},
});
---
// src/pages/blog/[slug].astro — control caching per route
const post = await getPost(Astro.params.slug);
Astro.cache.set({ maxAge: 3600, swr: 86400, tags: [post.id, 'blog-listing'] });
---
Canner caches at its proxy (in front of your app, shared across instances), so the
provider never spends your app's memory on an in-process cache — and invalidate()
purges by tag or path through the same token.
Option B — one-line headers (any Astro version)
---
// src/pages/blog/[slug].astro
import { cache } from '@canner-ca/astro-cache';
const post = await getPost(Astro.params.slug);
// Cache 1h; keep serving up to a day past that while refreshing in the background.
cache(Astro.response, { ttl: 3600, swr: 86400, tags: [post.id, 'blog-listing'] });
---
That sets:
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400
Surrogate-Key: <post.id> blog-listing
When the post changes, your CMS webhook purges tag <post.id> and Canner clears every
page carrying it. Add blog-listing to your index and to the purge to clear it too.
Draft mode
Preview unpublished content on its real URL, without purging the published page:
// src/pages/api/draft.ts
import { enableDraft } from '@canner-ca/astro-cache';
export const GET = ({ url, cookies, redirect }) => {
if (url.searchParams.get('secret') !== import.meta.env.PREVIEW_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
enableDraft(cookies, import.meta.env.CANNER_BYPASS_SECRET); // secret: Caching tab
return redirect(url.searchParams.get('to') ?? '/');
};
---
// in your page, fetch draft content when the visitor is in draft mode
import { isDraft } from '@canner-ca/astro-cache';
const post = await getPost(Astro.params.slug, { draft: isDraft(Astro.request) });
---
enableDraft sets a hardened __canner_bypass cookie carrying your bypass secret;
Canner then renders that visitor's requests fresh from origin while everyone else keeps
getting the cached page. disableDraft(cookies) turns it back off. You can also bypass
with a ?__canner_bypass=<secret> link or an X-Canner-Bypass: <secret> header.
Full guide (dashboard token, CMS webhook, bypass secret): https://canner.ca/docs/caching
API
cacheCanner(config)
Returns the object Astro's cache.provider expects. config: { slug?, token?, endpoint?, fetch? }
(slug + token are required for invalidate()).
enableDraft(cookies, secret) / disableDraft(cookies) / isDraft(request)
Draft-mode helpers. cookies is Astro's cookies object; secret is your project's
bypass secret (Caching tab). isDraft takes the Request and returns a boolean.
cache(target, options)
target is a Response, Astro.response, or a Headers. Returns the Headers.
applyCacheHeaders(headers, options)
Same thing, lower-level, when you already hold a Headers instance.
options
| Option | Type | Notes |
|---|---|---|
ttl |
number (required) |
Seconds Canner may serve the cached response. Sets s-maxage. Positive integer. |
swr |
number |
Optional. Stale-while-revalidate window in seconds. Past ttl, Canner serves the stale copy instantly and refreshes once in the background. Sets stale-while-revalidate. |
tags |
string | number | Array<string | number> |
Tags for purging. Sets Surrogate-Key. Numbers are coerced to strings; duplicates and whitespace-containing tags are dropped. |
browserTtl |
number |
Optional. Seconds the visitor's browser may cache (sets max-age). Omit to let the browser revalidate while Canner serves from cache — keeps purges instant. |
(The provider's Astro.cache.set() uses maxAge/swr/tags — Astro's own names.)
What it caches (and what it won't)
A response is cached by Canner only when all of these hold — the same rules this helper produces:
- method
GET/HEAD, status200 Cache-Control: publicwith a positives-maxage(ormax-age)- no
Set-Cookie Varyabsent or onlyAccept-Encoding- body under 8 MB
It only ever adds headers
This package never strips or mutates anything your app set. In particular it
does not remove Set-Cookie: Canner already declines to cache a response
that sets a cookie, so the only safe thing to do is tell you. If you call
cache() on a response that sets a cookie, you'll get a development-only
warning explaining it won't be cached — your cookie is left untouched.
Invalid input degrades gracefully too: a bad ttl sets no cache headers (the
page still renders, just uncached) and warns in dev. Only passing something
that isn't a Response/Headers throws.
Non-goals
- It doesn't configure your CMS webhook — that's two copy-paste values in the Canner dashboard (Settings → Caching).
- The provider deliberately doesn't implement Astro's
onRequest(in-process runtime caching): Canner caches out-of-process at its proxy, so a second in-app cache would just burn your app's memory duplicating it.
License
MIT