L5E docscontent/02-architecture.md

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.

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.

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.

It also generates the virtual modules the SSR runtime imports:

  • virtual:l5e-route → the user's src/route.ts handler
  • virtual:l5e-global-loader → the user's src/global-loader.ts (if present)
  • virtual:l5e-middleware → the user's src/middleware.ts (if present), composed via sequence(...)
  • virtual:l5e-actions → action registry mapping <actionName>_<shortHash> to { modulePath, actionName }, plus a glob of all actions.tsx modules

And two more client-side transforms:

  • Transforms actions.tsx for the client: replaces defineAction(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.ts automatically 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