Route census
Route census
Every user-facing page route in apps/web belongs to exactly one class. The
manifest lives in apps/web/scripts/check-route-census.ts; the gate runs in
mise run validate and validate:quick.
Classes
- A: static / prerendered. Marketing pages, blog, legal. No session
dependencies, no request-time auth. The file may not import or call
chromeSession/getChromeSession. - B: SSR, CDN cacheable. The
/audio/musictaxonomy tree (genre, mood,
instrument, use case, license, checkout-success, index). Deterministic per
URL, so responses cache at the edge. Same rule: nochromeSessionimport
or call in the route file. - C: dynamic SSR. Everything session or request dependent:
_authed
pages, onboarding,studio/**,s/$token, checkout-success,login,
strategy/$slug(client useQuery), redirects (status/$slug,music/$,
music), and the non HTMLrobots.txt/sitemap.xml.
Render strategy first
Default to SSG/prerender (class A). Every SSR page is a per-request origin
render unless declared otherwise; reaching for SSR by default rebuilds the
route bloat the census exists to prevent.
- SSG / prerender (class A) — static content, no per-request data, no
session reads. Default for marketing, legal, blog, shells. Add the path
to the prerender pages inapps/web/vite.config.tsAND the census
manifest; a missing declaration fails the build. - CSR (class A shell if the loader is static, else C) — data loads via
useQueryafter hydration (strategy/$slugis the example). Prefer for
user-specific or interactive pages that do not need SEO. - SSR + CDN cache (class B) — deterministic per URL, read-only DB,
no personalization. Loaders must stay free ofgetChromeSession;
cache headers come from nitrorouteRules(s-maxage). - Dynamic SSR (class C) — genuinely per-request: authed, personalized,
share tokens. The fallback, not the default.
Server handlers, API endpoints, internal admin, and the dev-only login
handler are not pages and stay out of the census.
Adding a route
- Add the route file as usual.
- Regenerate
routeTree.gen.ts(run the web dev or generate task once). - Declare the new path in the manifest in
apps/web/scripts/check-route-census.tswith its class. Undeclared routes
fail the gate, by design. - If the route is A or B, keep session code out of the route file. Session
dependent behavior belongs in class C pages or in_marketing.tsx’s
beforeLoad.
The gate also fails if a manifest entry no longer matches any route, so
deleting a page requires removing its census line.
Run
bun apps/web/scripts/check-route-census.ts
Facet split for taxonomy cache headers
Each taxonomy leaf and hub index under /audio/music/** sets its
Cache-Control via the route’s headers: () => ({ ... }) callback
(router-core merges the returned headers into match.headers, then
getStartResponseHeaders builds the final Response from those). The
shared value for unfiltered URLs is public, s-maxage=300, stale-while-revalidate=86400. When any filter query param is present
(mood, q, bpmMin, bpmMax, key, movement, character,
instrument), the helper returns private, max-age=0 so a poisoned
filtered variant can never persist at the edge.
Why route-level and not a nitro routeRules header: h3’s toResponse
finalization prefers event.res.headers (where nitro writes) over
Response.headers (where the route’s headers callback lands), so a
per-route private override wrote by the headers callback would lose to
a /audio/music/** routeRule. Putting the value on the Response (not
on event.res.headers) keeps it through the merge. There is therefore
no longer a /audio/music/** entry in vite.config.ts’s routeRules.
The helper is taxonomyLeafCacheControl() in
apps/web/src/lib/taxonomy-cache.ts. It reads the incoming URL via
getRequestUrl() (only available inside the request middleware chain —
TanStack Start wraps the headers callback invocation inside
runWithStartContext). It returns null outside a request context
(client hydration re-renders the headers callback; the route’s
cc ? { "Cache-Control": cc } : {} handles that).
Verifying the matrix
Start the dev stack, then run the check script:
mise run dev:fake
bun apps/web/scripts/check-taxonomy-cache.ts [baseUrl]
It parses taxonomy leaf URLs from the live /sitemap.xml, asserts every
unfiltered leaf returns 200 with s-maxage in Cache-Control, asserts
faceted variants of one genre leaf return exactly private, max-age=0,
and asserts /music/<tail> 301s to /audio/music/<tail>. Nonzero exit
on any failure. On demand only; not wired into mise run validate.
Cloudflare Cache Rule + canary (slice 3, out of repo)
Pending: a Cloudflare Cache Rule for /audio/music/* that respects
origin cache headers. Canary: curl the same unfiltered leaf twice and
watch cf-cache-status go MISS then HIT.
The purge token must be a NEW Cloudflare token scoped to
Zone.Cache Purge, stored in Infisical. Never reuse the existing
DNS:Edit token for purges.
Adding a new faceted leaf
- Add the route with
validateSearchdeclaring its filter params. - Add those param names to
FILTER_PARAMSin
apps/web/src/lib/filter-params.ts(a tiny pure-data module so the
check-taxonomy-cache.tsscript can import it too). - Add a
headers: () =>option to the route that returns the result
oftaxonomyLeafCacheControl():import { taxonomyLeafCacheControl } from "#src/lib/taxonomy-cache"; export const Route = createFileRoute("...")({ validateSearch: ..., headers: () => { const cc = taxonomyLeafCacheControl(); return cc ? { "Cache-Control": cc } : {}; }, loader: ..., });
Purge hook on admin publish
apps/web/src/server/admin/catalog-handlers.ts (sole mutation path for
catalog rows — updateCatalogTrackHandler) calls
clearLeafCountsCache() from packages/catalog/src/list-tracks-fn.ts
to reset the module-scope memo for this blue-green slot, then fires
purgeTaxonomyCache() from apps/web/src/lib/cdnPurge.ts. The purge
sends POST /zones/{CF_ZONE_ID}/purge_cache with
{"prefixes":["usebrioso.com/audio/music/"]} (a prefix purge needs no
URL enumeration). When CF_PURGE_TOKEN or CF_ZONE_ID are unset the
function returns immediately with { purged: false }, so local dev and
preview slots stay no-ops.
A purge failure must NOT fail the admin update. The hook is
fire-and-forget and logs success/failure via @brioso/obs-js.
Cloudflare zone sharing: beta and prod share one CF zone, so a prefix
purge from beta clears prod’s edge entries too. That is acceptable for
correctness — the origins re-serve per-host content and a beta cache
miss just re-fetches. Documented so future engineers don’t read purge
logs as prod signals.