diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 4baff7c7..f2ac1662 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -9,7 +9,7 @@ use axum::{ }; use serde_json::json; use std::sync::Arc; -use tower_http::{compression::CompressionLayer, trace::TraceLayer}; +use tower_http::trace::TraceLayer; use utoipa::OpenApi; /// Liveness probe — returns 200 if the process is running, no DB check. @@ -579,10 +579,10 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .with_state(app_state.clone()); router = router.nest("/users", users_router); - // Transparent compression (gzip + brotli) for all API responses. - // tower-http negotiates via Accept-Encoding and skips already-compressed - // content types automatically. No manual compression in handlers. - router - .layer(CompressionLayer::new().br(true).gzip(true)) - .layer(TraceLayer::new_for_http()) + // Compression is applied once, globally, in `main.rs` with a content-type + // aware predicate that skips already-compressed media. Re-applying it here + // would double-wrap `/api`: this inner layer (no predicate) would compress + // media downloads, burning CPU for ~0 gain and stripping `Content-Length`. + // So this router only adds tracing; compression is the global layer's job. + router.layer(TraceLayer::new_for_http()) } diff --git a/src/main.rs b/src/main.rs index 192b4146..eb558f7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -541,25 +541,70 @@ async fn main() -> Result<(), Box> { app = app.layer(DefaultBodyLimit::max(BODY_LIMIT)); // ── HTTP compression (gzip + Brotli) ───────────────────────────────── - // Negotiates the best encoding via Accept-Encoding. Skips responses - // that are already compressed or wouldn't benefit (images, video, etc.). - // Compatible with a future reverse proxy — if the proxy sees - // `Content-Encoding` it will pass the response through untouched. + // Negotiates the best encoding via Accept-Encoding. Policy: compress + // everything by default so no shrinkable response is ever missed (text, + // JSON, JS/CSS, XML, SVG, fonts ttf/otf, WASM…), and skip ONLY content + // that is already compressed — where a second pass burns CPU and adds + // latency for ~0 bytes saved. + // + // We deliberately do NOT blanket-exclude `image/*`: `image/svg+xml` is + // plain text and compresses ~70%, so the genuinely-compressed raster + // formats are listed individually instead, leaving SVG compressible. + // + // This is the single, global compression layer (the `/api` router used to + // add its own predicate-less one, which silently compressed media). It is + // reverse-proxy friendly: a proxy that sees `Content-Encoding` passes + // the response through untouched. { use tower_http::compression::CompressionLayer; use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove}; let predicate = SizeAbove::new(256) .and(NotForContentType::GRPC) - .and(NotForContentType::IMAGES) .and(NotForContentType::SSE) - .and(NotForContentType::const_new("application/octet-stream")) + // ── already-compressed raster images (SVG intentionally absent) ── + .and(NotForContentType::const_new("image/jpeg")) + .and(NotForContentType::const_new("image/png")) + .and(NotForContentType::const_new("image/gif")) + .and(NotForContentType::const_new("image/webp")) + .and(NotForContentType::const_new("image/avif")) + .and(NotForContentType::const_new("image/heic")) + .and(NotForContentType::const_new("image/heif")) + .and(NotForContentType::const_new("image/jp2")) + .and(NotForContentType::const_new("image/x-icon")) + .and(NotForContentType::const_new("image/vnd.microsoft.icon")) + // ── audio / video families (already compressed) ── + .and(NotForContentType::const_new("video/")) + .and(NotForContentType::const_new("audio/")) + // ── already-compressed web fonts; ttf/otf left compressible ── + .and(NotForContentType::const_new("font/woff")) + .and(NotForContentType::const_new("application/font-woff")) + // ── archives & compressed containers ── .and(NotForContentType::const_new("application/zip")) .and(NotForContentType::const_new("application/gzip")) + .and(NotForContentType::const_new("application/x-gzip")) .and(NotForContentType::const_new("application/x-tar")) + .and(NotForContentType::const_new("application/x-7z-compressed")) + .and(NotForContentType::const_new("application/x-rar-compressed")) + .and(NotForContentType::const_new("application/x-bzip2")) + .and(NotForContentType::const_new("application/zstd")) + .and(NotForContentType::const_new("application/x-xz")) + // ── zip-based document / app bundles (docx/xlsx/pptx, odf, epub…) ── + .and(NotForContentType::const_new( + "application/vnd.openxmlformats-officedocument", + )) + .and(NotForContentType::const_new( + "application/vnd.oasis.opendocument", + )) + .and(NotForContentType::const_new("application/epub+zip")) + .and(NotForContentType::const_new("application/java-archive")) + .and(NotForContentType::const_new( + "application/vnd.android.package-archive", + )) + // ── PDF: streams are usually already deflated; often large ── .and(NotForContentType::const_new("application/pdf")) - .and(NotForContentType::const_new("video/")) - .and(NotForContentType::const_new("audio/")); + // ── opaque binary we couldn't identify ── + .and(NotForContentType::const_new("application/octet-stream")); app = app.layer(CompressionLayer::new().compress_when(predicate)); }