Architecture
L5E is a thin server on top of Express + Vite. Most of the framework's behavior lives
in two places: a per-request render context (an AsyncLocalStorage store) and a
Vite plugin that prepares virtual modules at build time. Everything else is plain
functions composing those two surfaces.
Request lifecycle
HTTP request
│
▼
┌─────────────────────────────────────────────────────────┐
│ Express layer │
│ • compression + sirv (prod) or vite middleware (dev) │
│ • static /public │
│ • /_l5e/action/<key> ──────────────► Action handler │
│ • everything else ──────────────► render(url) │
└─────────────────────────────────────────────────────────┘
│
▼ runInRenderContext({ ... AsyncLocalStorage ... })
┌─────────────────────────────────────────────────────────┐
│ 1. Middleware chain (src/middleware.ts → sequence) │
│ 2. routeHandler(requestInfo) → view name or null │
│ 3. Global loader (optional, src/global-loader.ts) │
│ ├─ loader() → props, cacheTags, lang │
│ └─ generateMetadata / generateSchema (later) │
│ 4. View loader (optional, views/<name>/loader.ts) │
│ ├─ loader() → props, maxAge/sMaxAge/swr, cacheTags │
│ ├─ rawResponse → bypass JSX, emit bytes │
│ └─ generateMetadata / generateSchema │
│ 5. View component renders (views/<name>/index.tsx) │
│ ├─ Components call useCss / useClientJs / island │
│ └─ <Head> entries push into headRegistry │
│ 6. Bundle collected entries on the fly │
│ ├─ bundleScripts(...) → /bundle-<hash>.js │
│ └─ bundleCss(...) → /bundle-<hash>.css │
│ 7. Apply Cache-Control + Cache-Tag headers │
│ 8. Replace template placeholders → final HTML │
└─────────────────────────────────────────────────────────┘
│
▼
HTTP responseAny HttpException thrown anywhere in steps 1–5 jumps to the _error view
([[12-error-pages]]). RedirectException short-circuits to a 30x with Location.
The per-request render context
The hinge of the whole framework. runInRenderContext wraps each render in an
AsyncLocalStorage store with these registries:
| Registry | Pushed by | Read by |
|---|---|---|
clientJsRegistry |
useClientJs(path) |
Bundler (step 6) |
cssRegistry |
useCss(path) |
Bundler (step 6) |
islandRegistry |
<ClientIsland> |
Bundler + window.__L5E_ISLANDS__ |
cacheTags |
cacheTag="…" props, loader cacheTags, addCacheTag() |
Response headers |
headRegistry |
<Head>, MetadataRenderer |
Final HTML head |
metadataStack |
generateMetadata (global → view) |
MetadataRenderer |
schemaRegistry |
generateSchema (global → view) |
JSON-LD <script> in <head> |
request |
the request itself | useRequest() |
viewName |
route handler return | useRequest().view |
This is also what makes per-request bundling work: only components that actually render call the hooks, so only their assets enter the registry, so only those assets end up in the merged chunk.
Runtime bundling (the production path)
After step 5, the framework knows the exact set of CSS files and client scripts the
response needs. It runs bundleScripts(mappedScripts, root, distClientDir) and
bundleCss(...) — both wrap esbuild — to merge those entries into one hashed
chunk each. The merged file is served from an in-memory map at
/bundle-<hash>.js (and .css), with Cache-Control: public, max-age=31536000, immutable because the hash already covers cache invalidation.
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:
- Discovers entries by scanning view files for
useCss('…')anduseClientJs('…')literal arguments, plus the islands and the actions registry. These become Rollup inputs for the client build. - Generates 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
- 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 |
What lives where
| Concern | File(s) in the framework |
|---|---|
| Express setup, response building | packages/core/src/core/server.ts |
| Render pipeline + error fallback | packages/core/src/core/entry-server.ts |
| AsyncLocalStorage + hooks | packages/core/src/core/jsx-runtime.ts |
| JSX → HTML string | packages/core/src/core/render.ts |
| Build-time scanning + virtual modules | packages/core/src/core/vite-plugin.ts |
| Exception classes | packages/core/src/core/exceptions.ts |
| Middleware composition | packages/core/src/middleware/ |
| Islands | packages/core/src/island/ |
| Actions | packages/core/src/action/ |
| Swap runtime | packages/core/src/swap/ |
| SEO metadata | packages/core/src/seo/ |
The point of dropping these paths in: nothing is hidden behind a façade. If a piece of behavior is unclear, the source is one open file away.