From e562a3de3006ce0230b1a1f57cfb5ed1cabe651d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 29 Aug 2026 01:27:32 +0200 Subject: [PATCH] feat(bundled-binary): include /static-dist into binary --- Cargo.lock | 192 +++++++++++++++++++++-- Cargo.toml | 25 +++ build.rs | 55 ++++++- src/interfaces/web/embedded.rs | 163 ++++++++++++++++++++ src/interfaces/web/mod.rs | 269 ++++++++++++++++++++++++++------- src/main.rs | 104 +++++++++++-- 6 files changed, 731 insertions(+), 77 deletions(-) create mode 100644 src/interfaces/web/embedded.rs diff --git a/Cargo.lock b/Cargo.lock index 1156a1ae..c662e987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "adobe-cmap-parser" version = "0.4.1" @@ -160,7 +166,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -171,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1172,6 +1178,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1962,6 +1978,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + [[package]] name = "dashmap" version = "6.2.1" @@ -2267,7 +2289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2884,6 +2906,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "group" version = "0.13.0" @@ -3285,7 +3320,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -3507,6 +3542,39 @@ dependencies = [ "quick-error", ] +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3603,7 +3671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3802,6 +3870,30 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libflate" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + [[package]] name = "libloading" version = "0.9.0" @@ -4278,6 +4370,15 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -4318,7 +4419,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4597,6 +4698,7 @@ dependencies = [ "rand_core 0.6.4", "rayon", "reqwest", + "rust-embed", "serde", "serde_json", "sha2 0.11.0", @@ -5006,6 +5108,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "proc-macro-utils" version = "0.10.0" @@ -5140,7 +5264,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5177,7 +5301,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -5558,6 +5682,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rmp" version = "0.8.15" @@ -5597,6 +5727,44 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "include-flate", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "include-flate", + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rust-stemmers" version = "1.2.0" @@ -5651,7 +5819,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6174,7 +6342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6686,7 +6854,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8154,7 +8322,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 33f60fa8..cafd5186 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,12 @@ futures = "0.3.32" async-stream = "0.3.6" async-trait = "0.1.89" mime_guess = "2.0.5" +# `rust-embed` — compile-time asset embedding for the `bundled-assets` feature. +# Optional so default builds never pull it in. `compression` deflate-compresses +# each embedded file at compile time; the handler decompresses lazily on first +# read (cached per-file in a `OnceCell`). See src/interfaces/web/embedded.rs +# for the actual embed struct + handlers. +rust-embed = { version = "8", features = ["compression", "include-exclude"], optional = true } uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] } thiserror = "2.0.18" arc-swap = "1.9" @@ -174,6 +180,25 @@ bench = [] # from `test_utils` for the same reason `load_seed_bin` is — enabling # `dev_tools` on the CLI must not perturb the oxicloud dependency graph. dev_tools = [] +# Bake the SvelteKit build output (`static-dist/` at repo root) into the +# binary at compile time via `rust-embed`. Opt-in and off by default — the +# regular `cargo build --release` still produces a filesystem-served +# binary (matching the current Docker image where assets are separate +# layers, and the `just dev` HMR loop where Vite serves live). Release +# tarballs (`docs/plan/bundled-binary.md` § 2) build with this flag to +# ship a single self-contained executable. +# +# Precedence rule preserved: even when this feature is on, if +# OXICLOUD_STATIC_PATH points at an existing directory, that wins over +# the embedded fallback — ops can override embedded assets for locale +# patches or theming without a rebuild. +# +# Build-time invariant: `cargo build --features bundled-assets` requires +# `static-dist/` at the repo root (SvelteKit adapter-static emits there +# — `frontend/svelte.config.js`'s `pages: '../static-dist'`). `build.rs` +# fails fast with a pointer to `(cd frontend && npm run build)` when the +# directory is missing. +bundled-assets = ["dep:rust-embed"] [dev-dependencies] criterion = "0.5" diff --git a/build.rs b/build.rs index 282199e0..78c3f0d9 100644 --- a/build.rs +++ b/build.rs @@ -1,15 +1,64 @@ -//! build.rs — injects git build metadata into the binary. +//! build.rs — injects git build metadata into the binary and, under the +//! optional `bundled-assets` feature, guards the compile-time embed +//! precondition. //! //! Exposes `GIT_HASH` and `GIT_BRANCH` (consumed via `env!()` in `main.rs`). -//! There is no Rust-side asset pipeline: the frontend is built by Vite into -//! `static-dist/` and served directly by the web layer (`interfaces::web`). +//! The frontend is built by Vite into `static-dist/` at the repo root and +//! served directly by the web layer (`interfaces::web`); when +//! `bundled-assets` is on, `src/interfaces/web/embedded.rs` bakes that +//! directory into the binary at compile time via `rust-embed`. use std::env; +use std::path::Path; use std::process::Command; fn main() { println!("cargo:rerun-if-changed=build.rs"); git_status(); + bundled_assets_guard(); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Bundled-assets precondition guard +// +// When `--features bundled-assets` is on, `rust-embed`'s `#[folder = "static-dist/"]` +// scans that directory at compile time and errors with a not-very-helpful +// "No such file or directory" if it's missing. Users hit this first when they +// try `cargo build --release --features bundled-assets` before running the +// frontend build — we intercept it here with a clear, actionable message. +// +// Also emits `cargo:rerun-if-changed=static-dist/` so a fresh frontend build +// re-triggers the embed step without needing `cargo clean` — matches what a +// dev on the bundled feature would expect after `just fe-build`. +// ═══════════════════════════════════════════════════════════════════════════════ +fn bundled_assets_guard() { + if env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_err() { + return; + } + println!("cargo:rerun-if-changed=static-dist"); + + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let dist = Path::new(&manifest_dir).join("static-dist"); + let index = dist.join("index.html"); + if !index.exists() { + // `cargo:warning=` prefixes surface these in the terminal even + // when cargo's default output is quiet; the panic below turns + // them into a compile-time error so the missing prerequisite + // can't slip past a distracted dev. + println!( + "cargo:warning=`bundled-assets` feature requires static-dist/ at the repo root." + ); + println!( + "cargo:warning=Build the SvelteKit SPA first: (cd frontend && npm run build)" + ); + println!( + "cargo:warning=Or via the workspace shortcut: just fe-build" + ); + panic!( + "build.rs: missing {}/index.html — see the cargo:warning lines above", + dist.display() + ); + } } // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/web/embedded.rs b/src/interfaces/web/embedded.rs new file mode 100644 index 00000000..b027eebe --- /dev/null +++ b/src/interfaces/web/embedded.rs @@ -0,0 +1,163 @@ +//! Compile-time-embedded static assets — the `bundled-assets` feature. +//! +//! When the feature is on, the SvelteKit build output at `static-dist/` +//! (repo root, per `frontend/svelte.config.js`'s `adapter-static`) is +//! baked into the binary via `rust-embed` at compile time. The two axum +//! handlers below (`serve_root` for the SPA + fallback, `serve_immutable` +//! for the `_app/immutable` cache-forever subtree) parallel the two +//! `ServeDir` instances the filesystem path uses in `super::mod`. +//! +//! **Precedence** — this module is only invoked when +//! `resolve_static_source(config)` returns `StaticSource::Embedded`. If +//! `OXICLOUD_STATIC_PATH` (or the default `./static/static-dist/`) +//! points at a real directory, the filesystem `ServeDir` path is used +//! instead — ops can still override embedded bytes for locale patches +//! or theming without a full rebuild. +//! +//! **Compression** — `rust-embed`'s `compression` feature stores each +//! embedded file deflate-compressed. Lazy decompression on first access +//! keeps the binary small (~4-5 MB for the current corpus) and the +//! runtime cost negligible: after warmup every file is cached. Response +//! compression is handled by `CompressionLayer` in the parent module — +//! same wire behaviour as the filesystem path for `Content-Encoding: +//! br|gzip|identity` clients. +//! +//! **Vite's precompressed siblings** (`.br` / `.gz`) are excluded from +//! the embed via the `#[exclude]` attributes below — they'd be dead +//! weight because the response compression on the wire already handles +//! this negotiation. + +use axum::body::Body; +use axum::extract::{Path, Request}; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use axum::http::{HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; + +/// SvelteKit SPA build output, baked into the binary at compile time. +/// +/// The `#[folder]` path is relative to `Cargo.toml` (repo root), which +/// matches SvelteKit's `adapter-static` output in +/// `frontend/svelte.config.js` (`pages: '../static-dist'`). +#[derive(rust_embed::RustEmbed)] +#[folder = "static-dist/"] +// Default (no `include` attr) = include everything recursively. An +// explicit `include = "*"` was WRONG — the `*` glob is single-segment +// only, so `locales/*.json`, `_app/immutable/**`, and every other +// subdirectory got excluded and the boot-time `extract_embedded_locales` +// found 0 files (2026-08-28 bug fix). +// +// Vite's precompressed siblings — we serve raw and let the axum +// `CompressionLayer` on the wire negotiate br/gzip. Doubling storage +// would balloon the embed by ~50%. +#[exclude = "**/*.br"] +#[exclude = "**/*.gz"] +pub struct EmbeddedAssets; + +/// Serve any embedded asset by request path, falling back to the SPA +/// shell (`index.html`) for unmatched client routes. +/// +/// Mirror of the `spa` `ServeDir` in `super::create_web_routes` — same +/// fallback semantics so deep links like `/files/` boot the SvelteKit +/// router. `Cache-Control` for the shell itself is left to the outer +/// layer in the parent module (`no-cache` so a deploy can't leave a +/// stale app pinned in browsers); assets carrying no cache header here +/// pick up the parent's default the same way filesystem-served assets do. +/// +/// Wired as axum's `fallback` in `super::web_routes_embedded`, which +/// means there is NO route pattern to capture from — a `Path` extractor +/// would fail at runtime with "Wrong number of path arguments for +/// `Path`. Expected 1 but got 0." (real bug hit 2026-08-28). Pull the +/// URI path off the `Request` directly instead. +pub async fn serve_root(req: Request) -> Response { + let path = req.uri().path().trim_start_matches('/'); + if path.is_empty() { + return spa_shell_response(); + } + match EmbeddedAssets::get(path) { + Some(file) => asset_response(path, file.data), + None => spa_shell_response(), + } +} + +/// Root path (no trailing capture) — always the SPA shell. +/// +/// axum routes `/` separately from `/*path`, so this handles the +/// bare-slash case that `serve_root` never sees. +pub async fn serve_root_index() -> Response { + spa_shell_response() +} + +/// Serve an asset under the `/_app/immutable/*` prefix. The nested route +/// registration in `super::create_web_routes` already strips the +/// `/_app/immutable/` prefix from the captured path, so we look the +/// stripped path up with the prefix re-attached before hitting the embed. +/// +/// Cache-Control (`public, max-age=31536000, immutable`) is applied by +/// the outer `SetResponseHeaderLayer::overriding` in the parent module, +/// same as the filesystem path — this handler just returns bytes + MIME. +pub async fn serve_immutable(Path(path): Path) -> Response { + let full = format!("_app/immutable/{}", path.trim_start_matches('/')); + match EmbeddedAssets::get(&full) { + Some(file) => asset_response(&path, file.data), + None => (StatusCode::NOT_FOUND, "Not Found").into_response(), + } +} + +fn asset_response(path: &str, bytes: std::borrow::Cow<'static, [u8]>) -> Response { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + let mut resp = Response::new(Body::from(bytes.into_owned())); + resp.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static( + "application/octet-stream", + )), + ); + resp +} + +fn spa_shell_response() -> Response { + match EmbeddedAssets::get("index.html") { + Some(shell) => { + let mut resp = Response::new(Body::from(shell.data.into_owned())); + resp.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); + // Belt: the parent module also stamps this on unset, + // but stamp it here too so the shell never accidentally + // ends up cacheable in front of a deploy. + resp.headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + resp + } + None => { + // This means `static-dist/` was empty at build time — the + // build.rs guard should have prevented us from ever getting + // here. Surface as 500 rather than pretending the SPA works. + ( + StatusCode::INTERNAL_SERVER_ERROR, + "SPA shell missing from embedded assets; rebuild with an up-to-date static-dist/", + ) + .into_response() + } + } +} + +/// Iterate over the embedded `.html` files at the root of `static-dist/` +/// so the CSP inline-script scanner can hash them without a filesystem +/// read. Mirrors what `std::fs::read_dir(static_path)` yields on the +/// filesystem path, filtered to `.html` at the top level. +pub fn embedded_html_shells() -> Vec<(String, std::borrow::Cow<'static, [u8]>)> { + EmbeddedAssets::iter() + .filter(|p| { + // Root-level `.html` only — SvelteKit emits `index.html` at + // the root and everything else under `_app/`. Nested `.html` + // (e.g. sourcemap tooling artefacts) doesn't inline-script, + // so skip. + let s: &str = p.as_ref(); + s.ends_with(".html") && !s.contains('/') + }) + .filter_map(|p| { + let name = p.to_string(); + EmbeddedAssets::get(&name).map(|f| (name, f.data)) + }) + .collect() +} diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index e37a47b5..caa165a7 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -15,23 +15,105 @@ use tower_http::compression::CompressionLayer; use tower_http::services::{ServeDir, ServeFile}; use tower_http::set_header::SetResponseHeaderLayer; -/// Resolve the directory the SPA is actually served from. +#[cfg(feature = "bundled-assets")] +pub mod embedded; + +/// Where the SPA + immutable assets are served from — filesystem +/// (default and also the fallback on bundled builds when +/// `OXICLOUD_STATIC_PATH` points at real files) or the compile-time +/// embedded corpus (bundled-assets feature only). /// -/// Prefers the Vite build output (`static-dist/`) sitting next to the configured -/// static path, falling back to the configured path itself — the container ships -/// the built SPA straight to `OXICLOUD_STATIC_PATH` (default `./static`), so there -/// the fallback is what serves. Shared with the CSP layer in `main.rs` so the -/// inline-script hashes are computed from exactly the bytes that get served. -pub fn resolve_static_path(config: &AppConfig) -> PathBuf { +/// Returned by [`resolve_static_source`]; matched at each of the four +/// consumer sites (SPA `ServeDir`, `_app/immutable` `ServeDir`, +/// CSP inline-script scan, and the locale-loader picker in `main.rs`). +#[derive(Debug, Clone)] +pub enum StaticSource { + Filesystem(PathBuf), + /// Serve from the `EmbeddedAssets` corpus in the [`embedded`] module. + /// Only reachable under `--features bundled-assets` — the variant is + /// cfg-gated so match arms in non-bundled builds stay exhaustive on + /// a single variant, giving zero runtime cost. + #[cfg(feature = "bundled-assets")] + Embedded, +} + +/// Resolve where static assets come from. +/// +/// Order of precedence (highest first): +/// 1. `/../static-dist/` when it exists — matches +/// the SvelteKit adapter-static output at the repo root. +/// 2. `OXICLOUD_STATIC_PATH` itself when it exists — Docker image path +/// (assets copied straight to `/app/static/`). +/// 3. Bundled-assets fallback (only when the feature is on) — the +/// compile-time embedded corpus. +/// 4. Non-bundled fallback — return the configured path anyway, letting +/// downstream `ServeDir` fail predictably at request time. +/// +/// Rule (2) exists so ops running a bundled binary can still point +/// `OXICLOUD_STATIC_PATH` at a live directory (locale patch, theme +/// override) and see it win over the embedded copy without a rebuild. +pub fn resolve_static_source(config: &AppConfig) -> StaticSource { let dist = config .static_path .parent() .unwrap_or(Path::new(".")) .join("static-dist"); if dist.exists() { - return dist; + tracing::info!( + source = %dist.display(), + "static-assets: serving from filesystem (Vite build output at /../static-dist/)" + ); + return StaticSource::Filesystem(dist); + } + if config.static_path.exists() { + tracing::info!( + source = %config.static_path.display(), + "static-assets: serving from filesystem (OXICLOUD_STATIC_PATH)" + ); + return StaticSource::Filesystem(config.static_path.clone()); + } + #[cfg(feature = "bundled-assets")] + { + tracing::info!( + configured_static_path = %config.static_path.display(), + "static-assets: no filesystem source found, serving embedded corpus \ + (bundled-assets feature). Set OXICLOUD_STATIC_PATH to override with a \ + live directory." + ); + StaticSource::Embedded + } + #[cfg(not(feature = "bundled-assets"))] + { + // Non-bundled build with no on-disk source. Return the configured + // path anyway — downstream `ServeDir` will fail predictably at + // request time. A separate boot-time warning wouldn't help; the + // real fix is to build the SPA or set OXICLOUD_STATIC_PATH. + StaticSource::Filesystem(config.static_path.clone()) + } +} + +/// Backwards-compat helper: resolve to a `PathBuf` directly. +/// +/// Preserved for callers that predate the `StaticSource` enum. Only +/// callable in configurations where a filesystem path exists — a +/// bundled build whose `resolve_static_source` returned `Embedded` +/// would panic here, so new code should always match on +/// `resolve_static_source(...)` instead. +pub fn resolve_static_path(config: &AppConfig) -> PathBuf { + match resolve_static_source(config) { + StaticSource::Filesystem(p) => p, + #[cfg(feature = "bundled-assets")] + StaticSource::Embedded => { + // Every migrated caller matches on StaticSource directly; + // this branch means someone called the legacy helper from + // a bundled build. Fix the caller, not the shim. + panic!( + "resolve_static_path() called on a bundled build with no filesystem \ + assets — migrate the caller to resolve_static_source() and match on \ + StaticSource::Embedded" + ) + } } - config.static_path.clone() } /// Serves the SvelteKit single-page app. @@ -44,43 +126,36 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf { /// Caching: content-hashed assets under `/_app/immutable` are cached forever; /// everything else — crucially the `index.html` shell — is `no-cache` so a deploy /// can't leave a stale app pinned in browsers. -pub fn create_web_routes(app_state: Arc) -> Router> { - let config = AppConfig::from_env(); - let static_path = resolve_static_path(&config); +pub fn create_web_routes( + app_state: Arc, + source: StaticSource, +) -> Router> { + // `source` is resolved ONCE at boot in `main.rs::run()` and passed + // in — see the sequence there. Previously this fn called + // `AppConfig::from_env()` + `resolve_static_source(&config)` itself + // (duplicating the env parse + storage-summary log). Threading the + // resolved value through as an arg keeps this fn pure and eliminates + // both duplicates in the boot log (bug fixed 2026-08-28). - // SPA fallback: serve the file if it exists, else the app shell. - // - // `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz` - // (frontend/scripts/precompress.mjs runs at build time), serve those - // bytes directly with the right Content-Encoding instead of re-running - // Brotli over the same immutable bundle on EVERY request — the - // `CompressionLayer` below then skips the already-encoded response and - // remains only the fallback for assets without a precompressed sibling - // (benches/STATIC-PRECOMPRESSED.md). - let spa = ServeDir::new(&static_path) - .precompressed_br() - .precompressed_gzip() - .fallback(ServeFile::new(static_path.join("index.html"))); + // Build the router — two shapes depending on `StaticSource`, but both + // wear the SAME outer layers below (compression fallback, no-cache + // default for the shell, OIDC login short-circuit). Keeping the + // layers common means the filesystem and embedded paths behave + // identically at the wire boundary. + let inner = match source { + StaticSource::Filesystem(static_path) => web_routes_filesystem(&static_path), + #[cfg(feature = "bundled-assets")] + StaticSource::Embedded => web_routes_embedded(), + }; - // Hashed, immutable assets (SvelteKit emits these under /_app/immutable). - let app_immutable = ServeDir::new(static_path.join("_app").join("immutable")) - .precompressed_br() - .precompressed_gzip(); - - Router::new() - .nest_service( - "/_app/immutable", - get_service(app_immutable).layer(SetResponseHeaderLayer::overriding( - CACHE_CONTROL, - HeaderValue::from_static("public, max-age=31536000, immutable"), - )), - ) - .fallback_service(spa) - // Fallback compression for assets without a precompressed sibling. - // Quality 4, NOT the default: the default maps to Brotli q11 — - // ~1.3 s of CPU per 700 KiB bundle per request (measured in - // benches/STATIC-PRECOMPRESSED.md; the .br siblings above carry the - // real q11 bytes, paid once at build time). + inner + // Fallback compression for assets without a precompressed sibling + // (filesystem) or for embedded assets that were compressed at + // compile time and decompressed on read (bundled). Quality 4, + // NOT the default: the default maps to Brotli q11 — ~1.3 s of + // CPU per 700 KiB bundle per request (benches/STATIC-PRECOMPRESSED.md; + // the .br siblings on the filesystem path carry the real q11 + // bytes, paid once at build time). .layer( CompressionLayer::new() .quality(tower_http::CompressionLevel::Precise(4)) @@ -105,6 +180,58 @@ pub fn create_web_routes(app_state: Arc) -> Router> { )) } +/// Filesystem-served SPA — the historical shape. Two `ServeDir` instances +/// with tower-http's `precompressed_br().precompressed_gzip()` picking up +/// Vite's precompressed siblings when present. +fn web_routes_filesystem(static_path: &Path) -> Router> { + // SPA fallback: serve the file if it exists, else the app shell. + // + // `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz` + // (frontend/scripts/precompress.mjs runs at build time), serve those + // bytes directly with the right Content-Encoding instead of re-running + // Brotli over the same immutable bundle on EVERY request — the + // `CompressionLayer` in `create_web_routes` then skips the already-encoded + // response and remains only the fallback for assets without a + // precompressed sibling (benches/STATIC-PRECOMPRESSED.md). + let spa = ServeDir::new(static_path) + .precompressed_br() + .precompressed_gzip() + .fallback(ServeFile::new(static_path.join("index.html"))); + + // Hashed, immutable assets (SvelteKit emits these under /_app/immutable). + let app_immutable = ServeDir::new(static_path.join("_app").join("immutable")) + .precompressed_br() + .precompressed_gzip(); + + Router::new() + .nest_service( + "/_app/immutable", + get_service(app_immutable).layer(SetResponseHeaderLayer::overriding( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + )), + ) + .fallback_service(spa) +} + +/// Embedded-assets SPA — mirror of `web_routes_filesystem` using the +/// [`embedded`] module's handlers instead of `ServeDir`. Same URL shape, +/// same cache-header layers, same SPA-shell fallback semantics. +#[cfg(feature = "bundled-assets")] +fn web_routes_embedded() -> Router> { + use axum::routing::get; + Router::new() + .route( + "/_app/immutable/{*path}", + get(embedded::serve_immutable).layer(SetResponseHeaderLayer::overriding( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + )), + ) + .route("/", get(embedded::serve_root_index)) + .fallback(get(embedded::serve_root)) +} + /// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is /// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`). /// @@ -161,15 +288,31 @@ async fn oidc_standalone_login_redirect( /// web worker from a blob URL; `'self'` covers same-origin workers like the /// delta-upload worker. pub fn content_security_policy(config: &AppConfig) -> String { - let static_path = resolve_static_path(config); - let hashes = inline_script_csp_hashes(&static_path); + let source = resolve_static_source(config); + let hashes = match &source { + StaticSource::Filesystem(p) => inline_script_csp_hashes(p), + #[cfg(feature = "bundled-assets")] + StaticSource::Embedded => inline_script_csp_hashes_embedded(), + }; if hashes.is_empty() { - tracing::warn!( - static_path = %static_path.display(), - "CSP: no inline