Embedded SPA in Daemon Binary
Embedded SPA in daemon binary
If a daemon ships a web UI, the cleanest distribution is to embed the built SPA in the daemon binary itself. One artifact to ship, install, and update. No separate web server to manage, no version mismatch between daemon and UI, no "did you run npm install?" support thread.
The mechanism
In Rust with include_dir!():
static SPA_DIST: include_dir::Dir = include_dir!("apps/web/dist");
At build time the macro reads apps/web/dist/ and bakes the bytes into the binary as static data. At runtime the daemon serves them at / with mime guessed from extension. Long-cached for hashed asset paths, fallback to index.html for unknown paths so client-side routing works.
Equivalents in other languages: Go's embed, Node's pkg, Python's pyinstaller --add-data. Same idea — read the dist folder at build time, embed as static bytes, serve at runtime.
What this rules out
- A separate static-asset deployment alongside the daemon. The asset and the API are versioned together because they're the same file.
- A reverse proxy between user and daemon for the UI. The daemon answers HTTP on its bridge port; the UI is at
/. - Drift between "API the daemon speaks" and "API the UI expects." They ship together, by construction.
The build-time wrinkle
Embedding at build time means a fresh checkout can't compile until the SPA is built. Two ways to handle that:
- Build script triggers the SPA build. Cargo
build.rsrunsnpm run buildbefore compiling. Works but slows down every cargo invocation and tangles the dependency chain. - Ship a placeholder dist directory. A minimal
apps/web/dist/index.htmllives in-repo socargo build --features web-uiworks without first runningnpm run build. Real builds replace it; placeholder lingers in dev.
Mxr uses (2). The placeholder includes a spa-not-built marker that the CI smoke step greps for and fails the release if found. The placeholder is fine in dev because devs run npm run dev anyway and proxy through Vite. It's catastrophic to ship, so CI catches it loud.
Loud failure beats silent rot
The placeholder-plus-CI-grep pattern is a small specific case of a bigger principle. Anywhere a build can succeed with degraded content, mark the degraded content with something a build step can grep for and refuse to ship. The cost is one string and one grep; the benefit is a class of "we shipped the empty page" incidents never happens.
Feature gating for size-conscious packagers
The embedded SPA is a Cargo feature (web-ui), default-on in the root binary. Packagers who want a smaller binary can opt out with --no-default-features. The daemon still works; just no built-in web UI. This keeps the one-artifact distribution as the default while leaving room for distributions that want to slim down.
Where this generalizes
Any tool with a server component and a web UI where you don't want to manage two artifacts:
- Personal productivity tools (mxr, headless CMSes for solo use, local-first apps with optional UI)
- Internal tools whose users are also their operators
- CLI tools that grow a "and a web dashboard"
- Embedded systems with a config UI
Skip it when: the UI ships independently (mobile app store, browser extension), the server scales horizontally and UIs are CDN-served, or asset size dwarfs daemon size and the daemon updates rarely.
See also
- The Local Daemon Pattern — the architecture this slots into
- Mxr — concrete instance; bridge crate at
crates/web/src/spa.rs - Headless Core + Multiple Clients — the web SPA is one client among several