Askr 1.1 — the cache that finally handles the cart widget
Askr 1.1 brings Edge-Side Includes, PURGE/BAN and declarative cache rules in-process: cache the expensive page, keep the cart live, and invalidate by URL — no Varnish, no second hop. Plus two security findings four code reviews missed.
Why Edge-Side Includes, PURGE/BAN and cache rules, and how they actually work.
There's a moment every Laravel developer has had. You profile a slow page, find that it's 180ms of database work rendering the same HTML for every visitor, and think: I'll just cache it. Then you remember the little cart widget in the top-right corner that says "3 items · 749 kr", and the whole plan collapses. That widget is different for every single visitor. One tiny bit of HTML, and a page that could have been served in a hundred microseconds goes back to costing 180 milliseconds — for everyone, forever.
That's the problem Askr 1.1 is about.
The honest shape of the problem
Caching a whole page is easy. Askr has had that since 0.3: mark a response cacheable, and it's served straight from shared memory at static-file speed without PHP ever waking up.
header('Askr-Cache: 3600, tags=posts'); // and askr_cache_forget_tag('posts') to bust it
The trouble is that real pages are almost static. There's a cart. A "logged in as Knut". A stock counter. A CSRF token. And the moment one of those exists, page caching is off the table — so most teams end up caching nothing, or reaching for Varnish and accepting a second network hop plus a second config language.
Varnish solved this properly two decades ago with Edge-Side Includes. Askr 1.1 brings that in-process.
ESI: cache the page with holes
The trick is deciding when the holes get filled. Askr caches the page with the tags still in it, and assembles on the way out:
// The shell: cached for an hour, tags and all
header('Askr-Cache: 3600');
header('Askr-ESI: on'); // opt in — Askr only scans bodies that ask
?>
<html><body>
<esi:include src="/_esi/header"/> <!-- its own 24h TTL -->
<main>Article body, all 180ms of it…</main>
<esi:include src="/_esi/cart"/> <!-- no Askr-Cache header ⇒ per request -->
<esi:remove><p>Only caches that don't speak ESI show this</p></esi:remove>
</body></html>
Each fragment is an ordinary request through your front controller. It routes like any other URL, hits your middleware, and carries its own Askr-Cache header — which means its own TTL, its own tags, and its own PURGE. In Laravel:
Route::get('/_esi/cart', function () {
// no cache header: rendered fresh every time, and it's tiny
return view('partials.cart', ['cart' => Cart::current()]);
});
Route::get('/_esi/header', function () {
return response(view('partials.header'))->header('Askr-Cache', '86400, tags=nav');
});
Here's the actual output of three consecutive requests to a test page, straight from my terminal:
<html>SHELL=42d2 [HEADER cached=07fd][CART live=70afc0]</html>
<html>SHELL=42d2 [HEADER cached=07fd][CART live=785e09]</html>
<html>SHELL=42d2 [HEADER cached=07fd][CART live=db8c94]</html>
The shell is byte-identical — PHP never ran for it. The header is byte-identical — cached for a day. The cart changes every single time. And this is with X-Askr-Cache: HIT on the response: the expensive 180ms page is a cache hit while the cart is still live.
Fragments nest, too. A cached fragment can contain its own includes, each with an independent TTL. I resolve that with up to three passes rather than recursion — which, pleasingly, also means no boxed futures on the response path.
What happens when a fragment breaks? It leaves the hole empty and logs a warning. A broken cart widget must never take down the article. And src must be a same-origin absolute path — I tested an include pointing at http://169.254.169.254/latest/meta-data/, the cloud metadata endpoint, and it was refused. An ESI tag must never become an outbound fetch, or a template injection turns your server into an SSRF proxy from inside the trust boundary.
PURGE and BAN: invalidating by URL
Tag invalidation answers "this content changed". Sometimes you need "this URL changed" — from a deploy script, a CMS webhook, or by hand at 2am:
# Drop every cached variant of one URL (all encodings, all device classes, GET & HEAD)
curl -X PURGE https://example.com/posts/123
#=> {"purged":3}
Drop everything under a path, by glob
curl -X BAN -H 'X-Ban-Url: /category/tech/*' https://example.com/
#=> {"banned":12}
They answer with a count, deliberately. A purge that matched nothing is the kind of thing you want to notice immediately, not discover next week.
Two design notes I'd defend in a code review:
PURGE /posts/1 does not touch /posts/12. Matching stops at a path-component boundary. That's the sort of bug that would be quietly wrong for months.
BAN is an eager scan, not a rule list. The obvious implementation keeps ban patterns in shared memory and checks every cache lookup against them — which puts glob matching under a spinlock on the hot path, forever, for everyone. Instead a BAN walks the slots once and tombstones what matches. Invalidation costs one pass; the request path costs nothing. Entries stored after the ban are untouched, which is correct: they were rendered from current data.
Both are gated on ASKR_ADMIN_TOKEN, and without a token they're accepted from loopback only. An open purge endpoint is a cache-wiping DoS. I got a nice confirmation of this while verifying the published Docker image:
$ curl -X PURGE http://127.0.0.1:8086/
askr: set ASKR_ADMIN_TOKEN to allow PURGE/BAN from a non-loopback address
The container's IP isn't loopback, so it refused. The default behaved correctly in a situation I hadn't specifically planned to test.
Cache rules: policy without touching the app
Everything above assumes you can edit the application. Sometimes you can't — a legacy app, a vendor package, a site you inherited with no tests. So cache policy can now live in askr.toml:
[cache]
response_slots = 512
Never cache the admin area, whatever the app says
[[cache.rule]]
path = "/admin/*"
action = "pass"
Cache these for a day even though the app never opted in —
and even for visitors carrying cookies
[[cache.rule]]
path = "/static/*"
ttl = 86400
force = true
Everything else: short TTL with a stale-if-error safety net
[[cache.rule]]
path = "/"
ttl = 300
swr = 30
stale_if_error = 3600
First match wins. I verified this against an app that sets no cache header at all: /page became a HIT with an identical body, /admin/ stayed uncached and reported X-Askr-Cache: PASS, and /static/* cached even with a laravel_session cookie present. The control case — /page with a cookie and no force — correctly stayed uncached.
A detail I'm happy with: a rule's ttl overrides the app's header, but keeps the app's tags. So a rule-cached page can still be busted by askr_cache_forget_tag(). Rules and a partly-cooperative app compose instead of fighting.
And a warning I put in the docs in plain language:
forceis the dangerous one. Exactly as in Varnish, if the path can render anything user-specific, one visitor's page gets served to everyone.Set-Cookieis still stripped on store, but that doesn't make a personalised body safe to share.
What I said no to
The original plan for the rule engine had three phases: declarative TOML, then embedded Rhai scripting, then Wasm plugins. I built phase one and stopped.
Before writing any code I mapped what VCL is actually used for against what Askr already had:
VCL use Askr
host redirects [[redirect]] (0.9.12)
force https force_https (0.9.12)
hash_data / cache keys strip_query_params, ignore_cookies (1.0.1)
ban / purge this release
stale-if-error / saint mode 1.0.1
ESI this release
return(pass) ← the actual gap
beresp.ttl without app changes ← the actual gap
Six of eight were already config. The gap was two things, and both are now six lines of TOML. A scripting engine would put arbitrary code on the cache decision path, hand me a sandbox to secure, and freeze a scripting API under the 1.0 stability contract — to do what a table already does.
The docs now invite an issue with a concrete case that rules can't express. That's better evidence for a script engine than the idea of one.
Two security findings, and how they were found
While e2e-testing stale-if-error, I fired a curl at a .php file out of idle curiosity. It returned the source code. Static file serving had no extension filtering, so GET /index.php downloaded the file instead of running it — and with a document root pointed at an app root, GET /.env returned APP_KEY and the database password in plain text.
That went out immediately as 1.0.1. Then I ran a proper probe sweep of the same code path and found the same class one step over — index.php.bak, config.php~, db.php.save — which is fixed in 1.1.0.
Both are worth being blunt about: four separate code reviews had not found them. It took a stray curl against a real app. Reading code proves what it says; poking it proves what it does.
The sweep also cleared several things I'd rather know than assume: percent-encoded traversal, NUL tricks and trailing-dot paths were all already refused, and there is no directory listing. And one thing I deliberately did not "fix": Askr follows symlinks out of the document root, because php artisan storage:link creates exactly that, and nginx and Apache behave the same way. Blocking it would break uploads for most Laravel apps. That's documented instead of changed.
Along the way I also got fooled twice by my own test setup — once because kill %1 didn't kill anything (each shell is a new shell), once because cargo test doesn't rebuild the binary, so I "proved" a fix didn't work using an 18-minute-old build. Both times the test lied, not the code. It's a good reminder of how easy it is to conclude the wrong thing with real evidence in front of you.
The freeze held
1.0 froze Askr's surface under SemVer. This release is the first real test of that promise: five user-visible features and a security fix, and not one breaking change. Every addition is a new default-off [cache] key or an opt-in response header. The new bits of HTTP surface — PURGE/BAN with X-Ban-Url, Askr-ESI and the tag syntax, X-Askr-Cache: PASS — are recorded in STABILITY.md so they're covered going forward.
1.1.0 is a drop-in for 1.0.x. That was the whole point of writing the contract down.
Where this leaves things
Askr now does, in one binary and with no extra hop, what people run Varnish for: full-page caching, ESI assembly, purge and ban, stale-if-error with saint mode, cache-key normalisation, and per-path policy — plus instant tag invalidation across all workers, which Varnish doesn't have, because Askr is inside your application rather than in front of it.
The honest limits are in the docs, including my favourite: because the cache key includes the negotiated encoding, an ESI shell is stored once per encoding class your clients negotiate. Identical bodies, a few extra slots, one render per class. Not correctness — just a little waste I'd rather write down than pretend away.
docker pull ghcr.io/kwhorne/askr:1.1.0 # or :latest
composer require kwhorne/askr-laravel
Go cache that page with the cart widget on it. 🌳