Static Asset Serving
Static Asset Serving
server/start.ts builds its Bun.serve({ routes: {...} }) table at boot from two
sources - server/static-assets.ts's boot-time glob of dist/client/, and the
PRERENDERED map (see static-prerendering.md) - and serves
every eligible file with ETag/304 support, gzip compression, and in-memory preloading.
This doc covers the serving layer; static-prerendering.md covers what gets built.
Why a route table instead of a single fetch() handler
Previously server/start.ts had one fetch(request) function doing sequential
if/else checks (PRERENDERED map, then a generic static-asset branch, then SSR
fallback). It now registers one Bun.serve() route per servable path instead - same
behavior, more readable, and it removes the old request-time path-traversal guard
entirely: every servable path was discovered from a real directory glob at boot, so
the route table itself is the allowlist. Nothing resolves a request-supplied pathname
against the filesystem at request time anymore.
Two response-header policies exist, not three - despite three route categories (prerendered HTML, static assets, SSR fallback):
- Fast-path (
withFastPathSecurityHeadersinserver/start.ts): prerendered HTML with no session cookie, and every static asset. AlwaysisProduction=true- these only exist once a real build has run, so there's nothing to relax for local dev. - SSR-delegated (
deliverSsrResponse): the wildcard'/*'fallback, and a prerendered route's handler when a session cookie is present (sobeforeLoadcan run a real redirect). Usesprocess.env.NODE_ENV === 'production', matchingsecurityMiddleware's dev-mode CSP relaxation. A single prerendered route can produce either kind of response depending on the request - the header policy is chosen per response, not fixed per route.
The wildcard '/*' entry is a plain handler, not method-scoped ({ GET: ... }) -
server functions (POST), and any other HTTP method, all need to reach real SSR, not
just GET requests to unmatched paths.
ETag / conditional 304
Every preloaded asset gets a weak ETag (Bun.hash over the raw bytes) computed once
at boot. A repeat request with a matching if-none-match gets a 304 - still with
full security headers, the same as any other response leaving this process. Bun.hash
is deterministic across processes, so an ETag computed by one cluster.ts worker
stays valid when a client's conditional request lands on a different worker.
Gzip
Compressible MIME types (text/*, application/javascript, application/json,
application/xml, image/svg+xml) over a minimum size get a gzip variant
precomputed at boot, served when the request's Accept-Encoding includes gzip.
Vary: Accept-Encoding is set whenever a gzip variant exists, regardless of whether
this particular request used it - without it, an intermediary cache could serve the
wrong encoding to a client that didn't ask for it.
In-memory preloading
Files at or under ASSET_PRELOAD_MAX_SIZE are read into memory once at boot (with
their ETag/gzip variant, if applicable); anything larger is served on-demand via
Bun.file() per request, with no ETag or gzip. For this app's actual asset footprint
(6.6MB across ~256 files, largest ~600KB), everything fits under the default 5MB
threshold - don't expect to see the on-demand path exercised in testing unless a
future asset genuinely exceeds it.
Startup cost: reading, gzipping, and hashing every preloadable file happens
concurrently (Promise.all, not a sequential loop) but is still real CPU-bound work
(gzip/hash don't parallelize just because the surrounding async functions do, on a
single JS thread) - measured at roughly 1.1s added to cold boot for this app's asset
footprint. Accepted as a one-time per-process cost, not optimized further (e.g. via
lazy background population) given how small the total footprint is.
Config (env vars)
| Var | Default | Purpose |
|---|---|---|
ASSET_PRELOAD_MAX_SIZE | 5242880 (5MB) | Files at or under this size preload into memory; larger files are served on-demand from disk |
ASSET_PRELOAD_ENABLE_ETAG | true | Toggle ETag/304 support |
ASSET_PRELOAD_ENABLE_GZIP | true | Toggle gzip precompression |
ASSET_PRELOAD_GZIP_MIN_SIZE | 1024 (1KB) | Minimum size for gzip to be worth it |
Adapted from, not copied from, TanStack's reference server
The preload/ETag/gzip mechanism is adapted from TanStack Router's own
examples/react/start-bun/server.ts ("Production Server with Bun"). That reference is
not usable as-is here: it has no clean-URL → index.html mapping (would regress every
prerendered page back to full SSR on every request), no auth-cookie-aware routing (this
app's /login+session-cookie SSR bypass is custom), and no security headers or boot
side-effect hooks at all. Only the preload/ETag/gzip strategy was pulled in;
server/start.ts still owns everything the reference doesn't have.
Known, intentional quirks preserved from before this change
- Literal
*.htmlpaths bypass the auth-cookie check.PRERENDEREDmaps clean URLs (/pricing) to files; a direct request for the literal path (/pricing/index.html) was never inPRERENDEREDand is served by the generic static-asset route instead, with no cookie check. This means/login/index.htmlrequested directly bypasses the SSR-redirect-for-logged-in-users logic. Pre-existing, not something this refactor fixed or should silently change. .htmlhas no MIME table entry. A literal*.htmlpath getsapplication/octet-stream, nottext/html, for the same reason - it was never aPRERENDEREDentry (those hardcodetext/html; charset=utf-8directly), just a file the generic MIME-by-extension lookup doesn't cover.