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.
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 IntoResponse
That 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.
Method budget
The whole embed surface is intentionally small. The Server and ServerBuilder types combined expose nine methods: 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.
If a future feature needs to exceed ten total, the right move is a follow-up issue that re-shapes the seam — not a tenth 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.toml
Or 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.toml
The 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.