Build engine
How zfb's crates fit together, why the rebuild is per-page, and what makes a `dist/` write safe.
This page is about the shape of zfb’s build engine. For a step-by-step walk of what happens when you save a file, read Build pipeline; this page explains why the story is shaped this way.
See also: Architecture overview · Islands · Incremental rebuild
The crate split
zfb is a Rust workspace. Each crate owns one slice of the build, and the data flowing between them is small and explicit.
zfb-routerscanspages/and turns files into routes. It owns the file-name-to-URL convention and nothing else.zfb-graphholds the dependency graph. Every page knows which sources it depends on (components, layouts, content, styles); every source knows which pages depend on it. This is the index that makes per-page rebuilds possible.zfb-watcherwraps thenotifycrate, normalises the noisy native events into oneChangeper logical save, and emits them on a channel.zfb-buildis the orchestrator. It consumesChangevalues, classifies them, asks the graph which pages need rebuilding, and runs the asset pipeline.zfb-rendercompiles TSX through SWC, hands the resulting JS to aRenderHost, and writes the HTML.zfb-cssprocesses CSS modules and global styles.zfb-islandsscans forclient:*directives, bundles the per-island JS, and emits the hydration entry.zfb-contentparses Markdown / MDX content collections and runs the unified plugin chain.zfb-serveris the dev-only HTTP server: a page cache, a static-file route, the live-reload SSE stream, and a request-time SSR dispatcher forprerender = falsepages.
The crates depend downward: the orchestrator knows about render, css, islands, content. The renderer does not know about the orchestrator. The server only reads.
Per-page rebuild, not per-bundle
Bundler-based tools (Vite, esbuild) think in modules and chunks: a change invalidates a module, and the bundler walks the import graph to decide what to re-emit. zfb thinks in pages.
When a file changes, zfb-watcher emits a Change, the orchestrator asks zfb-graph which pages depend on this path, and only those pages re-render. A leaf component used by three pages produces three page rebuilds — not a whole-site rebuild, not a bundler walk.
The win is granularity. A 2,000-page site rebuilds the affected pages in milliseconds when a shared header changes. The cost is that the graph must be honest — zfb-graph is updated as part of every successful render so the next query has a current picture.
Tools the engine uses (and why they’re swappable)
zfb is the orchestrator. It owns the dependency graph, the per-page rebuild contract, and the data flowing between crates. The external tools it calls are an implementation detail — each sits behind a Rust trait, so any one of them can be replaced without touching the rest of the pipeline.
| Tool | Role | Trait boundary |
|---|---|---|
| esbuild | Bundles the server-side worker bundle (zfb-) and the per-island client bundles (zfb-islands). Fast, handles TSX/JSX, MDX loaders, and tree-shaking. | ClientBundler (in zfb-islands) |
| deno_core (V8) | The embedded V8 isolate that executes the server-side worker bundle for SSG and the dev preview server. Chosen because Tauri distribution requires a single binary with no Node dependency; see JS runtime for the rationale. | RenderHost (in zfb-render) |
| SWC | Compiles TSX source to JavaScript before handing it off to esbuild or the render host. Lives inside zfb-render. | Internal to zfb-render; swappable by replacing that crate’s transform step |
| lol_html | Streaming HTML rewriter used to inject hydration entry-point <script> tags and island mount markers into the rendered HTML without a full parse. | Used inside zfb-; no public trait needed — it is a low-level utility |
How the layering works
zfb owns orchestration, the dependency graph, and the per-page contract. The tools each appear in exactly one layer:
- esbuild is called in two places:
zfb-build(server worker bundle) andzfb-islands(per-island client bundles). Both use it for bundling TypeScript/JSX source trees; neither exposes it to the crates above. - deno_core (V8) is the JS engine behind
RenderHost. The renderer inzfb-rendercallsRenderHost::call_default; the concrete host (EmbeddedV8RenderHost) runs the bundle in an in-process V8 isolate. The orchestrator inzfb-buildnever names the engine — it only receives rendered HTML. - SWC runs inside
zfb-renderbefore the module reaches the render host, or insidezfb-before esbuild sees the source. Either way it is invisible to the orchestrator.build/ bundler. rs - lol_html runs after the render host returns HTML, inside
zfb-. The orchestrator sees plain strings in and plain strings out.build/ head_ inject. rs
This structure means the crates above each tool’s trait boundary are unaffected when the tool changes. See Islands for the ClientBundler contract and Incremental rebuild for how the dependency graph feeds the per-page policy.
V8-mode gate: output and auto-detection
zfb-render exposes the embedded V8 host behind a default-on embed_v8 cargo feature. The build engine decides at the start of every build whether the deploy artifact assumes a V8-bearing runtime is part of the shape — the V8Mode decision — and surfaces a clear error when the config and the route table disagree.
The decision is driven by two inputs:
Config.outputinzfb.config.ts("static"/"hybrid"/"auto", default"auto").- The detected set of routes that export
prerender = false. The same data the no-SSR-without-adapter precondition already uses — one walk overprerender_map, one decision point.
output | SSR routes present | result |
|---|---|---|
"static" | no | V8Mode::Off |
"static" | yes | error |
"hybrid" | any | V8Mode::On |
"auto" | no | V8Mode::Off |
"auto" | yes | V8Mode::On |
The error fires before the bundler runs, names both the output setting and the first offending route, and counts the rest if there are more. So a project that declares itself static can’t accidentally pick up an SSR route as a result of a copy-paste — the build refuses the contradiction instead of silently flipping the route’s deploy shape.
The two manual overrides ("static", "hybrid") and the default ("auto") cover three distinct intents:
"auto"— let the route table decide. Best default for projects that already match."static"— declare intent up front. Useful on SSG-only sites where the failure mode of “someone addsprerender = falseto a page” should be loud, not silent."hybrid"— declare intent the other way. Useful for projects that will add SSR routes later and want the build topology stable in the meantime.
What V8Mode::Off does today is the part to be honest about: it is observational on the shipping zfb binary. The build machine’s zfb always boots V8 to render SSG pages — that’s how the pipeline works — and embed_v8 = off is already a hard error at zfb build. The mode is wired so the future shipping path (Tauri sidecar, standalone SSR server, cargo install-as-deploy) can read the same decision without re-deriving it. The load-bearing user-visible role today is the "static" precondition error.
Atomic writes
Every file in dist/ is written through atomic_write_string (in zfb-build’s atomic.rs): write to a sibling temp file in the same directory, then rename over the destination. rename is atomic on POSIX for same-disk files, and Windows has the same guarantee through MoveFileExW’s replace-existing semantics.
Concretely:
- A reader opening
dist/mid-build sees the old bytes or the new bytes — never half-written, never empty.index. html - A crashed build leaves orphan
*.tmp-<pid>-<seq>files but never corrupts output. The naming is deliberate:ls dist/after a crash shows what was in flight. - The dev server can serve
dist/while the orchestrator rewrites it. No coordination needed.
This is the boring kind of correctness: not a feature, an invariant we never violate.
Watcher debounce and change coalescing
zfb-watcher debounces native events with a 50ms default window. Editor saves are messy — vim writes to a swap file then renames; vscode emits multiple metadata events; git checkout produces hundreds of events at once. The debounce collapses each burst into one Change per path.
The orchestrator does a second pass: when a tick fires, it drains every Change already in the channel before invoking the pipeline. A fast save burst still produces one pipeline run per natural pause. The orchestrator does no extra time-based coalescing on top of the watcher — the watcher already did the right thing. Typing fast does not thrash the build.
Relationship to the dev server
The dev server (zfb-server) is a thin reader on top of the orchestrator’s outputs. It owns a PageCache (URL path to rendered HTML, populated after every render), a tokio::sync::broadcast channel of ReloadEvent values, and an axum router that serves the cache, dist/assets/, public/, and an SSE endpoint at /.
The wiring point is outcome_to_events in zfb-server’s livereload.rs. Every non-noop BuildOutcome is translated into ReloadEvents and broadcast. CSS-only changes emit a css event so the browser swaps stylesheets without losing client state; everything else emits a page event and triggers location.reload().
Request-time SSR for prerender = false routes
The dev server also serves prerender = false routes through the same embedded V8 host that drives build-time SSG — not from a stamped static snapshot. The dev router’s per-request precedence is:
- plugin dev-middleware (longest-prefix match wins),
- request-time SSR for any URL matched by an
SsrRouteSet, - in-memory page cache (SSG output),
- on-disk fallback to
dist/, - on-disk fallback to
public/, - dev 404.
The SSR layer is wired via a small SsrDispatcher trait in zfb-server. The bin crate (crates/) provides the concrete implementation via EmbeddedV8SsrAdapter, which clones a handle to the renderer’s Arc<Mutex<Option<RendererState>>> and dispatches through EmbeddedV8Host::dispatch_fetch on a spawn_blocking task. The V8 isolate stays on its dedicated OS thread; the adapter doesn’t spawn a second thread.
This is what makes “dev matches prod” a real guarantee: the dev preview’s prerender = false output is semantically equivalent (same status, body, content-type — timestamps and request IDs may differ) to what the Cloudflare adapter produces from the same source. The shared V8 host means there is no second renderer to drift.
The server is dev-only. Production emits static files for an edge CDN, plus a _worker.js from the Cloudflare adapter for prerender = false routes. The same atomic-write guarantee that makes the dev server safe makes any production deploy safe.
Embed-as-library: host-supplied HTTP handlers
A Rust host (a Tauri desktop app, a CLI tool, a containerised service) can run zfb-server in-process via the Server::builder() API. That builder also exposes with_ssr_handler(pattern, handler), which registers a host-owned async function for a URL pattern.
Registered handlers slot into the dev router’s precedence chain between plugin dev-middleware and the runtime SSR dispatcher:
- plugin dev-middleware,
- host-registered Rust handler (new — embed-as-library only),
- request-time SSR for any URL matched by an
SsrRouteSet, - in-memory page cache (SSG output),
- on-disk fallback to
dist/, - on-disk fallback to
public/, - dev 404.
The host handler wins over any same-path runtime-SSR page — that is the whole point of the seam. See the Embed-as-library guide for the builder shape, the handler signature, and the precedence contract in code.