feat(server): embed SPA via memory-serve behind embed-web feature

Adds `memory-serve` 2.1 as an optional workspace dependency, a `build.rs`
that runs `load_directory` only when `CARGO_FEATURE_EMBED_WEB` is set, a
`web_assets` module serving `web/dist` at `/` with SPA fallback (200 OK)
for unknown client-side routes, and a feature-gated integration test.

The default build (no feature) compiles and tests cleanly without `web/dist`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:22:26 +02:00
parent 1d1be5fbe9
commit 7170be016d
7 changed files with 240 additions and 0 deletions
+9
View File
@@ -11,6 +11,9 @@ path = "src/lib.rs"
name = "server"
path = "src/main.rs"
[features]
embed-web = ["dep:memory-serve"]
[dependencies]
tokio.workspace = true
axum.workspace = true
@@ -24,10 +27,16 @@ db = { path = "../db" }
domain = { path = "../domain" }
search = { path = "../search" }
rpassword.workspace = true
memory-serve = { workspace = true, optional = true }
[build-dependencies]
memory-serve = { workspace = true }
[dev-dependencies]
reqwest.workspace = true
serde_json.workspace = true
tower.workspace = true
http-body-util.workspace = true
api = { path = "../api" }
auth = { path = "../auth" }
db = { path = "../db" }
+5
View File
@@ -0,0 +1,5 @@
fn main() {
if std::env::var("CARGO_FEATURE_EMBED_WEB").is_ok() {
memory_serve::load_directory("../../web/dist");
}
}
+14
View File
@@ -2,6 +2,9 @@
mod config;
#[cfg(feature = "embed-web")]
mod web_assets;
pub use config::Config;
use anyhow::Context;
@@ -65,6 +68,9 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
let app = build_app(state);
#[cfg(feature = "embed-web")]
let app = app.merge(web_assets::routes());
axum::serve(listener, app)
.await
.context("running the HTTP server")?;
@@ -72,6 +78,14 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()>
Ok(())
}
#[cfg(feature = "embed-web")]
pub mod test_support {
/// The SPA-asset router, for tests.
pub fn web_router() -> axum::Router {
super::web_assets::routes()
}
}
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
+15
View File
@@ -0,0 +1,15 @@
//! Serves the embedded SPA (built `web/dist`) at `/` with a client-side-routing
//! fallback. Compiled only with the `embed-web` feature; in dev the SPA is served by
//! Vite (which proxies `/api` to this server), so this module is absent.
use axum::{Router, http::StatusCode};
/// A router that serves the embedded `web/dist` assets, falling back to `index.html`
/// for unknown paths so the SPA can own client-side routes.
pub(crate) fn routes() -> Router {
memory_serve::load!()
.index_file(Some("/index.html"))
.fallback(Some("/index.html"))
.fallback_status(StatusCode::OK)
.into_router()
}
+37
View File
@@ -0,0 +1,37 @@
//! Only meaningful with `--features embed-web` and a built `web/dist`. Compiled only
//! under that feature.
#![cfg(feature = "embed-web")]
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn serves_index_at_root_and_spa_fallback() {
let app = server::test_support::web_router();
let root = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(root.status(), StatusCode::OK);
let body = root.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&body).contains("<div id=\"root\">"));
let deep = app
.oneshot(
Request::builder()
.uri("/objects/123")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(deep.status(), StatusCode::OK);
}