feat(api): pre-compress static assets with brotli and gzip

- build.rs: generate .gz and .br compressed variants at build time,
  embed both into the FRONTEND constant
- dist.rs: content-type for SPA fallback uses index.html path explicitly,
  explicit extension mapping for Vite content-hashed filenames
- frontend lib: get_frontend_asset_compressed returns (data, encoding, etag)
This commit is contained in:
ZhenYi 2026-04-25 20:09:09 +08:00
parent 73ba6329ea
commit 215846b1db
5 changed files with 552 additions and 126 deletions

373
Cargo.lock generated
View File

@ -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"

View File

@ -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<String>) -> 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<String>) -> 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());
}
}

View File

@ -9,3 +9,5 @@ lazy_static.workspace = true
[build-dependencies]
walkdir.workspace = true
md5.workspace = true
brotli = { workspace = true }
flate2 = { workspace = true }

View File

@ -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<PathBuf> {
fn find_all_files(path: PathBuf) -> Vec<PathBuf> {
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<PathBuf> {
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::<PathBuf>()
.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<u8>, &'static str)>,\n Option<(Vec<u8>, &'static str)>,\n )> = vec![\n{}\n ];\n}}\n",
strs.join("\n")
);
fs::write(&out_file, generated).unwrap();

View File

@ -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)
})
}