Embed as library
Run zfb's HTTP server in-process from a Rust host (Tauri, CLI, service) via the Server builder API.
The zfb-server crate ships a small builder API so a Rust host can run zfb's HTTP server in-process — no child sidecar, no extra binary. The same crate that powers zfb dev powers your embedded server; the only difference is who owns the lifecycle.
This guide covers the public builder shape, the request-extension injection point, and the host-handler seam (with_ssr_handler).
When to reach for this
Pick the embed-as-library path when the host needs to:
run zfb's full route table inside a desktop or CLI process,
attach per-process context (an
AppHandle, an FS capability set, an auth token) that handlers should read on every request,short-circuit specific URL patterns with a Rust-owned response (a markdown lookup, a database read, a sidecar API bridge) without round-tripping through any JS runtime.
See Desktop Deployment for the composition matrix that decides between this mode and shipping a child sidecar.
Builder shape
The public surface is small. The whole Server + ServerBuilder types together expose ≤ 10 methods.
use zfb_server::{Server, ServerHandle, ServerMode, RouteParams};
use axum::http::{Request, StatusCode};
use axum::body::Body;
let server = Server::builder()
.config_path("./zfb.config.json")
.mode(ServerMode::Embed)
.bind("127.0.0.1:0".parse()?)
.with_request_extension(host_ctx.clone())
.with_ssr_handler(
"/api/echo/:msg",
|req: Request<Body>, params: RouteParams| async move {
let msg = params.get("msg").unwrap_or("(none)");
let ctx = req.extensions().get::<HostCtx>().cloned();
(StatusCode::OK, format!("ctx={ctx:?} msg={msg}"))
},
)
.build()?;
let handle: ServerHandle = server.serve_in_thread()?;
println!("listening on {}", handle.addr());
// later:
handle.shutdown()?;Key choices the builder commits to:
ServerMode::Embedturns off the live-reload script injection and the/SSE endpoint. The route table is otherwise identical to_ _ zfb/ reload zfb dev.bind("127.0.0.1:0")asks the OS for an ephemeral port; read the actual port back fromServerHandle::addr().serve_in_thread()spawns a dedicated OS thread with its owncurrent_threadtokio runtime, so the host does not need a tokio runtime of its own (Tauri's synchronoussetupcallback works without changes).ServerHandle::shutdown()is idempotent — calling it twice is a no-op.
The async terminal Server::serve(self, shutdown).await is also available for hosts that already drive their own tokio runtime.
Config format: prefer zfb.config.json
config_path accepts both zfb.config.json and zfb.config.ts. For an embedded server, zfb.config.json is the recommended format — it is read directly with no build step (every example in this guide uses it).
zfb.config.ts is also supported, but evaluating a TypeScript config first bundles it with esbuild, and an embedded zfb-server does not ship its own esbuild binary (unlike the zfb CLI). So an out-of-repo embedder pointing config_path at a .ts file must either:
ship or generate a
zfb.config.jsoninstead — needs no esbuild at all, the simplest path; orset the
ZFB_ESBUILD_BINenvironment variable to a usable esbuild CLI binary before callingbuild().
Note
If a .ts config fails with an "esbuild binary not found" error, this is the cause — switch to zfb.config.json or set ZFB_ESBUILD_BIN.
Request-extension injection
ServerBuilder::with_request_extension::<T>(value) registers a per-process value to be cloned into every incoming request's http::Extensions map. The handler reads it via req.extensions().get::<T>():
#[derive(Clone)]
struct HostCtx { /* … */ }
let server = Server::builder()
.config_path("./zfb.config.json")
.mode(ServerMode::Embed)
.with_request_extension(host_ctx)
.with_ssr_handler("/whoami", |req: Request<Body>, _params| async move {
let ctx = req.extensions().get::<HostCtx>().cloned();
format!("{ctx:?}")
})
.build()?;The bound T: Clone + Send + Sync + 'static is the minimum any per-request context type needs. The handler never has to import axum::Extension<T> — that extractor type is deliberately kept off the public surface of zfb-server.
Calling with_request_extension multiple times with different Ts accumulates values; calling twice with the same T overwrites the first (this matches http::Extensions::insert semantics).
Handler signature
with_ssr_handler takes a URL pattern and an async function with the shape:
async fn(http::Request<axum::body::Body>, zfb_server::RouteParams) -> impl IntoResponseThat is the whole contract. The handler:
Receives the inbound request as a plain
http::Request<Body>— query string, headers, body, and extensions all accessible on the request itself.Receives the captured route parameters as a
RouteParamsvalue withparams.get("name")lookups.Returns anything that implements
axum::response::IntoResponse— aString, a tuple(StatusCode, String), a fullhttp::Response<…>, or a custom type.
The signature is HTTP-shaped on purpose: it does not mention what the handler is for. The same primitive can back a markdown lookup, a database read, an IPC bridge, or any other request-time response — the server only sees bytes in and bytes out.
Route-pattern grammar
Patterns are leading-slash paths. Each segment is one of:
| Form | Matches | Captured under |
|---|---|---|
foo | the literal segment foo | — |
:name | exactly one non-empty segment | params.get("name") |
*name | one or more remaining segments joined with / | params.get("name") |
A *name wildcard must be the final segment of the pattern. Empty captures (a bare / against a :name slot) are rejected.
Examples:
/matches onlyhealth /.health /matchesusers/ : id /withusers/ 42 id = "42"./matchesfiles/ *rest /withfiles/ a/ b. txt rest =."a/ b. txt" /mixes both.projects/ : proj/ refs/ *rest
Precedence: host handlers win over runtime SSR
This is the load-bearing rule of the embed seam. When the dev router receives a request, it tries layers in this order:
plugin dev-middleware (longest-prefix match) — when a registered plugin claims the path,
host-registered Rust handler (this builder method) — when one of the patterns above matches,
request-time SSR — when an
SsrRouteSetclaims the path,in-memory page cache (SSG output),
on-disk fallback to
dist/,on-disk fallback to
public/,dev 404.
The host handler is always preferred over a runtime-SSR page that claims the same URL. If your project has both a Rust handler at / and a prerender = false page that would also serve /, the Rust handler wins and the SSR dispatcher is never invoked.
Why this direction:
The host is the trusted owner of the process. If it registers a handler for a path, that is a deliberate override.
A handler that needs to fall through can choose not to register; precedence is decided at registration time, not by the handler returning a sentinel.
The reverse order would force every host that wants to override a runtime page to gate the page export behind a build-time flag — coupling two concerns that the embed seam intentionally separates.
Lifecycle and shutdown
Server::serve_in_thread() returns a ServerHandle. The handle is Clone so the host can hand copies to multiple shutdown call-sites (Tauri's on_window_event, a Ctrl-C handler, an HTTP / route). All clones share the same one-shot sender and join handle behind Arc<Mutex<…>>:
let handle = server.serve_in_thread()?;
let h2 = handle.clone();
ctrl_c_callback(move || {
let _ = h2.shutdown(); // idempotent
});
// On the main thread, wait for the server to exit:
handle.join()??;shutdown() sends the graceful-shutdown signal once; subsequent calls are no-ops. join() is single-shot — only one caller can wait for the thread.
Live content / file-watch
Everything above describes a static-dist server: build() reads your zfb.config.json, and the route table falls through to the files in dist/. That is the right shape for a packaged app whose content is fixed at build time.
If instead the host needs to push content into the server at runtime — a PageCache written from a file-watcher callback, a livereload signal after each rebuild — there are two levels to reach for, depending on how much control you need.
Level 1: seed a PageCache through the builder
ServerBuilder::with_page_cache(cache) hands the server a PageCache you own instead of the empty one build() allocates by default. PageCache is Clone (internally an Arc<RwLock<…>>), so you keep a handle, give a clone to the builder, and then write into the same shared map at runtime — the running server sees every insert:
use zfb_server::{Server, ServerMode, PageCache};
let cache = PageCache::new();
cache.insert("/", "<h1>initial</h1>").await; // seed before serving
let server = Server::builder()
.config_path("./zfb.config.json")
.mode(ServerMode::Embed)
.with_page_cache(cache.clone())
.build()?;
let handle = server.serve_in_thread()?;
// later — from your own file-watcher callback or rebuild loop:
cache.insert("/blog/foo", rebuilt_html).await; // add or replace one URL
cache.remove(["/blog/stale"]).await; // drop a vanished routeCache keys are the leading-slash URL path the browser asks for (/, /, /). The page-cache layer sits above the on-disk dist/ fallback in the precedence list above, so a cache hit shadows the static file at the same URL while a miss still falls through to disk. PageCache also exposes insert_with_content_type, insert_bytes_with_content_type (binary bodies), and replace_all (atomic full swap) for non-HTML and bulk-rebuild cases.
This is enough when all you need is dynamic page bytes. What it does not give you is a livereload channel — ServerMode::Embed does not inject the live-reload script or mount the / SSE endpoint, and the builder owns the broadcast sender internally.
Level 2: drop to ServeOpts + serve_with_listener
When you need more than a page cache — a live livereload broadcast channel, request-time SSR routes (SsrRoutesHandle), plugin dev-middleware — the builder is deliberately not the entry point. Construct a ServeOpts yourself and call the lower-level serve_with_listener(opts, listener, shutdown) (or serve(opts, shutdown), which binds the listener for you). Both are public and stable — they are the same entry points zfb dev itself uses.
ServeOpts is a plain struct you fill in directly. The fields relevant to live content are:
pages: PageCache— same shared cache as Level 1; write into it from your watcher.broadcast: ReloadTx— the live-reload sender.ReloadTxistokio::sync::broadcast::Sender<ReloadEvent>. After each rebuild, send aReloadEventinto it and connected clients receiving the SSE stream reload. (You build the channel withtokio::sync::broadcast::channel::<ReloadEvent>(capacity)and keep theSender; themodeyou pick decides whether the SSE endpoint is mounted.)ssr_routes: Option<SsrRoutesHandle>— request-time JS SSR forprerender = falsepages, swappable mid-session.mode: ServerMode— pickServerMode::Devif you want the livereload script injection and/SSE endpoint mounted;_ _ zfb/ reload Embed/Previewkeep those off.
use zfb_server::{serve_with_listener, ServeOpts, ServerMode, PageCache, ReloadEvent};
use tokio::net::TcpListener;
let pages = PageCache::new();
let (broadcast, _rx) = tokio::sync::broadcast::channel::<ReloadEvent>(64);
let opts = ServeOpts {
project_root: project_root.clone(),
dist_root: dist_root.clone(),
html_root: dist_root.clone(), // embed/preview callers alias dist_root
public_root,
addr: "127.0.0.1:0".parse()?,
pages: pages.clone(),
broadcast: broadcast.clone(),
mode: ServerMode::Dev, // Dev mounts the livereload SSE endpoint
plugins: None,
injected_routes: None,
ssr_routes: None,
base: None,
trailing_slash: false,
islands_bundle_url: None,
css_bundle_url: None,
allowed_hosts: Vec::new(),
bound_host: None,
render_on_request_hook: None,
};
let listener = TcpListener::bind(opts.addr).await?;
// from your watcher: pages.insert(url, html).await; then broadcast.send(reload_event);
serve_with_listener(opts, listener, std::future::pending()).await?;All paths in ServeOpts must be absolute, and you resolve them yourself (the builder's config_path loader does this for you, which is one reason to prefer Level 1 when it suffices). This is the lowest public layer — exactly what the zfb dev bin crate threads its file-watcher and rebuild orchestrator into.
Full MDX watch-rebuild
Seeding the cache covers serving dynamic bytes, but it does not run zfb's MDX compile / island bundling pipeline for you — that lives in the zfb bin crate. If you need a user to edit Markdown locally and see a fully re-rendered preview, the Desktop Deployment "harder case" note covers spawning zfb dev as a sidecar (Mode B) for that full pipeline.
Method budget
The whole embed surface is intentionally small. The Server and ServerBuilder types combined expose nine methods that count toward the budget: Server::builder, Server::serve, Server::serve_in_thread, plus six builder methods (config_path, mode, bind, with_request_extension, with_ssr_handler, build). ServerHandle::addr, ServerHandle::shutdown, and ServerHandle::join are deliberately on a separate type and are excluded from the budget.
with_page_cache (above) is the live-content escape hatch and is likewise excluded from the count by design — it is the builder-level parallel to dropping to ServeOpts, not part of the core static-dist surface.
If a future feature needs to exceed the budget, the right move is a follow-up issue that re-shapes the seam — not another method tacked on.
Example crate
A minimal end-to-end example lives at crates/. Build it from the workspace root:
cargo build --manifest-path crates/zfb-server/examples/embed/Cargo.tomlOr run it (the example boots a server, issues one HTTP request to demonstrate the handler answers with the injected context, and shuts down):
cargo run --manifest-path crates/zfb-server/examples/embed/Cargo.tomlThe example crate uses an empty [workspace] table to opt itself out of the parent workspace, so the root manifest doesn't have to list it as a member.