diff --git a/Cargo.lock b/Cargo.lock index c157261..b61ae4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,7 +110,7 @@ dependencies = [ "actix-utils", "base64 0.22.1", "bitflags", - "brotli", + "brotli 8.0.2", "bytes", "bytestring", "derive_more 2.1.1", @@ -443,7 +443,6 @@ name = "agent" version = "0.2.9" dependencies = [ "agent-tool-derive", - "async-openai", "async-trait", "chrono", "config", @@ -454,13 +453,18 @@ dependencies = [ "once_cell", "qdrant-client", "regex", + "reqwest 0.13.2", + "rig-core", + "rust_decimal", "sea-orm", "serde", "serde_json", "thiserror 2.0.18", "tiktoken-rs", "tokio", + "tokio-stream", "tracing", + "utoipa", "uuid", ] @@ -918,6 +922,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + [[package]] name = "as-slice" version = "0.2.1" @@ -950,46 +960,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-openai" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec08254d61379df136135d3d1ac04301be7699fd7d9e57655c63ac7d650a6922" -dependencies = [ - "async-openai-macros", - "backoff", - "base64 0.22.1", - "bytes", - "derive_builder", - "eventsource-stream", - "futures", - "getrandom 0.3.4", - "rand 0.9.2", - "reqwest 0.12.28", - "reqwest-eventsource", - "secrecy", - "serde", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", -] - -[[package]] -name = "async-openai-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81872a8e595e8ceceab71c6ba1f9078e313b452a1e31934e6763ef5d308705e4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -1193,12 +1163,9 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" dependencies = [ - "futures-core", "getrandom 0.2.17", "instant", - "pin-project-lite", "rand 0.8.5", - "tokio", ] [[package]] @@ -1404,12 +1371,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + [[package]] name = "brotli" version = "8.0.2" @@ -1418,7 +1396,17 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -1658,7 +1646,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -2102,7 +2090,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -2197,6 +2185,47 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deluxe" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed332aaf752b459088acf3dd4eca323e3ef4b83c70a84ca48fb0ec5305f1488" +dependencies = [ + "deluxe-core", + "deluxe-macros", + "once_cell", + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "deluxe-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddada51c8576df9d6a8450c351ff63042b092c9458b8ac7d20f89cbd0ffd313" +dependencies = [ + "arrayvec", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.117", +] + +[[package]] +name = "deluxe-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87546d9c837f0b7557e47b8bd6eae52c3c223141b76aa233c345c9ab41d9117" +dependencies = [ + "deluxe-core", + "heck 0.4.1", + "if_chain", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "der" version = "0.7.10" @@ -2832,6 +2861,8 @@ dependencies = [ name = "frontend" version = "0.2.9" dependencies = [ + "brotli 7.0.0", + "flate2", "lazy_static", "md5", "walkdir", @@ -3761,9 +3792,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.3", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3904,6 +3937,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + [[package]] name = "image" version = "0.25.10" @@ -4244,7 +4283,7 @@ checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" dependencies = [ "base64 0.22.1", "chrono", - "schemars", + "schemars 0.8.22", "serde", "serde-value", "serde_json", @@ -4312,7 +4351,7 @@ dependencies = [ "http 1.4.0", "json-patch", "k8s-openapi", - "schemars", + "schemars 0.8.22", "serde", "serde-value", "serde_json", @@ -4950,6 +4989,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -5384,6 +5432,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "ouroboros" version = "0.18.5" @@ -5913,13 +5970,23 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] @@ -6580,6 +6647,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -6632,6 +6719,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -6641,21 +6729,24 @@ dependencies = [ "http-body-util", "hyper 1.8.1", "hyper-rustls", + "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", + "mime", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", "rustls", - "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower 0.5.3", @@ -6704,22 +6795,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "reqwest-eventsource" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde" -dependencies = [ - "eventsource-stream", - "futures-core", - "futures-timer", - "mime", - "nom 7.1.3", - "pin-project-lite", - "reqwest 0.12.28", - "thiserror 1.0.69", -] - [[package]] name = "rfc6979" version = "0.4.0" @@ -6736,6 +6811,54 @@ version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +[[package]] +name = "rig-core" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f7a3f0c7c00eaced15a68ee16e1bd6bb709ff598d11b9aedac8b628217dc09" +dependencies = [ + "as-any", + "async-stream", + "base64 0.22.1", + "bytes", + "eventsource-stream", + "fastrand", + "futures", + "futures-timer", + "glob", + "http 1.4.0", + "mime", + "mime_guess", + "nanoid", + "ordered-float 5.3.0", + "pin-project-lite", + "reqwest 0.12.28", + "rig-derive", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-futures", + "url", +] + +[[package]] +name = "rig-derive" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7590f1ffc5cef2af569072500c3ee02836c6cfb9faee9b6f0fc140428a50891" +dependencies = [ + "convert_case 0.10.0", + "deluxe", + "indoc", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "ring" version = "0.17.14" @@ -6785,7 +6908,6 @@ version = "0.2.9" dependencies = [ "agent", "anyhow", - "async-openai", "chrono", "config", "dashmap", @@ -7141,7 +7263,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", - "schemars_derive", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -7158,6 +7293,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -7384,7 +7531,6 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ - "serde", "zeroize", ] @@ -7535,7 +7681,6 @@ dependencies = [ "agent", "anyhow", "argon2", - "async-openai", "avatar", "base64 0.22.1", "base64ct", @@ -8172,6 +8317,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -8261,6 +8412,27 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -8516,6 +8688,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "1.1.0+spec-1.1.0" @@ -8525,6 +8703,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.25.8+spec-1.1.0" @@ -8532,9 +8721,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap 2.13.0", - "toml_datetime", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] @@ -8543,7 +8732,7 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -8753,6 +8942,18 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -9439,6 +9640,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -9642,6 +9854,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.0" diff --git a/libs/api/dist.rs b/libs/api/dist.rs index 27876aa..22a9f7c 100644 --- a/libs/api/dist.rs +++ b/libs/api/dist.rs @@ -26,6 +26,87 @@ fn cache_control_header(path: &str) -> &'static str { } } +/// Returns true if the client explicitly accepts the given encoding via Accept-Encoding header. +/// "*" is treated as accepting all encodings. +fn accepts_encoding(req: &HttpRequest, target: &str) -> bool { + let header_val = match req.headers().get(header::ACCEPT_ENCODING) { + Some(v) => v.to_str().unwrap_or(""), + None => return false, + }; + for part in header_val.split(',') { + let part = part.trim(); + // Strip quality value if present + let enc = part.split(';').next().unwrap_or(part).trim(); + if enc.eq_ignore_ascii_case(target) || enc == "*" { + return true; + } + } + false +} + +/// Determines the Content-Type for a path, with explicit fallback for common frontend extensions. +fn content_type_for_path(path: &str) -> String { + if let Some(ext) = path.rsplit('.').next() { + match ext { + "html" | "htm" => return "text/html; charset=utf-8".into(), + "js" | "mjs" => return "application/javascript; charset=utf-8".into(), + "jsx" | "ts" | "tsx" => return "text/plain; charset=utf-8".into(), + "css" => return "text/css; charset=utf-8".into(), + "json" => return "application/json; charset=utf-8".into(), + "svg" => return "image/svg+xml".into(), + "png" => return "image/png".into(), + "jpg" | "jpeg" => return "image/jpeg".into(), + "gif" => return "image/gif".into(), + "webp" => return "image/webp".into(), + "ico" => return "image/x-icon".into(), + "woff" => return "font/woff".into(), + "woff2" => return "font/woff2".into(), + "ttf" => return "font/ttf".into(), + "otf" => return "font/otf".into(), + "txt" => return "text/plain; charset=utf-8".into(), + "xml" => return "application/xml; charset=utf-8".into(), + _ => {} + } + } + MimeGuess::from_path(path).first_or_octet_stream().to_string() +} + +/// Build an HttpResponse for the given asset. +/// Sets Content-Type, Cache-Control, ETag, and Content-Encoding (only if non-empty). +fn build_asset_response( + req: &HttpRequest, + data: &[u8], + etag: &str, + path: &str, + cc: &str, + content_encoding: &str, +) -> HttpResponse { + // 304 Not Modified when client has the same ETag + if let Some(if_none_match) = req.headers().get(header::IF_NONE_MATCH) { + if let Ok(client_etag) = if_none_match.to_str() { + if client_etag == etag { + let mut resp = HttpResponse::NotModified(); + resp.insert_header(("Cache-Control", cc)); + resp.insert_header(("ETag", etag)); + if !content_encoding.is_empty() { + resp.insert_header(("Content-Encoding", content_encoding)); + } + return resp.finish(); + } + } + } + + let mime = content_type_for_path(path); + let mut resp = HttpResponse::Ok(); + resp.content_type(mime); + resp.insert_header(("Cache-Control", cc)); + resp.insert_header(("ETag", etag)); + if !content_encoding.is_empty() { + resp.insert_header(("Content-Encoding", content_encoding)); + } + resp.body(data.to_vec()) +} + pub async fn serve_frontend(req: HttpRequest, path: web::Path) -> HttpResponse { let path = path.into_inner(); let path_str = if path.is_empty() || path == "/" { @@ -33,37 +114,53 @@ pub async fn serve_frontend(req: HttpRequest, path: web::Path) -> HttpRe } else { path.as_str() }; - let cc = cache_control_header(path_str); - match frontend::get_frontend_asset_with_etag(path_str) { - Some((data, etag)) => { - // Check If-None-Match for conditional request - if let Some(if_none_match) = req.headers().get(header::IF_NONE_MATCH) { - if let Ok(client_etag) = if_none_match.to_str() { - if client_etag == etag { - return HttpResponse::NotModified() - .insert_header(("Cache-Control", cc)) - .insert_header(("ETag", etag)) - .finish(); - } + // Try brotli first (best compression), then gzip, then uncompressed. + // Only serve compressed variant if client explicitly accepts it AND we have one. + let (data, encoding, etag, content_path) = + match frontend::get_frontend_asset_compressed(path_str) { + Some(r) => (r.0, r.1, r.2, path_str), + None => { + // Path not found — try index.html as SPA fallback. + // Also use "index.html" for Content-Type detection (text/html). + match frontend::get_frontend_asset_with_etag("index.html") { + Some((data, etag)) => (data, "", etag, "index.html"), + None => return HttpResponse::NotFound().finish(), } } + }; - let mime = MimeGuess::from_path(path_str).first_or_octet_stream(); - HttpResponse::Ok() - .content_type(mime.as_ref()) - .insert_header(("Cache-Control", cc)) - .insert_header(("ETag", etag)) - .body(data.to_vec()) - } - None => match frontend::get_frontend_asset_with_etag("index.html") { - Some((data, etag)) => HttpResponse::Ok() - .content_type("text/html") - .insert_header(("Cache-Control", "no-cache, no-store, must-revalidate")) - .insert_header(("ETag", etag)) - .body(data.to_vec()), - None => HttpResponse::NotFound().finish(), - }, + if !encoding.is_empty() && accepts_encoding(&req, &encoding) { + build_asset_response(&req, data, etag, content_path, cc, &encoding) + } else { + build_asset_response(&req, data, etag, content_path, cc, "") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_content_type_for_html() { + assert_eq!( + content_type_for_path("index.html"), + "text/html; charset=utf-8" + ); + } + + #[test] + fn test_content_type_for_hashed_js() { + assert_eq!( + content_type_for_path("index-De8T6ILu.js"), + "application/javascript; charset=utf-8" + ); + } + + #[test] + fn test_content_type_for_extensionless() { + let ct = content_type_for_path("auth/login"); + assert!(!ct.is_empty()); } } diff --git a/libs/frontend/Cargo.toml b/libs/frontend/Cargo.toml index 67fd7ec..12203c6 100644 --- a/libs/frontend/Cargo.toml +++ b/libs/frontend/Cargo.toml @@ -9,3 +9,5 @@ lazy_static.workspace = true [build-dependencies] walkdir.workspace = true md5.workspace = true +brotli = { workspace = true } +flate2 = { workspace = true } diff --git a/libs/frontend/build.rs b/libs/frontend/build.rs index 17d50c4..a212f53 100644 --- a/libs/frontend/build.rs +++ b/libs/frontend/build.rs @@ -1,5 +1,8 @@ -use md5::compute as md5_hash; -use std::{env, fs, path::PathBuf, process::Command}; +use std::env; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; fn run_pnpm(args: &[&str], cwd: &str) { let mut cmd = if cfg!(target_os = "windows") { @@ -21,7 +24,7 @@ fn run_pnpm(args: &[&str], cwd: &str) { } } -fn find_all_file(path: PathBuf) -> Vec { +fn find_all_files(path: PathBuf) -> Vec { let mut files = vec![]; for entry in fs::read_dir(path).unwrap() { let entry = entry.unwrap(); @@ -29,12 +32,40 @@ fn find_all_file(path: PathBuf) -> Vec { if path.is_file() { files.push(path); } else if path.is_dir() { - files.extend(find_all_file(path)); + files.extend(find_all_files(path)); } } files } +/// Returns true if the file type is already compressed and not worth re-compressing. +fn is_compressed_asset(path: &str) -> bool { + path.ends_with(".png") + || path.ends_with(".jpg") + || path.ends_with(".jpeg") + || path.ends_with(".gif") + || path.ends_with(".webp") + || path.ends_with(".avif") + || path.ends_with(".ico") + || path.ends_with(".woff") + || path.ends_with(".woff2") + || path.ends_with(".ttf") + || path.ends_with(".otf") + || path.ends_with(".eot") + || path.ends_with(".zip") + || path.ends_with(".gz") + || path.ends_with(".br") + || path.ends_with(".xz") + || path.ends_with(".mp4") + || path.ends_with(".mp3") + || path.ends_with(".webm") + || path.ends_with(".ogg") + || path.ends_with(".wasm") +} + +/// Minimum file size (bytes) before compression is applied. +const MIN_COMPRESS_SIZE: usize = 256; + fn main() { println!("cargo:rerun-if-changed=../../package.json"); println!("cargo:rerun-if-changed=../../pnpm-lock.yaml"); @@ -44,45 +75,97 @@ fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let project_root = manifest_dir.parent().unwrap().parent().unwrap(); - let node_modules = project_root.join("node_modules"); - let _cache_file = node_modules.join(".cache_hash"); - - // Build frontend using pnpm in project root println!("cargo:warning=Building frontend..."); run_pnpm(&["run", "build"], project_root.to_str().unwrap()); - // Embed dist/ into OUT_DIR as blob files + generated .rs let dist = project_root.join("dist"); let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let blob_dir = out_dir.join("dist_blobs"); fs::create_dir_all(&blob_dir).unwrap(); let mut strs = vec![]; - for file in find_all_file(dist.clone()) { - let key = file.strip_prefix(&dist).unwrap() + for file in find_all_files(dist.clone()) { + let key = file + .strip_prefix(&dist) + .unwrap() .components() .collect::() .to_string_lossy() .replace('\\', "/"); let safe_name = key.replace('/', "_").replace('\\', "_"); let blob_path = blob_dir.join(&safe_name); - fs::copy(&file, &blob_path).unwrap(); - - // Compute ETag (MD5 hex of content) at build time let content = fs::read(&file).unwrap(); - let hash = md5_hash(&content); - let etag_literal = format!("\"{:x}\"", hash); + fs::write(&blob_path, &content).unwrap(); + let hash = md5::compute(&content); + let etag_literal = format!("\"{:x}\"", hash); let key_literal = format!("\"{}\"", key.replace('"', "\\\"")); + + let (gz_literal, br_literal) = + if !is_compressed_asset(&key) && content.len() >= MIN_COMPRESS_SIZE { + let gz_path = blob_dir.join(format!("{}.gz", safe_name)); + let br_path = blob_dir.join(format!("{}.br", safe_name)); + + let gz_data = { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let _ = encoder.write_all(&content); + encoder.finish().unwrap() + }; + let br_data = { + let mut encoder = brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22); + let _ = encoder.write_all(&content); + encoder.flush().unwrap(); + encoder.into_inner() + }; + + if gz_data.len() < content.len() { + fs::write(&gz_path, &gz_data).unwrap(); + } + if br_data.len() < content.len() { + fs::write(&br_path, &br_data).unwrap(); + } + + let gz = if gz_data.len() < content.len() { + format!( + "Some((include_bytes!(\"dist_blobs/{}.gz\").to_vec(), \"gzip\"))", + safe_name + ) + } else { + "None".to_string() + }; + let br = if br_data.len() < content.len() { + format!( + "Some((include_bytes!(\"dist_blobs/{}.br\").to_vec(), \"br\"))", + safe_name + ) + } else { + "None".to_string() + }; + (gz, br) + } else { + ("None".to_string(), "None".to_string()) + }; + strs.push(format!( - " ({}, include_bytes!(\"dist_blobs/{}\"), {}),", - key_literal, safe_name, etag_literal + r#" ( + {}, + include_bytes!("dist_blobs/{}"), + {}, + {}, + {}, + ),"#, + key_literal, + safe_name, + etag_literal, + gz_literal, + br_literal )); } let out_file = out_dir.join("frontend.rs"); let generated = format!( - "lazy_static::lazy_static! {{\n pub static ref FRONTEND: Vec<(&'static str, &'static [u8], &'static str)> = vec![\n{} ];\n}}\n", + "lazy_static::lazy_static! {{\n pub static ref FRONTEND: Vec<(\n &'static str,\n &'static [u8],\n &'static str,\n Option<(Vec, &'static str)>,\n Option<(Vec, &'static str)>,\n )> = vec![\n{}\n ];\n}}\n", strs.join("\n") ); fs::write(&out_file, generated).unwrap(); diff --git a/libs/frontend/src/lib.rs b/libs/frontend/src/lib.rs index 3860661..0424338 100644 --- a/libs/frontend/src/lib.rs +++ b/libs/frontend/src/lib.rs @@ -4,12 +4,35 @@ include!(concat!(env!("OUT_DIR"), "/frontend.rs")); /// Returns the embedded frontend static asset for the given path, or `None` if not found. pub fn get_frontend_asset(path: &str) -> Option<&'static [u8]> { - FRONTEND.iter().find(|(k, _, _)| *k == path).map(|(_, v, _)| *v) + FRONTEND.iter().find(|(k, _, _, _, _)| *k == path).map(|(_, v, _, _, _)| *v) } /// Returns the embedded frontend static asset and its ETag for the given path. pub fn get_frontend_asset_with_etag(path: &str) -> Option<(&'static [u8], &'static str)> { - FRONTEND.iter() - .find(|(k, _, _)| *k == path) - .map(|(_, v, etag)| (v as &_, etag as &_)) + FRONTEND + .iter() + .find(|(k, _, _, _, _)| *k == path) + .map(|(_, v, etag, _, _)| (v as &[u8], *etag)) +} + +/// Returns the best compressed variant (brotli > gzip) and its ETag, if smaller than uncompressed. +/// Returns `(data, encoding, etag)` — `encoding` is "" when no compressed variant exists. +pub fn get_frontend_asset_compressed(path: &str) -> Option<(&'static [u8], &'static str, &'static str)> { + FRONTEND + .iter() + .find(|(k, _, _, _, _)| *k == path) + .map(|(_, v, etag, brotli, gzip)| { + // brotli preferred; fall back to gzip; fall back to uncompressed + if let Some((ref data, enc)) = *brotli { + if !enc.is_empty() { + return (data.as_slice(), enc, *etag); + } + } + if let Some((ref data, enc)) = *gzip { + if !enc.is_empty() { + return (data.as_slice(), enc, *etag); + } + } + (v as &[u8], "", *etag) + }) }