Architecture
L5E is a thin server on top of Express + Vite. Two ideas carry the whole framework.
1. Per-request bundling, decided on the server. Components register the CSS/JS they need
into a per-request hook (AsyncLocalStorage); after render, L5E merges that exact set into one
<link> + one <script>. Only the server knows the request's data — and so the exact
components that rendered — so the call is made at runtime. Register nothing, ship 0 KB JS.
(Why this beats build-time bundling for block-builder pages: [[01-why-l5e]].)
2. Interactivity is opt-in. A page ships minimal JS and no framework runtime by default — what costs JS is interactivity, not whether the content changes (loaders still run per request, so data stays fresh).
- Most content pages need little client-side behavior, so L5E ships none by default.
- Need it? Opt into an island ([[20-islands]]) — only that island's JS ships, loaded lazily (on idle, on scroll into view).
Everything else is plain functions composing these two ideas.
Request lifecycle
A request enters through the Express layer; anything that isn't a static asset or an action call runs inside a single render context. The middleware chain wraps the whole thing — it runs on the way in and again on the way out — with the router → loader → view pipeline at the core.
▼ on the way in
▲ on the way out · read / modify the Response
Any HttpException thrown in the chain above jumps to the _error view
([[12-error-pages]]). RedirectException short-circuits to a 30x with Location.
Runtime bundling (the production path)
Once the view has rendered, the framework knows the exact set of CSS files and client
scripts the response needs — they're sitting in the per-request registries. It runs
bundleScripts(mappedScripts, root, distClientDir) and bundleCss(...) to merge those
entries into one hashed chunk each, served from an in-memory map.
GET /
bundleScripts(...) / bundleCss(...) merge only the registered entries into one hashed chunk each.
/bundle-<hash>.css
/bundle-<hash>.js
Keyed by the sorted set of registered paths — a later page that registers the same set reuses the same chunk instead of re-bundling. An empty set emits no tag at all (0 KB).
The merged file is served at /bundle-<hash>.js (and .css) with Cache-Control: public, max-age=31536000, immutable because the hash already covers cache
invalidation. Because the cache is keyed by the set of paths and lives for the
lifetime of the process, two pages that pull in the same components share one chunk —
the bundling cost is paid once, not per page.
If the page registered no client scripts, the script tag is simply absent. 0 KB JS for fully static pages is the default, not a special case.
In dev, no bundling happens — Vite serves each entry on its own URL and HMR handles updates. The shape of the registered entries is identical, only the delivery is different.
Build-time: the Vite plugin
@withl5e/l5e/vite-plugin does the work that can't happen at request time. The key
move for bundling: it scans every view for literal useCss('…') / useClientJs('…')
arguments and turns each one into its own Rollup input, so the client build emits
one ready-to-serve target per asset. At request time the runtime only merges those
prebuilt targets — it never compiles your source per request.
useCss('…') / useClientJs('…') argument across the views (plus islands & actions).
useCss / useClientJs file becomes its own built artifact in dist/client.
It also generates the virtual modules the SSR runtime imports:
virtual:l5e-route→ the user'ssrc/route.tshandlervirtual:l5e-global-loader→ the user'ssrc/global-loader.ts(if present)virtual:l5e-middleware→ the user'ssrc/middleware.ts(if present), composed viasequence(...)virtual:l5e-actions→ action registry mapping<actionName>_<shortHash>to{ modulePath, actionName }, plus a glob of allactions.tsxmodules
And two more client-side transforms:
- Transforms
actions.tsxfor the client: replacesdefineAction(opts)exports with fetch stubs hitting/_l5e/action/<key>. The server keeps the real handler; the client only ships a function that sends an HTTP request. - Detects
src/client.global.tsautomatically and adds it as a global client entry (the only script every page ships, when it exists).
Actions: a separate transport
POST / GET to /_l5e/action/<key> is handled outside the HTML render path.
Express has JSON-body parsing scoped to /_l5e/action, the route validates the key
shape (<name>_<4-8 hex>), loads the matching module from the action registry, runs
the handler, and serializes the returned JSX to HTML — same render machinery, no
template, no <Head> collection, no cache headers.
The client side (via the Vite transform) treats actions as typed RPC: searchDocs({q})
becomes a fetch with q in the querystring or JSON body depending on method. The
returned HTML fragment is what [[18-swap-and-action]] swaps into the DOM.
Dev vs production
| Concern | Dev (tsx server.ts) |
Production (node dist/server.js) |
|---|---|---|
| Module loader | Vite's ssrLoadModule |
Pre-built dist/server/*.js |
| Asset delivery | Vite middleware (HMR, transforms) | sirv static + in-memory bundle map |
Cache-Control |
Not emitted | Emitted from loader maxAge/sMaxAge/swr |
Cache-Tag |
Always emitted as global,… |
Same, but non-global tags are hashed (global,1u7gb,…) |
| Bundling | Off — Vite serves each entry | On — esbuild merges per request |
| Action registry | Read from virtual:l5e-actions |
Read from dist/server/action-registry.json |