From cf6c303c13786612a52fc74c2963fb58ef9a19b0 Mon Sep 17 00:00:00 2001 From: David Alsh Date: Mon, 19 Oct 2026 08:42:13 +0000 Subject: [PATCH 1/6] fix(serve): negotiate Content-Encoding from Accept-Encoding Only apply brotli compression, or serve a pre-compressed sibling, when the client accepts that coding, and send Vary: Accept-Encoding so caches do not reuse the wrong variant. Fixes #22. --- src/cmd/serve/handler.rs | 46 ++++++++++------ src/platform/compress/mod.rs | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 17 deletions(-) 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/mod.rs b/src/platform/compress/mod.rs index a5b51f4..b1c0dde 100644 --- a/src/platform/compress/mod.rs +++ b/src/platform/compress/mod.rs @@ -7,3 +7,103 @@ pub fn brotli(input: &[u8]) -> Vec { writer.write_all(input).unwrap(); writer.into_inner() } + +/// 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?; + + [Encoding::Brotli, Encoding::Gzip] + .into_iter() + .filter_map(|encoding| { + let quality = quality(header, encoding.name())?; + (quality > 0.0).then_some((encoding, quality)) + }) + .fold( + None, + |best: Option<(Encoding, f32)>, candidate| match best { + Some((_, quality)) if quality >= candidate.1 => best, + _ => Some(candidate), + }, + ) + .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 +} + +#[cfg(test)] +mod tests { + use super::*; + + 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")); + } +} From eb5d490e510ef5865958788a6a992615c404bf6d Mon Sep 17 00:00:00 2001 From: David Alsh Date: Mon, 19 Oct 2026 12:16:21 +0000 Subject: [PATCH 2/6] refactor(compress): keep mod.rs to re-exports, move code and tests out --- src/platform/compress/compress.rs | 71 +++++++++++++++++++ src/platform/compress/mod.rs | 109 +----------------------------- src/platform/compress/mod_test.rs | 35 ++++++++++ 3 files changed, 109 insertions(+), 106 deletions(-) create mode 100644 src/platform/compress/compress.rs create mode 100644 src/platform/compress/mod_test.rs diff --git a/src/platform/compress/compress.rs b/src/platform/compress/compress.rs new file mode 100644 index 0000000..bd49597 --- /dev/null +++ b/src/platform/compress/compress.rs @@ -0,0 +1,71 @@ +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() +} + +/// 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?; + + [Encoding::Brotli, Encoding::Gzip] + .into_iter() + .filter_map(|encoding| { + let quality = quality(header, encoding.name())?; + (quality > 0.0).then_some((encoding, quality)) + }) + .fold( + None, + |best: Option<(Encoding, f32)>, candidate| match best { + Some((_, quality)) if quality >= candidate.1 => best, + _ => Some(candidate), + }, + ) + .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/mod.rs b/src/platform/compress/mod.rs index b1c0dde..071c3da 100644 --- a/src/platform/compress/mod.rs +++ b/src/platform/compress/mod.rs @@ -1,109 +1,6 @@ -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() -} - -/// 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?; - - [Encoding::Brotli, Encoding::Gzip] - .into_iter() - .filter_map(|encoding| { - let quality = quality(header, encoding.name())?; - (quality > 0.0).then_some((encoding, quality)) - }) - .fold( - None, - |best: Option<(Encoding, f32)>, candidate| match best { - Some((_, quality)) if quality >= candidate.1 => best, - _ => Some(candidate), - }, - ) - .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 -} +mod compress; #[cfg(test)] -mod tests { - use super::*; - - 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); - } +mod mod_test; - #[test] - fn honours_wildcard() { - assert_eq!(accepted("*"), Some("br")); - assert_eq!(accepted("br;q=0, *"), Some("gzip")); - } -} +pub use self::compress::*; diff --git a/src/platform/compress/mod_test.rs b/src/platform/compress/mod_test.rs new file mode 100644 index 0000000..6a863d0 --- /dev/null +++ b/src/platform/compress/mod_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")); +} From ad93ff21f50db743bcfd1beeb04179aa190a610a Mon Sep 17 00:00:00 2001 From: David Alsh Date: Tue, 20 Oct 2026 12:46:18 +0000 Subject: [PATCH 3/6] refactor(compress): split content negotiation into its own module --- src/platform/compress/compress.rs | 62 ------------------------------- src/platform/compress/encoding.rs | 61 ++++++++++++++++++++++++++++++ src/platform/compress/mod.rs | 2 + 3 files changed, 63 insertions(+), 62 deletions(-) create mode 100644 src/platform/compress/encoding.rs diff --git a/src/platform/compress/compress.rs b/src/platform/compress/compress.rs index bd49597..a5b51f4 100644 --- a/src/platform/compress/compress.rs +++ b/src/platform/compress/compress.rs @@ -7,65 +7,3 @@ pub fn brotli(input: &[u8]) -> Vec { writer.write_all(input).unwrap(); writer.into_inner() } - -/// 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?; - - [Encoding::Brotli, Encoding::Gzip] - .into_iter() - .filter_map(|encoding| { - let quality = quality(header, encoding.name())?; - (quality > 0.0).then_some((encoding, quality)) - }) - .fold( - None, - |best: Option<(Encoding, f32)>, candidate| match best { - Some((_, quality)) if quality >= candidate.1 => best, - _ => Some(candidate), - }, - ) - .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.rs b/src/platform/compress/encoding.rs new file mode 100644 index 0000000..af3ad0e --- /dev/null +++ b/src/platform/compress/encoding.rs @@ -0,0 +1,61 @@ +/// 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?; + + [Encoding::Brotli, Encoding::Gzip] + .into_iter() + .filter_map(|encoding| { + let quality = quality(header, encoding.name())?; + (quality > 0.0).then_some((encoding, quality)) + }) + .fold( + None, + |best: Option<(Encoding, f32)>, candidate| match best { + Some((_, quality)) if quality >= candidate.1 => best, + _ => Some(candidate), + }, + ) + .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/mod.rs b/src/platform/compress/mod.rs index 071c3da..3222e4f 100644 --- a/src/platform/compress/mod.rs +++ b/src/platform/compress/mod.rs @@ -1,6 +1,8 @@ mod compress; +mod encoding; #[cfg(test)] mod mod_test; pub use self::compress::*; +pub use self::encoding::*; From ab45bf8e12eccb58354fd7b9c8aa0aab39cb96b2 Mon Sep 17 00:00:00 2001 From: David Alsh Date: Tue, 20 Oct 2026 12:53:28 +0000 Subject: [PATCH 4/6] refactor(compress): rename mod_test.rs to encoding_test.rs --- src/platform/compress/{mod_test.rs => encoding_test.rs} | 0 src/platform/compress/mod.rs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/platform/compress/{mod_test.rs => encoding_test.rs} (100%) diff --git a/src/platform/compress/mod_test.rs b/src/platform/compress/encoding_test.rs similarity index 100% rename from src/platform/compress/mod_test.rs rename to src/platform/compress/encoding_test.rs diff --git a/src/platform/compress/mod.rs b/src/platform/compress/mod.rs index 3222e4f..1ebd3e0 100644 --- a/src/platform/compress/mod.rs +++ b/src/platform/compress/mod.rs @@ -2,7 +2,7 @@ mod compress; mod encoding; #[cfg(test)] -mod mod_test; +mod encoding_test; pub use self::compress::*; pub use self::encoding::*; From b25e5a4680f7e732151446f5a986d3ba73faf19a Mon Sep 17 00:00:00 2001 From: David Alsh Date: Tue, 20 Oct 2026 21:40:37 +0000 Subject: [PATCH 5/6] refactor(compress): rename compress.rs to brotli.rs --- src/platform/compress/{compress.rs => brotli.rs} | 0 src/platform/compress/mod.rs | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/platform/compress/{compress.rs => brotli.rs} (100%) diff --git a/src/platform/compress/compress.rs b/src/platform/compress/brotli.rs similarity index 100% rename from src/platform/compress/compress.rs rename to src/platform/compress/brotli.rs diff --git a/src/platform/compress/mod.rs b/src/platform/compress/mod.rs index 1ebd3e0..1349b68 100644 --- a/src/platform/compress/mod.rs +++ b/src/platform/compress/mod.rs @@ -1,8 +1,8 @@ -mod compress; +mod brotli; mod encoding; #[cfg(test)] mod encoding_test; -pub use self::compress::*; +pub use self::brotli::*; pub use self::encoding::*; From cb7ea4aff6431920cdb4b31dd52cb9e05b0065e9 Mon Sep 17 00:00:00 2001 From: David Alsh Date: Tue, 20 Oct 2026 21:50:30 +0000 Subject: [PATCH 6/6] refactor(compress): use a for loop in encoding negotiation --- src/platform/compress/encoding.rs | 32 +++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/platform/compress/encoding.rs b/src/platform/compress/encoding.rs index af3ad0e..1a8df83 100644 --- a/src/platform/compress/encoding.rs +++ b/src/platform/compress/encoding.rs @@ -18,20 +18,24 @@ impl Encoding { pub fn negotiate(accept_encoding: Option<&str>) -> Option { let header = accept_encoding?; - [Encoding::Brotli, Encoding::Gzip] - .into_iter() - .filter_map(|encoding| { - let quality = quality(header, encoding.name())?; - (quality > 0.0).then_some((encoding, quality)) - }) - .fold( - None, - |best: Option<(Encoding, f32)>, candidate| match best { - Some((_, quality)) if quality >= candidate.1 => best, - _ => Some(candidate), - }, - ) - .map(|(encoding, _)| 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(