diff --git a/Cargo.lock b/Cargo.lock index 972c484..406b0a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -106,6 +121,19 @@ dependencies = [ "serde", ] +[[package]] +name = "async-compression" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -330,6 +358,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.12.0" @@ -407,6 +456,8 @@ version = "1.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -501,6 +552,26 @@ dependencies = [ "time", ] +[[package]] +name = "compression-codecs" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1642,6 +1713,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -3603,14 +3684,17 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ + "async-compression", "bitflags 2.9.4", "bytes", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", "iri-string", "pin-project-lite", "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -4563,3 +4647,31 @@ dependencies = [ "quote", "syn 2.0.106", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index c2a30cd..5103553 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ url = "2.5" governor = "0.10.1" serde_path_to_error = "0.1.17" num-format = "0.4.4" -tower-http = { version = "0.6.0", features = ["cors", "trace", "timeout"] } +tower-http = { version = "0.6.0", features = ["cors", "trace", "timeout", "compression-full"] } rust-embed = { version = "8.0", features = ["include-exclude"], optional = true } mime_guess = { version = "2.0", optional = true } clap = { version = "4.5", features = ["derive"] } diff --git a/Dockerfile b/Dockerfile index 8be76cc..3550631 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ FROM oven/bun:1 AS frontend-builder WORKDIR /app +# Install zstd for pre-compression +RUN apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/* + # Copy backend Cargo.toml for build-time version retrieval COPY ./Cargo.toml ./ @@ -19,8 +22,8 @@ RUN bun install --frozen-lockfile # Copy frontend source code COPY ./web ./ -# Build frontend -RUN bun run build +# Build frontend, then pre-compress static assets (gzip, brotli, zstd) +RUN bun run build && bun run scripts/compress-assets.ts # --- Chef Base Stage --- FROM lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} AS chef diff --git a/src/web/assets.rs b/src/web/assets.rs index d91190c..91b490b 100644 --- a/src/web/assets.rs +++ b/src/web/assets.rs @@ -1,14 +1,18 @@ -//! Embedded assets for the web frontend +//! Embedded assets for the web frontend. //! -//! This module handles serving static assets that are embedded into the binary -//! at compile time using rust-embed. +//! Serves static assets embedded into the binary at compile time using rust-embed. +//! Supports content negotiation for pre-compressed variants (.br, .gz, .zst) +//! generated at build time by `web/scripts/compress-assets.ts`. +use axum::http::{HeaderMap, HeaderValue, header}; use dashmap::DashMap; use rapidhash::v3::rapidhash_v3; use rust_embed::RustEmbed; use std::fmt; use std::sync::LazyLock; +use super::encoding::{COMPRESSION_MIN_SIZE, ContentEncoding, parse_accepted_encodings}; + /// Embedded web assets from the dist directory #[derive(RustEmbed)] #[folder = "web/dist/"] @@ -21,17 +25,15 @@ pub struct WebAssets; pub struct AssetHash(u64); impl AssetHash { - /// Create a new AssetHash from u64 value pub fn new(hash: u64) -> Self { Self(hash) } - /// Get the hash as a hex string pub fn to_hex(&self) -> String { format!("{:016x}", self.0) } - /// Get the hash as a quoted hex string + /// Get the hash as a quoted hex string (for ETag headers) pub fn quoted(&self) -> String { format!("\"{}\"", self.to_hex()) } @@ -51,12 +53,8 @@ pub struct AssetMetadata { } impl AssetMetadata { - /// Check if the etag matches the asset hash pub fn etag_matches(&self, etag: &str) -> bool { - // Remove quotes if present (ETags are typically quoted) let etag = etag.trim_matches('"'); - - // ETags generated from u64 hex should be 16 characters etag.len() == 16 && u64::from_str_radix(etag, 16) .map(|parsed| parsed == self.hash.0) @@ -68,28 +66,125 @@ impl AssetMetadata { static ASSET_CACHE: LazyLock> = LazyLock::new(DashMap::new); /// Get cached asset metadata for a file path, caching on-demand -/// Returns AssetMetadata containing MIME type and RapidHash hash pub fn get_asset_metadata_cached(path: &str, content: &[u8]) -> AssetMetadata { - // Check cache first if let Some(cached) = ASSET_CACHE.get(path) { return cached.value().clone(); } - // Calculate MIME type let mime_type = mime_guess::from_path(path) .first() .map(|mime| mime.to_string()); - - // Calculate RapidHash hash (using u64 native output size) - let hash_value = rapidhash_v3(content); - let hash = AssetHash::new(hash_value); - + let hash = AssetHash::new(rapidhash_v3(content)); let metadata = AssetMetadata { mime_type, hash }; - // Only cache if we haven't exceeded the limit if ASSET_CACHE.len() < 1000 { ASSET_CACHE.insert(path.to_string(), metadata.clone()); } metadata } + +/// Set appropriate `Cache-Control` header based on the asset path. +/// +/// SvelteKit outputs fingerprinted assets under `_app/immutable/` which are +/// safe to cache indefinitely. Other assets get shorter cache durations. +fn set_cache_control(headers: &mut HeaderMap, path: &str) { + let cache_control = if path.contains("immutable/") { + // SvelteKit fingerprinted assets — cache forever + "public, max-age=31536000, immutable" + } else if path == "index.html" || path.ends_with(".html") { + "public, max-age=300" + } else { + match path.rsplit_once('.').map(|(_, ext)| ext) { + Some("css" | "js") => "public, max-age=86400", + Some("png" | "jpg" | "jpeg" | "gif" | "svg" | "ico") => "public, max-age=2592000", + _ => "public, max-age=3600", + } + }; + + if let Ok(value) = HeaderValue::from_str(cache_control) { + headers.insert(header::CACHE_CONTROL, value); + } +} + +/// Serve an embedded asset with content encoding negotiation. +/// +/// Tries pre-compressed variants (.br, .gz, .zst) in the order preferred by +/// the client's `Accept-Encoding` header, falling back to the uncompressed +/// original. Returns `None` if the asset doesn't exist at all. +pub fn try_serve_asset_with_encoding( + path: &str, + request_headers: &HeaderMap, +) -> Option { + use axum::response::IntoResponse; + + let asset_path = path.strip_prefix('/').unwrap_or(path); + + // Get the uncompressed original first (for metadata: MIME type, ETag) + let original = WebAssets::get(asset_path)?; + let metadata = get_asset_metadata_cached(asset_path, &original.data); + + // Check ETag for conditional requests (304 Not Modified) + if let Some(etag) = request_headers.get(header::IF_NONE_MATCH) + && etag.to_str().is_ok_and(|s| metadata.etag_matches(s)) + { + return Some(axum::http::StatusCode::NOT_MODIFIED.into_response()); + } + + let mime_type = metadata + .mime_type + .unwrap_or_else(|| "application/octet-stream".to_string()); + + // Only attempt pre-compressed variants for files above the compression + // threshold — the build script skips smaller files too. + let accepted_encodings = if original.data.len() >= COMPRESSION_MIN_SIZE { + parse_accepted_encodings(request_headers) + } else { + vec![ContentEncoding::Identity] + }; + + for encoding in &accepted_encodings { + if *encoding == ContentEncoding::Identity { + continue; + } + + let compressed_path = format!("{}{}", asset_path, encoding.extension()); + if let Some(compressed) = WebAssets::get(&compressed_path) { + let mut response_headers = HeaderMap::new(); + + if let Ok(ct) = HeaderValue::from_str(&mime_type) { + response_headers.insert(header::CONTENT_TYPE, ct); + } + if let Some(ce) = encoding.header_value() { + response_headers.insert(header::CONTENT_ENCODING, ce); + } + if let Ok(etag_val) = HeaderValue::from_str(&metadata.hash.quoted()) { + response_headers.insert(header::ETAG, etag_val); + } + // Vary so caches distinguish by encoding + response_headers.insert(header::VARY, HeaderValue::from_static("Accept-Encoding")); + set_cache_control(&mut response_headers, asset_path); + + return Some( + ( + axum::http::StatusCode::OK, + response_headers, + compressed.data, + ) + .into_response(), + ); + } + } + + // No compressed variant found — serve uncompressed original + let mut response_headers = HeaderMap::new(); + if let Ok(ct) = HeaderValue::from_str(&mime_type) { + response_headers.insert(header::CONTENT_TYPE, ct); + } + if let Ok(etag_val) = HeaderValue::from_str(&metadata.hash.quoted()) { + response_headers.insert(header::ETAG, etag_val); + } + set_cache_control(&mut response_headers, asset_path); + + Some((axum::http::StatusCode::OK, response_headers, original.data).into_response()) +} diff --git a/src/web/encoding.rs b/src/web/encoding.rs new file mode 100644 index 0000000..326de04 --- /dev/null +++ b/src/web/encoding.rs @@ -0,0 +1,196 @@ +//! Content encoding negotiation for pre-compressed asset serving. +//! +//! Parses Accept-Encoding headers with quality values and returns +//! supported encodings in priority order for content negotiation. + +use axum::http::{HeaderMap, HeaderValue, header}; + +/// Minimum size threshold for compression (bytes). +/// +/// Must match `MIN_SIZE` in `web/scripts/compress-assets.ts`. +pub const COMPRESSION_MIN_SIZE: usize = 512; + +/// Supported content encodings in priority order (best compression first). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ContentEncoding { + Zstd, + Brotli, + Gzip, + Identity, +} + +impl ContentEncoding { + /// File extension suffix for pre-compressed variant lookup. + #[inline] + pub fn extension(&self) -> &'static str { + match self { + Self::Zstd => ".zst", + Self::Brotli => ".br", + Self::Gzip => ".gz", + Self::Identity => "", + } + } + + /// `Content-Encoding` header value, or `None` for identity. + #[inline] + pub fn header_value(&self) -> Option { + match self { + Self::Zstd => Some(HeaderValue::from_static("zstd")), + Self::Brotli => Some(HeaderValue::from_static("br")), + Self::Gzip => Some(HeaderValue::from_static("gzip")), + Self::Identity => None, + } + } + + /// Default priority when quality values are equal (higher = better). + #[inline] + fn default_priority(&self) -> u8 { + match self { + Self::Zstd => 4, + Self::Brotli => 3, + Self::Gzip => 2, + Self::Identity => 1, + } + } +} + +/// Parse `Accept-Encoding` header and return supported encodings in priority order. +/// +/// Supports quality values: `Accept-Encoding: gzip;q=0.8, br;q=1.0, zstd` +/// When quality values are equal: zstd > brotli > gzip > identity. +/// Encodings with `q=0` are excluded. +pub fn parse_accepted_encodings(headers: &HeaderMap) -> Vec { + let Some(accept) = headers + .get(header::ACCEPT_ENCODING) + .and_then(|v| v.to_str().ok()) + else { + return vec![ContentEncoding::Identity]; + }; + + let mut encodings: Vec<(ContentEncoding, f32)> = Vec::new(); + + for part in accept.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + let (encoding_str, quality) = if let Some((enc, params)) = part.split_once(';') { + let q = params + .split(';') + .find_map(|p| p.trim().strip_prefix("q=")) + .and_then(|q| q.parse::().ok()) + .unwrap_or(1.0); + (enc.trim(), q) + } else { + (part, 1.0) + }; + + if quality == 0.0 { + continue; + } + + let encoding = match encoding_str.to_lowercase().as_str() { + "zstd" => ContentEncoding::Zstd, + "br" | "brotli" => ContentEncoding::Brotli, + "gzip" | "x-gzip" => ContentEncoding::Gzip, + "*" => ContentEncoding::Gzip, + "identity" => ContentEncoding::Identity, + _ => continue, + }; + + encodings.push((encoding, quality)); + } + + // Sort by quality (desc), then default priority (desc) + encodings.sort_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.0.default_priority().cmp(&a.0.default_priority())) + }); + + if encodings.is_empty() { + vec![ContentEncoding::Identity] + } else { + encodings.into_iter().map(|(e, _)| e).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_all_encodings() { + let mut headers = HeaderMap::new(); + headers.insert(header::ACCEPT_ENCODING, "gzip, br, zstd".parse().unwrap()); + let encodings = parse_accepted_encodings(&headers); + assert_eq!(encodings[0], ContentEncoding::Zstd); + assert_eq!(encodings[1], ContentEncoding::Brotli); + assert_eq!(encodings[2], ContentEncoding::Gzip); + } + + #[test] + fn test_parse_with_quality_values() { + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCEPT_ENCODING, + "gzip;q=1.0, br;q=0.5, zstd;q=0.8".parse().unwrap(), + ); + let encodings = parse_accepted_encodings(&headers); + assert_eq!(encodings[0], ContentEncoding::Gzip); + assert_eq!(encodings[1], ContentEncoding::Zstd); + assert_eq!(encodings[2], ContentEncoding::Brotli); + } + + #[test] + fn test_no_header_returns_identity() { + let headers = HeaderMap::new(); + let encodings = parse_accepted_encodings(&headers); + assert_eq!(encodings, vec![ContentEncoding::Identity]); + } + + #[test] + fn test_disabled_encoding_excluded() { + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCEPT_ENCODING, + "zstd;q=0, br, gzip".parse().unwrap(), + ); + let encodings = parse_accepted_encodings(&headers); + assert_eq!(encodings[0], ContentEncoding::Brotli); + assert_eq!(encodings[1], ContentEncoding::Gzip); + assert!(!encodings.contains(&ContentEncoding::Zstd)); + } + + #[test] + fn test_real_chrome_header() { + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCEPT_ENCODING, + "gzip, deflate, br, zstd".parse().unwrap(), + ); + assert_eq!(parse_accepted_encodings(&headers)[0], ContentEncoding::Zstd); + } + + #[test] + fn test_extensions() { + assert_eq!(ContentEncoding::Zstd.extension(), ".zst"); + assert_eq!(ContentEncoding::Brotli.extension(), ".br"); + assert_eq!(ContentEncoding::Gzip.extension(), ".gz"); + assert_eq!(ContentEncoding::Identity.extension(), ""); + } + + #[test] + fn test_header_values() { + assert_eq!( + ContentEncoding::Zstd.header_value().unwrap(), + HeaderValue::from_static("zstd") + ); + assert_eq!( + ContentEncoding::Brotli.header_value().unwrap(), + HeaderValue::from_static("br") + ); + assert!(ContentEncoding::Identity.header_value().is_none()); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 8e08f35..5b3c675 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -4,6 +4,8 @@ pub mod admin; #[cfg(feature = "embed-assets")] pub mod assets; pub mod auth; +#[cfg(feature = "embed-assets")] +pub mod encoding; pub mod extractors; pub mod routes; pub mod session_cache; diff --git a/src/web/routes.rs b/src/web/routes.rs index 34c56f4..e385152 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -13,11 +13,9 @@ use crate::web::admin; use crate::web::auth::{self, AuthConfig}; #[cfg(feature = "embed-assets")] use axum::{ - http::{HeaderMap, HeaderValue, StatusCode, Uri}, - response::{Html, IntoResponse}, + http::{HeaderMap, StatusCode, Uri}, + response::IntoResponse, }; -#[cfg(feature = "embed-assets")] -use http::header; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::{collections::BTreeMap, time::Duration}; @@ -27,48 +25,14 @@ use crate::state::AppState; use crate::status::ServiceStatus; #[cfg(not(feature = "embed-assets"))] use tower_http::cors::{Any, CorsLayer}; -use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer}; +use tower_http::{ + classify::ServerErrorsFailureClass, compression::CompressionLayer, timeout::TimeoutLayer, + trace::TraceLayer, +}; use tracing::{Span, debug, trace, warn}; #[cfg(feature = "embed-assets")] -use crate::web::assets::{WebAssets, get_asset_metadata_cached}; - -/// Set appropriate caching headers based on asset type -#[cfg(feature = "embed-assets")] -fn set_caching_headers(response: &mut Response, path: &str, etag: &str) { - let headers = response.headers_mut(); - - // Set ETag - if let Ok(etag_value) = HeaderValue::from_str(etag) { - headers.insert(header::ETAG, etag_value); - } - - // Set Cache-Control based on asset type - let cache_control = if path.starts_with("assets/") { - // Static assets with hashed filenames - long-term cache - "public, max-age=31536000, immutable" - } else if path == "index.html" { - // HTML files - short-term cache - "public, max-age=300" - } else { - match path.split_once('.').map(|(_, extension)| extension) { - Some(ext) => match ext { - // CSS/JS files - medium-term cache - "css" | "js" => "public, max-age=86400", - // Images - long-term cache - "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" => "public, max-age=2592000", - // Default for other files - _ => "public, max-age=3600", - }, - // Default for files without an extension - None => "public, max-age=3600", - } - }; - - if let Ok(cache_control_value) = HeaderValue::from_str(cache_control) { - headers.insert(header::CACHE_CONTROL, cache_control_value); - } -} +use crate::web::assets::try_serve_asset_with_encoding; /// Creates the web server router pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router { @@ -125,6 +89,13 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router { } router.layer(( + // Compress API responses (gzip/brotli/zstd). Pre-compressed static + // assets already have Content-Encoding set, so tower-http skips them. + CompressionLayer::new() + .zstd(true) + .br(true) + .gzip(true) + .quality(tower_http::CompressionLevel::Fastest), TraceLayer::new_for_http() .make_span_with(|request: &Request| { tracing::debug_span!("request", path = request.uri().path()) @@ -171,71 +142,35 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router { )) } -/// Handler that extracts request information for caching +/// SPA fallback handler with content encoding negotiation. +/// +/// Serves embedded static assets with pre-compressed variants when available, +/// falling back to `index.html` for SPA client-side routing. #[cfg(feature = "embed-assets")] -async fn fallback(request: Request) -> Response { +async fn fallback(request: Request) -> axum::response::Response { let uri = request.uri().clone(); let headers = request.headers().clone(); - handle_spa_fallback_with_headers(uri, headers).await + handle_spa_fallback(uri, headers).await } -/// Handles SPA routing by serving index.html for non-API, non-asset requests -/// This version includes HTTP caching headers and ETag support #[cfg(feature = "embed-assets")] -async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap) -> Response { - let path = uri.path().trim_start_matches('/'); - - if let Some(content) = WebAssets::get(path) { - // Get asset metadata (MIME type and hash) with caching - let metadata = get_asset_metadata_cached(path, &content.data); - - // Check if client has a matching ETag (conditional request) - if let Some(etag) = request_headers.get(header::IF_NONE_MATCH) - && etag.to_str().is_ok_and(|s| metadata.etag_matches(s)) - { - return StatusCode::NOT_MODIFIED.into_response(); - } - - // Use cached MIME type, only set Content-Type if we have a valid MIME type - let mut response = ( - [( - header::CONTENT_TYPE, - // For unknown types, set to application/octet-stream - metadata - .mime_type - .unwrap_or("application/octet-stream".to_string()), - )], - content.data, - ) - .into_response(); - - // Set caching headers - set_caching_headers(&mut response, path, &metadata.hash.quoted()); +async fn handle_spa_fallback(uri: Uri, request_headers: HeaderMap) -> axum::response::Response { + let path = uri.path(); + // Try serving the exact asset (with encoding negotiation) + if let Some(response) = try_serve_asset_with_encoding(path, &request_headers) { return response; - } else { - // Any assets that are not found should be treated as a 404, not falling back to the SPA index.html - if path.starts_with("assets/") { - return (StatusCode::NOT_FOUND, "Asset not found").into_response(); - } } - // Fall back to the SPA index.html - match WebAssets::get("index.html") { - Some(content) => { - let metadata = get_asset_metadata_cached("index.html", &content.data); + // SvelteKit assets under _app/ that don't exist are a hard 404 + let trimmed = path.trim_start_matches('/'); + if trimmed.starts_with("_app/") || trimmed.starts_with("assets/") { + return (StatusCode::NOT_FOUND, "Asset not found").into_response(); + } - // Check if client has a matching ETag for index.html - if let Some(etag) = request_headers.get(header::IF_NONE_MATCH) - && etag.to_str().is_ok_and(|s| metadata.etag_matches(s)) - { - return StatusCode::NOT_MODIFIED.into_response(); - } - - let mut response = Html(content.data).into_response(); - set_caching_headers(&mut response, "index.html", &metadata.hash.quoted()); - response - } + // SPA fallback: serve index.html with encoding negotiation + match try_serve_asset_with_encoding("/index.html", &request_headers) { + Some(response) => response, None => ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to load index.html", diff --git a/web/scripts/compress-assets.ts b/web/scripts/compress-assets.ts new file mode 100644 index 0000000..04041d8 --- /dev/null +++ b/web/scripts/compress-assets.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env bun +/** + * Pre-compress static assets with maximum compression levels. + * Run after `bun run build`. + * + * Generates .gz, .br, .zst variants for compressible files ≥ MIN_SIZE bytes. + * These are embedded alongside originals by rust-embed and served via + * content negotiation in src/web/assets.rs. + */ +import { readdir, stat, readFile, writeFile } from "fs/promises"; +import { join, extname } from "path"; +import { gzipSync, brotliCompressSync, constants } from "zlib"; +import { $ } from "bun"; + +// Must match COMPRESSION_MIN_SIZE in src/web/encoding.rs +const MIN_SIZE = 512; + +const COMPRESSIBLE_EXTENSIONS = new Set([ + ".js", + ".css", + ".html", + ".json", + ".svg", + ".txt", + ".xml", + ".map", +]); + +// Check if zstd CLI is available +let hasZstd = false; +try { + await $`which zstd`.quiet(); + hasZstd = true; +} catch { + console.warn("Warning: zstd not found, skipping .zst generation"); +} + +async function* walkDir(dir: string): AsyncGenerator { + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + yield* walkDir(path); + } else if (entry.isFile()) { + yield path; + } + } + } catch { + // Directory doesn't exist, skip + } +} + +async function compressFile(path: string): Promise { + const ext = extname(path); + + if (!COMPRESSIBLE_EXTENSIONS.has(ext)) return; + if (path.endsWith(".br") || path.endsWith(".gz") || path.endsWith(".zst")) return; + + const stats = await stat(path); + if (stats.size < MIN_SIZE) return; + + // Skip if all compressed variants already exist + const variantsExist = await Promise.all([ + stat(`${path}.br`).then( + () => true, + () => false + ), + stat(`${path}.gz`).then( + () => true, + () => false + ), + hasZstd + ? stat(`${path}.zst`).then( + () => true, + () => false + ) + : Promise.resolve(false), + ]); + + if (variantsExist.every((exists) => exists || !hasZstd)) { + return; + } + + const content = await readFile(path); + const originalSize = content.length; + + // Brotli (maximum quality = 11) + const brContent = brotliCompressSync(content, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + await writeFile(`${path}.br`, brContent); + + // Gzip (level 9) + const gzContent = gzipSync(content, { level: 9 }); + await writeFile(`${path}.gz`, gzContent); + + // Zstd (level 19 - maximum) + if (hasZstd) { + try { + await $`zstd -19 -q -f -o ${path}.zst ${path}`.quiet(); + } catch (e) { + console.warn(`Warning: Failed to compress ${path} with zstd: ${e}`); + } + } + + const brRatio = ((brContent.length / originalSize) * 100).toFixed(1); + const gzRatio = ((gzContent.length / originalSize) * 100).toFixed(1); + console.log(`Compressed: ${path} (br: ${brRatio}%, gz: ${gzRatio}%, ${originalSize} bytes)`); +} + +async function main() { + console.log("Pre-compressing static assets..."); + + // Banner uses adapter-static with output in dist/ + const dirs = ["dist"]; + let scannedFiles = 0; + let compressedFiles = 0; + + for (const dir of dirs) { + for await (const file of walkDir(dir)) { + const ext = extname(file); + scannedFiles++; + + if ( + COMPRESSIBLE_EXTENSIONS.has(ext) && + !file.endsWith(".br") && + !file.endsWith(".gz") && + !file.endsWith(".zst") + ) { + const stats = await stat(file); + if (stats.size >= MIN_SIZE) { + await compressFile(file); + compressedFiles++; + } + } + } + } + + console.log(`Done! Scanned ${scannedFiles} files, compressed ${compressedFiles} files.`); +} + +main().catch((e) => { + console.error("Compression failed:", e); + process.exit(1); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1962ebd..a015dc8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -144,7 +144,7 @@ export class BannerApiClient { return this.request("/admin/users"); } - async setUserAdmin(discordId: string, isAdmin: boolean): Promise { + async setUserAdmin(discordId: bigint, isAdmin: boolean): Promise { const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, { method: "PUT", headers: { "Content-Type": "application/json" }, diff --git a/web/src/lib/components/SearchStatus.svelte b/web/src/lib/components/SearchStatus.svelte index 9bbc8af..ff5f0ef 100644 --- a/web/src/lib/components/SearchStatus.svelte +++ b/web/src/lib/components/SearchStatus.svelte @@ -1,25 +1,25 @@ {#if meta} diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index 81c8a96..6114ab7 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -43,7 +43,7 @@ const navItems = [

Admin

{#if authStore.user} -

{authStore.user.username}

+

{authStore.user.discordUsername}

{/if}