zfb
GitHub repository

Type to search...

to open search from anywhere

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-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 "use client" directives, bundles all islands into a single shared JS bundle, 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 shared islands client bundle (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 where selector-aware mutation matters: island marker processing, base-link rewriting, and dev-server HTML injection. Production head asset injection is a byte-level splice, not a lol_html pass.Used by zfb-islands/src/html_tree.rs, zfb-build/src/link_base_rewrite.rs, and zfb-server; 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 (shared islands client bundle). 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 in the HTML mutation utilities that need real element or comment awareness: zfb-islands/src/html_tree.rs, zfb-build/src/link_base_rewrite.rs, and zfb-server's dev HTML injection path. zfb-build/src/head_inject.rs deliberately does not use it; that helper only searches for </head> and splices stable CSS / island asset tags before the closing tag.

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.

Shadow staging: how the bundler feeds esbuild a coherent tree

Before esbuild ever runs for the server-side worker bundle, zfb-build's bundler (crates/zfb-build/src/bundler.rs) materialises a shadow tree: a temporary copy of the project's source roots (pages/, content/, components/, layouts/) that mirrors the project's own directory structure, so relative imports resolve unchanged. Each .mdx file is compiled to its JSX output in place, keeping the .mdx extension so esbuild's --loader:.mdx=jsx still applies, and the framework's hydration shim is materialised alongside them as a real file.

Two synthetic files anchor the shadow root:

  • A synthetic tsconfig.json. The bundler rebases the user's tsconfig path aliases (already resolved against the project's own extends chain) onto the shadow tree — rebase_tsconfig_paths_to_shadow — then writes the result with write_synthetic_tsconfig. esbuild resolves the project's aliases (@/components/foo./components/foo) through this file via --tsconfig=, so an aliased import behaves identically whether esbuild reads it from the live tree or the shadow copy.

  • A synthetic entry.mjs. It imports every page module found under the shadow pages/, the hydration shim, and createPageRouter from @takazudo/zfb-runtime/server, and re-exports the routes map plus the Workers-style default { fetch } entry that esbuild bundles.

--preserve-symlinks and the node_modules symlink. By default, non-MDX source files are staged into the shadow tree as symlinks back to the live files rather than copies — cheap, and esbuild reads through them transparently. copy_mode (real copies instead of symlinks) only kicks in for the one configuration where esbuild runs without --preserve-symlinks: a workspace project with non-empty tsconfig paths, where esbuild would otherwise canonicalise a symlinked source back to the live tree and make shadow-only transforms (like import.meta.glob expansion) invisible. Separately, when the caller configures a node_modules_dir (e.g. a pnpm workspace's <workspace-root>/node_modules/.pnpm/node_modules), the bundler creates a <shadow>/node_modules symlink so esbuild can resolve workspace packages from inside the shadow tree. --preserve-symlinks keeps esbuild anchored at the <shadow>/node_modules/<pkg> symlink during resolution instead of canonicalising back to the live path — esbuild skips tsconfig.json discovery for any importer whose resolved path contains a node_modules segment, so losing that anchor would silently break alias resolution for imports written inside workspace packages (see issues #443 / #450, the regression this flag exists to prevent).

The command layer stages its own trees, with a different trigger. The copy_mode rule above is the SSR bundler's own predicate: it governs the shadow tree in crates/zfb-build/src/bundler.rs and nothing else. Client-script and islands staging happens separately, in the command layer (crates/zfb/src/commands/build.rs), and derives its own copy-mode decision as a disjunction — either condition alone selects copy mode. The first is that the staged closure reaches a workspace sibling: a file that is project-local under the widened first-party root (the project boundary stretched out to the pnpm workspace root) but outside the project's own root. The second is the node_modules + non-empty tsconfig paths shape described above. The sibling half stands on its own rather than refining the second, because nothing in the copy path needs node_modules to exist, and a sibling staged as a symlink lets esbuild canonicalise it back to the live tree — precisely the escape staging exists to prevent.

Unsupported by design: a path-style extends reaching into a sibling package's unhoisted node_modules. The command layer mirrors the project's tsconfig extends chain into its stage, but skips any config whose path carries a node_modules segment (internal_shadow_config_path) — dependency-owned configs are expected to resolve through the stage's node_modules symlinks instead. Exactly two such wholesale symlinks exist: one for the workspace-root install, one for the active project's own nested install. A workspace sibling that keeps its own unhoisted node_modules is covered by neither, so an extends written as a path that walks into it ("extends": "../other-pkg/node_modules/@scope/tsconfig/base.json") resolves in the live tree but has nothing to resolve against inside the stage. Relative or absolute makes no difference: internal_shadow_config_path receives an already-resolved path, so both spellings land on the same location and hit the same rejection. That shape is deliberately unsupported. A bare package-name extends is not affectedislands_shadow_config_bytes rewrites those to absolute real paths, so wherever one resolves in the live tree it resolves in the stage too. Which workaround applies depends on where the config doing the extends sits. If that config lives inside the sibling, spell the extends as a bare package name: resolve_tsconfig_extends_file walks up from the extending config and finds the sibling's own node_modules on the way, and the value is then pinned to an absolute real path. If the extending config sits outside the sibling, neither half works alone — a bare name does not resolve, because the sibling's node_modules is not one of that config's ancestors, and hoisting alone changes nothing, because the path still points into the sibling's nested tree. There you have to hoist the dependency to the workspace-root node_modules and rewrite the extends to a bare package name. The decision is recorded on issue #2322: the shape is theoretical today (nothing in the codebase exercises it), and growing the staging gatekeeper into predicting esbuild's resolution case by case is a known non-converging trap. If it is ever supported, the fix is added symlink coverage, not resolution logic.

A fail-closed stage-escape audit runs against esbuild's own metafile after every bundle: it hard-fails the build if any input resolved to live first-party source outside the shadow tree with no staged spelling, or to a node_modules symlink escaping to a live workspace sibling. The shadow tree itself is a build-time-only artifact — it is discarded once the bundle is written, and nothing in dist/ ever depends on it existing.

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 one or more of three ReloadEvent variants and broadcast: Css for CSS-only changes (the browser swaps stylesheets in place without losing client state), Islands when the shared island bundle changed (the browser dynamic-imports the new bundle and re-runs hydration), and Page for everything else, which triggers location.reload(). See Dev mode lifecycle for the full event taxonomy and which edit scenarios trigger which event.

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 (Workers Static Assets, Pages-compatible) 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

CreatedUpdated