diff --git a/src/cmd/serve/handler.rs b/src/cmd/serve/handler.rs index 412ddbb..c8221c7 100644 --- a/src/cmd/serve/handler.rs +++ b/src/cmd/serve/handler.rs @@ -88,6 +88,12 @@ async fn handle_request( let proxy_client = state.proxy_client.clone(); let scss_transformer = state.scss_transformer.clone(); let typescript_transformer = state.typescript_transformer.clone(); + let encoding = compress::negotiate( + req + .headers() + .get("accept-encoding") + .and_then(|value| value.to_str().ok()), + ); // Basic Auth if !config.basic_auth.is_empty() { @@ -350,11 +356,12 @@ async fn handle_request( let content_type = format!("text/css; {}", DEFAULT_CHARSET_SUFFIX); - if config.compress { - res = res.header("Content-Encoding", "br"); + if config.compress && encoding == Some(compress::Encoding::Brotli) { return Ok( res .header("Content-Type", content_type) + .header("Content-Encoding", "br") + .header("Vary", "Accept-Encoding") .status(200) .body_from(compress::brotli(css.as_bytes()))?, ); @@ -393,11 +400,12 @@ async fn handle_request( let content_type = format!("application/javascript; {}", DEFAULT_CHARSET_SUFFIX); - if config.compress { - res = res.header("Content-Encoding", "br"); + if config.compress && encoding == Some(compress::Encoding::Brotli) { return Ok( res .header("Content-Type", content_type) + .header("Content-Encoding", "br") + .header("Vary", "Accept-Encoding") .status(200) .body_from(compress::brotli(code.as_bytes()))?, ); @@ -431,19 +439,18 @@ async fn handle_request( } // If a .br or .gz file is found next to the target, serve that file - if !config.compress { + if !config.compress + && let Some(encoding) = encoding + { let Some(file_path_str) = file_path.to_str() else { return Ok(res.status(404).body_from("File not found")?); }; - let brotli_path = PathBuf::from(format!("{}.br", file_path_str)); - let gzip_path = PathBuf::from(format!("{}.gz", file_path_str)); - - if brotli_path.exists() { - file_path = brotli_path; - res = res.header("Content-Encoding", "br"); - } else if gzip_path.exists() { - file_path = gzip_path; - res = res.header("Content-Encoding", "gzip"); + let path = PathBuf::from(format!("{}.{}", file_path_str, encoding.name())); + if path.exists() { + file_path = path; + res = res + .header("Content-Encoding", encoding.name()) + .header("Vary", "Accept-Encoding"); } } @@ -508,9 +515,14 @@ async fn handle_request( } } - if config.compress { - res = res.header("Content-Encoding", "br"); - Ok(res.status(200).body_from(compress::brotli(&contents))?) + if config.compress && encoding == Some(compress::Encoding::Brotli) { + Ok( + res + .header("Content-Encoding", "br") + .header("Vary", "Accept-Encoding") + .status(200) + .body_from(compress::brotli(&contents))?, + ) } else { Ok(res.status(200).body_from(contents)?) } diff --git a/src/platform/compress/brotli.rs b/src/platform/compress/brotli.rs new file mode 100644 index 0000000..a5b51f4 --- /dev/null +++ b/src/platform/compress/brotli.rs @@ -0,0 +1,9 @@ +use std::io::Write; + +use brotli as brotli_rs; + +pub fn brotli(input: &[u8]) -> Vec { + let mut writer = brotli_rs::CompressorWriter::new(Vec::new(), 4096, 11, 22); + writer.write_all(input).unwrap(); + writer.into_inner() +} diff --git a/src/platform/compress/encoding.rs b/src/platform/compress/encoding.rs new file mode 100644 index 0000000..1a8df83 --- /dev/null +++ b/src/platform/compress/encoding.rs @@ -0,0 +1,65 @@ +/// The content codings the server can produce, in preference order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Encoding { + Brotli, + Gzip, +} + +impl Encoding { + pub fn name(self) -> &'static str { + match self { + Encoding::Brotli => "br", + Encoding::Gzip => "gzip", + } + } +} + +/// Pick the most preferred supported coding from an `Accept-Encoding` header. +pub fn negotiate(accept_encoding: Option<&str>) -> Option { + let header = accept_encoding?; + + let mut best: Option<(Encoding, f32)> = None; + + for encoding in [Encoding::Brotli, Encoding::Gzip] { + let Some(quality) = quality(header, encoding.name()) else { + continue; + }; + + if quality <= 0.0 { + continue; + } + + match best { + Some((_, best_quality)) if best_quality >= quality => {} + _ => best = Some((encoding, quality)), + } + } + + best.map(|(encoding, _)| encoding) +} + +fn quality( + header: &str, + name: &str, +) -> Option { + let mut wildcard = None; + + for part in header.split(',') { + let mut params = part.split(';'); + let token = params.next().unwrap_or_default().trim(); + + let quality = params + .find_map(|param| param.trim().strip_prefix("q=")?.trim().parse::().ok()) + .unwrap_or(1.0); + + if token.eq_ignore_ascii_case(name) { + return Some(quality); + } + + if token == "*" { + wildcard = Some(quality); + } + } + + wildcard +} diff --git a/src/platform/compress/encoding_test.rs b/src/platform/compress/encoding_test.rs new file mode 100644 index 0000000..6a863d0 --- /dev/null +++ b/src/platform/compress/encoding_test.rs @@ -0,0 +1,35 @@ +use super::Encoding; +use super::negotiate; + +fn accepted(header: &str) -> Option<&'static str> { + negotiate(Some(header)).map(Encoding::name) +} + +#[test] +fn prefers_brotli_when_accepted() { + assert_eq!(accepted("gzip, deflate, br"), Some("br")); +} + +#[test] +fn honours_quality_values() { + assert_eq!(accepted("br;q=0.5, gzip"), Some("gzip")); +} + +#[test] +fn falls_back_to_gzip() { + assert_eq!(accepted("gzip"), Some("gzip")); +} + +#[test] +fn returns_none_when_nothing_supported() { + assert_eq!(accepted("deflate"), None); + assert_eq!(accepted("br;q=0"), None); + assert_eq!(accepted("identity"), None); + assert_eq!(negotiate(None), None); +} + +#[test] +fn honours_wildcard() { + assert_eq!(accepted("*"), Some("br")); + assert_eq!(accepted("br;q=0, *"), Some("gzip")); +} diff --git a/src/platform/compress/mod.rs b/src/platform/compress/mod.rs index a5b51f4..1349b68 100644 --- a/src/platform/compress/mod.rs +++ b/src/platform/compress/mod.rs @@ -1,9 +1,8 @@ -use std::io::Write; +mod brotli; +mod encoding; -use brotli as brotli_rs; +#[cfg(test)] +mod encoding_test; -pub fn brotli(input: &[u8]) -> Vec { - let mut writer = brotli_rs::CompressorWriter::new(Vec::new(), 4096, 11, 22); - writer.write_all(input).unwrap(); - writer.into_inner() -} +pub use self::brotli::*; +pub use self::encoding::*;