feat: optimize asset delivery with build-time compression and encoding negotiation

This commit is contained in:
2026-01-29 13:56:10 -06:00
parent e008ee5a12
commit 4deeef2f00
12 changed files with 634 additions and 143 deletions
+114 -19
View File
@@ -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<DashMap<String, AssetMetadata>> = 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<axum::response::Response> {
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())
}
+196
View File
@@ -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<HeaderValue> {
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<ContentEncoding> {
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::<f32>().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());
}
}
+2
View File
@@ -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;
+32 -97
View File
@@ -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<Body>| {
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",