diff --git a/CHANGELOG.md b/CHANGELOG.md index 669e071..3401e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.4.1 + +- Replace rust-embed + startup brotli/gzip compression with memory-serve (build-time compression, zero startup cost) +- Remove rust-embed, brotli, flate2, mime_guess dependencies +- Closes #613 + ## 2.4.0 - Upgrade axum 0.7 to 0.8, ws-bridge 0.1 to 0.2, tokio-tungstenite 0.24 to 0.28 diff --git a/Cargo.toml b/Cargo.toml index f786bbf..318b364 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["shared", "backend", "frontend", "proxy", "claude-session-lib", "laun resolver = "2" [workspace.package] -version = "2.4.0" +version = "2.4.1" edition = "2021" authors = ["Matthew Goodman "] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 15e0227..6205634 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -71,10 +71,10 @@ clap = { version = "4.5", features = ["derive"] } tower-cookies = { version = "0.11", features = ["signed"] } bigdecimal = "0.4.10" google-cognitive-apis = { version = "0.2.2", features = ["speech-to-text"] } -rust-embed = { version = "8.11.0", features = ["axum", "mime-guess"] } -mime_guess = "2.0.5" -brotli = "8.0" -flate2 = "1.0" +memory-serve = "2.1" ws-bridge = { workspace = true, features = ["server"] } tower_governor = "0.8.0" governor = "0.8" + +[build-dependencies] +memory-serve = "2.1" diff --git a/backend/build.rs b/backend/build.rs new file mode 100644 index 0000000..1c06761 --- /dev/null +++ b/backend/build.rs @@ -0,0 +1,3 @@ +fn main() { + memory_serve::load_directory("../frontend/dist"); +} diff --git a/backend/src/embedded_assets.rs b/backend/src/embedded_assets.rs deleted file mode 100644 index 4f7c560..0000000 --- a/backend/src/embedded_assets.rs +++ /dev/null @@ -1,137 +0,0 @@ -use axum::{ - body::Body, - http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, - response::{IntoResponse, Response}, -}; -use rust_embed::RustEmbed; -use std::collections::HashMap; -use std::io::Write; -use std::sync::OnceLock; - -#[derive(RustEmbed)] -#[folder = "../frontend/dist"] -pub struct FrontendAssets; - -struct CompressedAsset { - raw: Vec, - brotli: Vec, - gzip: Vec, - mime: String, - is_index: bool, -} - -static CACHE: OnceLock> = OnceLock::new(); - -/// Pre-compress all embedded assets at startup. -/// Call this before starting the server so the first request is fast. -pub fn init_cache() { - CACHE.get_or_init(|| { - let mut map = HashMap::new(); - let mut total_raw = 0u64; - let mut total_br = 0u64; - - for path in FrontendAssets::iter() { - if let Some(content) = FrontendAssets::get(&path) { - let raw = content.data.to_vec(); - let mime = mime_guess::from_path(&*path) - .first_or_octet_stream() - .to_string(); - let is_index = *path == *"index.html"; - - // Brotli compress (quality 11 = max) - let brotli_buf = { - let mut buf = Vec::new(); - { - let mut writer = brotli::CompressorWriter::new(&mut buf, 4096, 11, 22); - writer.write_all(&raw).unwrap(); - } - buf - }; - - // Gzip compress (best) - let gzip_buf = { - let mut encoder = - flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); - encoder.write_all(&raw).unwrap(); - encoder.finish().unwrap() - }; - - total_raw += raw.len() as u64; - total_br += brotli_buf.len() as u64; - - map.insert( - path.to_string(), - CompressedAsset { - raw, - brotli: brotli_buf, - gzip: gzip_buf, - mime, - is_index, - }, - ); - } - } - - tracing::info!( - "Pre-compressed {} assets: {:.1} MB raw -> {:.1} MB brotli ({:.0}% reduction)", - map.len(), - total_raw as f64 / 1_048_576.0, - total_br as f64 / 1_048_576.0, - (1.0 - total_br as f64 / total_raw as f64) * 100.0 - ); - - map - }); -} - -fn cache_control(is_index: bool) -> HeaderValue { - if is_index { - HeaderValue::from_static("no-cache") - } else { - HeaderValue::from_static("public, max-age=31536000, immutable") - } -} - -/// Serve pre-compressed embedded frontend assets with SPA fallback. -/// Checks Accept-Encoding and returns brotli/gzip/raw accordingly. -pub async fn serve_embedded_frontend(uri: Uri, headers: HeaderMap) -> Response { - let path = uri.path().trim_start_matches('/'); - let path = if path.is_empty() { "index.html" } else { path }; - - let cache = CACHE.get().expect("asset cache not initialized"); - - let accept = headers - .get(header::ACCEPT_ENCODING) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - // Look up the exact path, fall back to index.html for SPA routing - let asset = cache.get(path).or_else(|| cache.get("index.html")); - - match asset { - Some(asset) => { - let (body, encoding) = if accept.contains("br") { - (asset.brotli.as_slice(), Some("br")) - } else if accept.contains("gzip") { - (asset.gzip.as_slice(), Some("gzip")) - } else { - (asset.raw.as_slice(), None) - }; - - let mut resp = Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &asset.mime) - .header(header::CACHE_CONTROL, cache_control(asset.is_index)) - .body(Body::from(body.to_vec())) - .unwrap(); - - if let Some(enc) = encoding { - resp.headers_mut() - .insert(header::CONTENT_ENCODING, HeaderValue::from_static(enc)); - } - - resp - } - None => (StatusCode::NOT_FOUND, "Frontend not found").into_response(), - } -} diff --git a/backend/src/main.rs b/backend/src/main.rs index 5e8bf04..e57880f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,6 +1,5 @@ mod auth; mod db; -mod embedded_assets; mod errors; mod handlers; mod jwt; @@ -22,7 +21,6 @@ use tower_cookies::{CookieManagerLayer, Key}; use tower_governor::governor::GovernorConfigBuilder; use tower_governor::key_extractor::SmartIpKeyExtractor; use tower_governor::GovernorLayer; -use tower_http::compression::CompressionLayer; use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -537,17 +535,18 @@ async fn main() -> anyhow::Result<()> { .merge(auth_device_routes) .merge(download_routes) // Serve embedded frontend assets with SPA fallback - .fallback(axum::routing::get(embedded_assets::serve_embedded_frontend)); - - // Pre-compress all embedded assets at startup (brotli + gzip) - embedded_assets::init_cache(); - tracing::info!("Serving embedded frontend assets"); + .merge( + memory_serve::load!() + .index_file(Some("/index.html")) + .fallback(Some("/index.html")) + .fallback_status(axum::http::StatusCode::OK) + .html_cache_control(memory_serve::CacheControl::NoCache) + .cache_control(memory_serve::CacheControl::Long) + .into_router(), + ); // Add CORS and cookie management - let app = app - .layer(CompressionLayer::new()) - .layer(CookieManagerLayer::new()) - .layer(cors); + let app = app.layer(CookieManagerLayer::new()).layer(cors); // Spawn background task to broadcast user spend updates {