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/music taxonomy tree (genre, mood,
    instrument, use case, license, checkout-success, index). Deterministic per
    URL, so responses cache at the edge. Same rule: no chromeSession import
    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 HTML robots.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.

  1. 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 in apps/web/vite.config.ts AND the census
    manifest; a missing declaration fails the build.
  2. CSR (class A shell if the loader is static, else C) — data loads via
    useQuery after hydration (strategy/$slug is the example). Prefer for
    user-specific or interactive pages that do not need SEO.
  3. SSR + CDN cache (class B) — deterministic per URL, read-only DB,
    no personalization. Loaders must stay free of getChromeSession;
    cache headers come from nitro routeRules (s-maxage).
  4. 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

  1. Add the route file as usual.
  2. Regenerate routeTree.gen.ts (run the web dev or generate task once).
  3. Declare the new path in the manifest in
    apps/web/scripts/check-route-census.ts with its class. Undeclared routes
    fail the gate, by design.
  4. 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

  1. Add the route with validateSearch declaring its filter params.
  2. Add those param names to FILTER_PARAMS in
    apps/web/src/lib/filter-params.ts (a tiny pure-data module so the
    check-taxonomy-cache.ts script can import it too).
  3. Add a headers: () => option to the route that returns the result
    of taxonomyLeafCacheControl():
    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.