From 7170be016dd4016a7b3cfb89c6852fda2eb5aa04 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Wed, 3 Jun 2026 23:22:26 +0200 Subject: [PATCH] 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 --- Cargo.lock | 159 ++++++++++++++++++++++++++++++++ Cargo.toml | 1 + crates/server/Cargo.toml | 9 ++ crates/server/build.rs | 5 + crates/server/src/lib.rs | 14 +++ crates/server/src/web_assets.rs | 15 +++ crates/server/tests/embed.rs | 37 ++++++++ 7 files changed, 240 insertions(+) create mode 100644 crates/server/build.rs create mode 100644 crates/server/src/web_assets.rs create mode 100644 crates/server/tests/embed.rs diff --git a/Cargo.lock b/Cargo.lock index f117daf..1823e21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -268,6 +289,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -424,6 +466,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -581,6 +632,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1291,12 +1352,48 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memory-serve" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b5bbad2035f57b1e95f66da606832edd935b47d82312e38e1ccffbcfb8a427" +dependencies = [ + "axum", + "brotli", + "flate2", + "mime_guess", + "sha256", + "tracing", + "urlencoding", + "walkdir", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1866,6 +1963,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1970,6 +2076,8 @@ dependencies = [ "clap", "db", "domain", + "http-body-util", + "memory-serve", "reqwest", "rpassword", "search", @@ -1977,6 +2085,7 @@ dependencies = [ "sqlx", "temp-env", "tokio", + "tower", "tracing", "tracing-subscriber", ] @@ -2003,6 +2112,19 @@ dependencies = [ "digest", ] +[[package]] +name = "sha256" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2038,6 +2160,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -2740,6 +2868,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2803,6 +2937,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2869,6 +3009,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3058,6 +3208,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index cfc53ad..3a86aa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ argon2 = "0.5" tower-sessions = "0.14" tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } rpassword = "7" +memory-serve = "2.1" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 233045a..2b75d6c 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -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" } diff --git a/crates/server/build.rs b/crates/server/build.rs new file mode 100644 index 0000000..c332587 --- /dev/null +++ b/crates/server/build.rs @@ -0,0 +1,5 @@ +fn main() { + if std::env::var("CARGO_FEATURE_EMBED_WEB").is_ok() { + memory_serve::load_directory("../../web/dist"); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index c584062..ae630b8 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -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 diff --git a/crates/server/src/web_assets.rs b/crates/server/src/web_assets.rs new file mode 100644 index 0000000..3725699 --- /dev/null +++ b/crates/server/src/web_assets.rs @@ -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() +} diff --git a/crates/server/tests/embed.rs b/crates/server/tests/embed.rs new file mode 100644 index 0000000..29b75c0 --- /dev/null +++ b/crates/server/tests/embed.rs @@ -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("
")); + + let deep = app + .oneshot( + Request::builder() + .uri("/objects/123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(deep.status(), StatusCode::OK); +}