feat(bundled-binary): include /static-dist into binary
This commit is contained in:
Generated
+180
-12
@@ -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]]
|
||||
|
||||
+25
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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/<id>` 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<String>) -> 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()
|
||||
}
|
||||
+212
-47
@@ -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. `<OXICLOUD_STATIC_PATH>/../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>/../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<AppState>) -> Router<Arc<AppState>> {
|
||||
let config = AppConfig::from_env();
|
||||
let static_path = resolve_static_path(&config);
|
||||
pub fn create_web_routes(
|
||||
app_state: Arc<AppState>,
|
||||
source: StaticSource,
|
||||
) -> Router<Arc<AppState>> {
|
||||
// `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<AppState>) -> Router<Arc<AppState>> {
|
||||
))
|
||||
}
|
||||
|
||||
/// 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<Arc<AppState>> {
|
||||
// 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<Arc<AppState>> {
|
||||
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,16 +288,32 @@ 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() {
|
||||
match &source {
|
||||
StaticSource::Filesystem(p) => {
|
||||
tracing::warn!(
|
||||
static_path = %static_path.display(),
|
||||
static_path = %p.display(),
|
||||
"CSP: no inline <script> hashes computed — if the SPA shell ships \
|
||||
inline scripts they will be blocked by script-src 'self'. Check the \
|
||||
static asset path (OXICLOUD_STATIC_PATH)."
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "bundled-assets")]
|
||||
StaticSource::Embedded => {
|
||||
tracing::warn!(
|
||||
"CSP: no inline <script> hashes computed from embedded corpus — \
|
||||
the SPA shell may boot with a blocked script-src. Rebuild with \
|
||||
an up-to-date static-dist/."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `'wasm-unsafe-eval'` is required for WebAssembly compilation/instantiation
|
||||
// under a strict CSP (Chromium blocks `WebAssembly.instantiate` otherwise with
|
||||
@@ -232,6 +375,28 @@ fn inline_script_csp_hashes(static_path: &Path) -> Vec<String> {
|
||||
hashes.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Embedded-corpus twin of [`inline_script_csp_hashes`].
|
||||
///
|
||||
/// Same arithmetic — iterate root-level `.html` shells, extract every
|
||||
/// inline `<script>`, hash each — but pulls bytes from
|
||||
/// [`embedded::EmbeddedAssets`] instead of the filesystem. The two
|
||||
/// functions produce identical output for the same source tree, so
|
||||
/// `content_security_policy` can pick either without callers seeing a
|
||||
/// difference.
|
||||
#[cfg(feature = "bundled-assets")]
|
||||
fn inline_script_csp_hashes_embedded() -> Vec<String> {
|
||||
let mut hashes = BTreeSet::new();
|
||||
for (_name, bytes) in embedded::embedded_html_shells() {
|
||||
let Ok(html) = std::str::from_utf8(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
for script in inline_scripts(html) {
|
||||
hashes.insert(csp_hash(script));
|
||||
}
|
||||
}
|
||||
hashes.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The CSP `'sha256-<base64>'` source expression for one inline script body.
|
||||
fn csp_hash(script: &str) -> String {
|
||||
let digest = Sha256::digest(script.as_bytes());
|
||||
|
||||
+94
-10
@@ -58,7 +58,7 @@ use common::di::AppServiceFactory;
|
||||
use infrastructure::db::create_database_pools;
|
||||
use interfaces::{
|
||||
create_api_routes, create_health_routes, create_public_api_routes,
|
||||
web::{create_web_routes, resolve_static_path},
|
||||
web::{StaticSource, create_web_routes, resolve_static_source},
|
||||
};
|
||||
|
||||
fn parse_addr(host: &str, port: u16) -> Result<SocketAddr, String> {
|
||||
@@ -350,6 +350,92 @@ fn build_runtime() -> std::io::Result<tokio::runtime::Runtime> {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Resolve where locale JSON files are read from at boot time.
|
||||
///
|
||||
/// Under the default (filesystem) build: return the same path the
|
||||
/// filesystem `ServeDir` serves from, with a fallback to
|
||||
/// `frontend/static/locales` for `just dev` checkouts where the SPA
|
||||
/// build hasn't run.
|
||||
///
|
||||
/// Under `--features bundled-assets`, when `resolve_static_source`
|
||||
/// returns `Embedded` (i.e. no filesystem override is present), extract
|
||||
/// the embedded `locales/*.json` files to a boot-time tempdir and
|
||||
/// return that path. Runtime code (`LocaleRegistry::discover`,
|
||||
/// `FileSystemI18nService`) is unchanged: it still reads locale JSON
|
||||
/// from a directory. The tempdir is process-scoped; `LocaleRegistry`
|
||||
/// caches everything in-memory at boot, so the extracted files are
|
||||
/// unused after the initial scan and can leak on abrupt process death
|
||||
/// without affecting subsequent boots.
|
||||
fn resolve_locales_path(source: &StaticSource) -> std::path::PathBuf {
|
||||
match source {
|
||||
StaticSource::Filesystem(path) => {
|
||||
let served = path.join("locales");
|
||||
if served.is_dir() {
|
||||
served
|
||||
} else {
|
||||
std::path::PathBuf::from("frontend/static/locales")
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "bundled-assets")]
|
||||
StaticSource::Embedded => extract_embedded_locales(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the embedded `locales/*.json` corpus to a boot-time tempdir
|
||||
/// so the existing filesystem-based locale loader can consume it
|
||||
/// unchanged. Called once at boot in the embedded-assets path.
|
||||
///
|
||||
/// Cost: ~50 ms for 16 JSON files totalling ~2.2 MB. The tempdir lives
|
||||
/// under `std::env::temp_dir()` (respects `$TMPDIR`); no cleanup is
|
||||
/// registered because `LocaleRegistry::discover` reads every file into
|
||||
/// memory at boot, so the extracted copy is dead weight once boot
|
||||
/// completes. On a graceful shutdown the tempdir persists until the
|
||||
/// OS's tmpfs / cron reaper collects it; on abrupt kill likewise. Safe:
|
||||
/// no secrets touch these files.
|
||||
#[cfg(feature = "bundled-assets")]
|
||||
fn extract_embedded_locales() -> std::path::PathBuf {
|
||||
use interfaces::web::embedded::EmbeddedAssets;
|
||||
let dir = std::env::temp_dir().join(format!("oxicloud-locales-{}", std::process::id()));
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
panic!(
|
||||
"FATAL: failed to create embedded-locales staging dir at {}: {e}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
let mut count = 0usize;
|
||||
for path in EmbeddedAssets::iter() {
|
||||
let s: &str = path.as_ref();
|
||||
// Root-level `locales/*.json` only. SvelteKit copies
|
||||
// `frontend/static/locales/*.json` here at build time.
|
||||
if !s.starts_with("locales/") || !s.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let name = &s["locales/".len()..];
|
||||
if name.contains('/') {
|
||||
continue; // no nested subdirs today
|
||||
}
|
||||
let Some(file) = EmbeddedAssets::get(s) else {
|
||||
continue;
|
||||
};
|
||||
let out = dir.join(name);
|
||||
if let Err(e) = std::fs::write(&out, file.data.as_ref()) {
|
||||
panic!(
|
||||
"FATAL: failed to stage embedded locale {} at {}: {e}",
|
||||
s,
|
||||
out.display()
|
||||
);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
tracing::info!(
|
||||
staging_dir = %dir.display(),
|
||||
count,
|
||||
"static-assets: staged {count} embedded locale file(s) for the boot-time \
|
||||
LocaleRegistry scan (bundled-assets feature)."
|
||||
);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Async entrypoint, driven by the runtime built in [`main`].
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize tracing.
|
||||
@@ -500,14 +586,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// /app/static). Fail-fast if the path is missing rather than silently
|
||||
// creating an empty directory and limping along with a "translation missing"
|
||||
// error on every request later.
|
||||
let locales_path = {
|
||||
let served = resolve_static_path(&config).join("locales");
|
||||
if served.is_dir() {
|
||||
served
|
||||
} else {
|
||||
std::path::PathBuf::from("frontend/static/locales")
|
||||
}
|
||||
};
|
||||
// Resolve the static-assets source ONCE at boot — the resolution
|
||||
// logs a single line describing which path was chosen. Reused
|
||||
// downstream for both the locale loader (below) and the web router
|
||||
// (`create_web_routes`), so neither has to re-parse env or re-log.
|
||||
let static_source = resolve_static_source(&config);
|
||||
let locales_path = resolve_locales_path(&static_source);
|
||||
if !locales_path.is_dir() {
|
||||
panic!(
|
||||
"FATAL: locales directory not found at {}. \
|
||||
@@ -532,7 +616,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_routes = create_api_routes(&app_state);
|
||||
let public_api_routes = create_public_api_routes(&app_state);
|
||||
let health_routes = create_health_routes(&app_state);
|
||||
let web_routes = create_web_routes(app_state.clone());
|
||||
let web_routes = create_web_routes(app_state.clone(), static_source);
|
||||
|
||||
let mut app;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user