zfb

Type to search...

to open search from anywhere

Build engine

CreatedJun 1, 2026Takeshi Takatsudo

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-router scans pages/ and turns files into routes. It owns the file-name-to-URL convention and nothing else.
  • zfb-graph holds 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-watcher wraps the notify crate, normalises the noisy native events into one Change per logical save, and emits them on a channel.
  • zfb-build is the orchestrator. It consumes Change values, classifies them, asks the graph which pages need rebuilding, and runs the asset pipeline.
  • zfb-render compiles TSX through SWC, hands the resulting JS to a RenderHost, and writes the HTML.
  • zfb-css processes CSS modules and global styles.
  • zfb-islands scans for client:* directives, bundles the per-island JS, and emits the hydration entry.
  • zfb-content parses Markdown / MDX content collections and runs the unified plugin chain.
  • zfb-server is the dev-only HTTP server: a page cache, a static-file route, the live-reload SSE stream, and a request-time SSR dispatcher for prerender = false pages.

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.

ToolRoleTrait boundary
esbuildBundles the server-side worker bundle (zfb-build/bundler.rs) 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)
SWCCompiles 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_htmlStreaming 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-build/head_inject.rs; 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) and zfb-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 in zfb-render calls RenderHost::call_default; the concrete host (EmbeddedV8RenderHost) runs the bundle in an in-process V8 isolate. The orchestrator in zfb-build never names the engine — it only receives rendered HTML.
  • SWC runs inside zfb-render before the module reaches the render host, or inside zfb-build/bundler.rs before esbuild sees the source. Either way it is invisible to the orchestrator.
  • lol_html runs after the render host returns HTML, inside zfb-build/head_inject.rs. The orchestrator sees plain strings in and plain strings out.

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.output in zfb.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 over prerender_map, one decision point.
outputSSR routes presentresult
"static"noV8Mode::Off
"static"yeserror
"hybrid"anyV8Mode::On
"auto"noV8Mode::Off
"auto"yesV8Mode::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 adds prerender = false to 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/index.html mid-build sees the old bytes or the new bytes — never half-written, never empty.
  • 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 /__zfb/reload.

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:

  1. plugin dev-middleware (longest-prefix match wins),
  2. request-time SSR for any URL matched by an SsrRouteSet,
  3. in-memory page cache (SSG output),
  4. on-disk fallback to dist/,
  5. on-disk fallback to public/,
  6. dev 404.

The SSR layer is wired via a small SsrDispatcher trait in zfb-server. The bin crate (crates/zfb/src/commands/dev.rs) 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:

  1. plugin dev-middleware,
  2. host-registered Rust handler (new — embed-as-library only),
  3. request-time SSR for any URL matched by an SsrRouteSet,
  4. in-memory page cache (SSG output),
  5. on-disk fallback to dist/,
  6. on-disk fallback to public/,
  7. 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.

Revision History