Invalidation & restarts in isolation

bext serves N sites from a single masquerade process. The hard rule for operating it: never reach for systemctl restart nginx when a per-site or per-cache surface exists — restarting the master process briefly drains every co-hosted vhost. This page is the reference for the smallest possible blast radius for each kind of state.

The cache stack is layered. Pick the lowest layer you need to invalidate; layers below it pass through automatically on the next request.

┌──────────────────────────────────────────────────────────┐
│ L0  in-process hot-path response cache (per (host,path)) │
│ ISR cached SSR responses (per route tag)                 │
│ Proxy / FastCGI response cache (per host+URI)            │
│ V8 isolate pool (per site)                               │
│ Compiled module registry (per abs source path)           │
│ Disk module cache (.bext/cache/modules/<hash>.js)        │
│ Source files on disk                                     │
└──────────────────────────────────────────────────────────┘

All endpoints below are loopback-only by default (gate at dispatch.rs:2393). From outside the box you can hit them only with an admin Bearer token. Examples assume you're SSH'd to the host.

Response caches

L0 — in-process hot-path

Drops the per-(host, path, query, encoding) response cache that short-circuits before middleware. Use after a deploy that changes middleware/header behavior — the underlying ISR or proxy cache may be fine but L0 is serving pre-deploy bytes.

# Whole-cache flush:
curl -X POST http://127.0.0.1/__bext/cache/purge-l0

# Targeted paths:
curl -X POST http://127.0.0.1/__bext/cache/purge-l0 \
  -H 'Content-Type: application/json' \
  -d '{"paths":["/","/blog","/about"]}'

L0 is keyed on (host, path, …), so paths from one site never affect another. (crates/bext-server/src/vhost/dispatch.rs:2526)

ISR — cached SSR responses

Tag-based; clears the in-RAM response cache used by SSR routes. Tags follow the convention route:<tag> and * for everything.

# Per-route:
curl -X POST 'http://127.0.0.1/__bext/admin/api/cache/purge?tag=route:/blog/[slug]' \
  -H "Authorization: Bearer $BEXT_ADMIN_TOKEN"

# Per-site (requires the site's vhost label in the tag pattern):
curl -X POST 'http://127.0.0.1/__bext/admin/api/cache/purge?pattern=route:*' \
  -H "Authorization: Bearer $BEXT_ADMIN_TOKEN"

Proxy / FastCGI response cache

Per-host, per-path or per-prefix purge of the proxy_cache / FastCGI cache (key format host+URI). Used by the Next.js CMS revalidation webhook.

curl -X POST http://127.0.0.1/__bext/cache/purge-proxy \
  -H 'Content-Type: application/json' \
  -d '{
    "host":   "blog.example.com",
    "paths":  ["/posts/abc", "/posts/def"],
    "prefixes": ["/tag/"]
  }'

Isolation is per-host because the cache key includes Host. (dispatch.rs:2466)

Compiled module cache (Turbopack registry)

This is the layer that was causing the "I edited layout.tsx and nothing changed" pain. The fs watcher now calls invalidate_file() automatically on every detected source mutation, but you may need to force it when:

  • Files were swapped atomically (mtime hops backwards)
  • An editor writes via rename and inotify misses the event
  • You're testing watcher logic itself
# Targeted: evict specific source files (also evicts transitive dependents)
curl -X POST http://127.0.0.1/__bext/cache/purge-modules \
  -H 'Content-Type: application/json' \
  -d '{"paths":[
    "/home/infra-sj278/bext/sites/dufournaud-prism/src/app/layout.tsx",
    "/home/infra-sj278/bext/sites/dufournaud-prism/src/components/SiteNav.tsx"
  ]}'

# Per-site: evict every cached module under a directory prefix
curl -X POST http://127.0.0.1/__bext/cache/purge-modules \
  -H 'Content-Type: application/json' \
  -d '{"site":"/home/infra-sj278/bext/sites/dufournaud-prism"}'

# Nuclear: drop every compiled module (heavy — next request to every site
# recompiles its entry tree). Avoid in production.
curl -X POST http://127.0.0.1/__bext/cache/purge-modules

The endpoint also calls clear_compile_cache, clear_path_memo, and clear_context_cache so the next request really recompiles from disk. (dispatch.rs:2579)

New routes & edits — now automatic (2026-06-12)

Adding a new page.tsx / route.ts, or editing an existing one, no longer needs a manual purge or a restart:

  • Worker mode — the per-site site_worker watcher invalidates on save.
  • Masquerade in-process mode — the master masquerade_route_watcher polls each site's app_dir and, on a route add/remove or an edit, resets that site's scanned_routes_cache + route table + Turbopack compile caches in-process. New routes and edits go live within ~2 s. Tunables: BEXT_MASQUERADE_ROUTE_WATCH_MS (default 2000), BEXT_MASQUERADE_ROUTE_WATCH=0 to disable.
  • Scripted / forced — the loopback POST /nginx-cache/purge-app {app_root} resets a site's route caches by app_root (no host resolution needed) for the forked-worker path or CI scripts.

Restart survival: the persistent compile + bytecode disk caches (/var/cache/bext/{compile,bytecode}) outlive the process, so a redeploy no longer cold-compiles every route — unchanged routes are served from disk (compile ~80 µs vs ~8 ms, plus a V8 bytecode restore). This is why rm -rf .bext + systemctl restart nginx is no longer the routine recipe for picking up source edits.

V8 isolate pool — per site

If a site's modules are stale inside a long-lived isolate (e.g. you changed module-level mutable state), drop just that site's pool. Old in-flight requests drain naturally; new requests get a fresh pool via ArcSwap.

# Per-site flush (loopback):
curl -X POST http://127.0.0.1/__bext/api/sites/dufournaud-prism/v8/flush

# Per-site flush via super-admin label (zeros last_build_ms + clears
# response cache for every vhost on that label):
curl -X POST 'http://127.0.0.1/__bext/admin/api/v8/flush?site_label=dufournaud-prism' \
  -H "Authorization: Bearer $BEXT_ADMIN_TOKEN"

Site IDs/labels come from bext.config.toml or the vhost name. (dispatch.rs:2445, admin/handlers.rs:1643)

TLS / SSL

Per-vhost cert reload does not have its own surface. The TLS resolver re-reads every cert+key off disk on SIGHUP (main.rs:6891). After a certbot renewal:

sudo systemctl reload nginx

The masquerade SIGHUP handler swaps the cert resolver atomically and drains old workers for up to 10s — existing TLS connections are not killed, new connections pick up the new cert. This is the "soft" reload and is safe even under traffic.

Gap: there is no POST /__bext/api/ssl/reload?host=… today. If per-vhost reload becomes important, add a handler that calls just the TLS-reload path of the SIGHUP routine (don't rebuild routing config).

Vhost / routing config

Changing one site's nginx config (upstreams, headers, locations) requires a full SIGHUP — the parser rebuilds NginxCompatState for every vhost.

sudo systemctl reload nginx

This re-parses nginx.conf, atomically swaps the SiteRouter via ArcSwap, and drains old workers gracefully (main.rs:6814–7037). Briefly affects every co-hosted site, but never drops in-flight requests.

Gap: no per-vhost config reload exists. If this becomes important, build a POST /__bext/api/vhosts/reload?host=… that re-parses just one server block and patches it into the SiteRouter.

Per-site systemd units

A handful of sites have their own bext processes (e.g. bext-gto, bext-avivre, bext-demo-bun). These you can restart without touching the master:

sudo systemctl restart bext-gto

This kills + respawns that single site's worker. The master masquerade keeps routing all other sites uninterrupted. List with:

systemctl list-units 'bext-*'

Routing / discovery caches

Source-file changes that add or remove routes invalidate three discovery caches automatically when the fs watcher fires (bundler.rs:597–607):

  • scanned_routes_cache — PRISM route table
  • prism_discover_cache — per-route layouts, conventions, slot pages, metadata, middleware
  • route_table_cachessr_pipeline::prism::clear_route_table_cache()

No HTTP surface for these — they ride on the watcher's invalidation event. If you need to force a re-scan without changing a file, touch any page.tsx/route.ts/layout.tsx in the site:

touch /home/infra-sj278/bext/sites/dufournaud-prism/src/app/layout.tsx

Process lifecycle

You want… Use this Blast radius
Reload nginx config + TLS certs sudo systemctl reload nginx (SIGHUP) All vhosts (graceful drain, no dropped requests)
Restart the bext masquerade sudo systemctl restart nginx All vhosts (brief gap)
Restart one PRISM/Next site sudo systemctl restart bext-<site> Just that site
Rotate JSC worker pool POST /__bext/admin/api/workers/restart All workers, rolling
Atomic binary swap ./scripts/deploy-local.sh --swap-only Brief gap on swap+restart

Decision tree

Source files changed?
  └─ Yes: wait — the fs watcher invalidates modules + ISR + L0 + discovery automatically.
     If still stale: POST /__bext/cache/purge-modules with {site: "<abs/dir>"}.

Stale cached SSR response?
  └─ Yes: POST /__bext/cache/purge-l0 (paths) + ISR purge by tag.
     Don't touch modules or V8 — those layers are upstream.

Edited module state behaves stale even with fresh source?
  └─ Yes: per-site V8 flush. The isolate is holding old module bindings.

Cert renewed?
  └─ Yes: sudo systemctl reload nginx. SIGHUP picks up new certs atomically.

Site config (nginx server block) changed?
  └─ Yes: sudo systemctl reload nginx. Per-site reload doesn't exist (yet).

Site process crashed or wedged?
  └─ Yes: sudo systemctl restart bext-<site> if it has its own unit;
     otherwise sudo systemctl restart nginx.

bext-server binary rebuilt?
  └─ Yes: ./scripts/deploy-local.sh --swap-only. Never edit the running
     binary at /usr/local/bin/bext-server in place.

Auth

All /__bext/cache/*, /__bext/api/*, and /__bext/debug/* paths are loopback-only by default (gate at dispatch.rs:2393). To call them from another box, send Authorization: Bearer <admin-jwt> minted by the masquerade's JwtValidator::new_admin.

/__bext/admin/api/* (browser-facing admin) always requires the bearer token or an admin session cookie.

Existing endpoint discovery

A 404 on any /__bext/* path returns the full list of available endpoints in its body. Useful for spot-checking what the running binary supports:

curl http://127.0.0.1/__bext/does-not-exist
Warning

Never reach for systemctl restart nginx as a first response to a stale-content or slow-render issue. It briefly drains every co-hosted vhost. Use the per-site or per-cache endpoints above — they are designed to have zero blast radius on unrelated sites.

Tip

The persistent compile and bytecode disk caches at /var/cache/bext/{compile,bytecode} survive process restarts, so a deploy-local.sh --swap-only no longer cold-compiles every route. rm -rf .bext + restart is only needed when you suspect a corrupted on-disk cache.

Related