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

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:

  1. Build script triggers the SPA build. Cargo build.rs runs npm run build before compiling. Works but slows down every cargo invocation and tangles the dependency chain.
  2. Ship a placeholder dist directory. A minimal apps/web/dist/index.html lives in-repo so cargo build --features web-ui works without first running npm 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:

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