#[cfg(test)]
mod tests;
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
use crate::header::Header;
use crate::request::Request;
use crate::response::Response;
const COMPRESSIBLE: &[&str] = &[
"text/html",
"text/css",
"text/javascript",
"text/plain",
"text/xml",
"application/json",
"application/javascript",
"application/xml",
"application/xhtml+xml",
"image/svg+xml",
];
pub const MIN_COMPRESS_SIZE: usize = 512;
pub fn apply_gzip(request: &Request, response: &mut Response) {
compress_with(request, response, &[Encoding::Gzip]);
}
pub fn apply_compression(request: &Request, response: &mut Response) {
compress_with(request, response, DYNAMIC_PREFERENCE);
}
fn compress_with(request: &Request, response: &mut Response, preference: &[Encoding]) {
if response.content_range_list.is_empty() {
return;
}
if response.status_code == 206 || response.content_range_list.len() > 1 {
return;
}
if response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(Header::_CONTENT_ENCODING)) {
return;
}
let original_size: usize = response.content_range_list.iter().map(|cr| cr.body.len()).sum();
if original_size < MIN_COMPRESS_SIZE {
return;
}
let Some(encoding) = negotiate(request, preference) else { return };
let content_type = response
.content_range_list
.first()
.map(|cr| cr.content_type.as_str())
.unwrap_or_default();
if !is_compressible(content_type) {
return;
}
let mut compressed_bodies = Vec::with_capacity(response.content_range_list.len());
for cr in &response.content_range_list {
match encode(encoding, &cr.body, Level::Dynamic) {
Some(compressed) => compressed_bodies.push(compressed),
None => return,
}
}
let compressed_size: usize = compressed_bodies.iter().map(Vec::len).sum();
if compressed_size >= original_size {
return;
}
for (cr, compressed) in response.content_range_list.iter_mut().zip(compressed_bodies) {
let new_len = compressed.len() as u64;
cr.body = compressed;
cr.range.end = new_len;
cr.size = new_len.to_string();
}
mark_encoded(response, encoding);
tag_etag(response, encoding);
}
fn tag_etag(response: &mut Response, encoding: Encoding) {
for header in response.headers.iter_mut().filter(|h| h.name.eq_ignore_ascii_case(Header::_ETAG)) {
let value = header.value.trim();
if let Some(quoted) = value.strip_suffix('"') {
let mut tagged = String::with_capacity(value.len() + 6);
tagged.push_str(quoted);
tagged.push('-');
tagged.push_str(encoding.token());
tagged.push('"');
header.value = tagged.into();
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Encoding {
Gzip,
Brotli,
Zstd,
}
impl Encoding {
pub fn token(self) -> &'static str {
match self {
Encoding::Gzip => "gzip",
Encoding::Brotli => "br",
Encoding::Zstd => "zstd",
}
}
pub fn available(self) -> bool {
match self {
Encoding::Gzip => true,
Encoding::Brotli => cfg!(feature = "brotli"),
Encoding::Zstd => cfg!(feature = "zstd"),
}
}
}
pub const DYNAMIC_PREFERENCE: &[Encoding] = &[Encoding::Zstd, Encoding::Brotli, Encoding::Gzip];
pub const STATIC_PREFERENCE: &[Encoding] = &[Encoding::Brotli, Encoding::Zstd, Encoding::Gzip];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Dynamic,
Static,
}
pub const BROTLI_QUALITY: [u32; 2] = [4, 6];
pub const ZSTD_LEVEL: [i32; 2] = [3, 6];
impl Level {
fn index(self) -> usize {
match self {
Level::Dynamic => 0,
Level::Static => 1,
}
}
}
pub fn negotiate(request: &Request, preference: &[Encoding]) -> Option<Encoding> {
let header = request.header(Header::_ACCEPT_ENCODING)?;
let mut best: Option<(Encoding, f32)> = None;
for &encoding in preference.iter().filter(|e| e.available()) {
let q = accepted_weight(&header.value, encoding.token());
if q > 0.0 && best.is_none_or(|(_, best_q)| q > best_q) {
best = Some((encoding, q));
}
}
best.map(|(encoding, _)| encoding)
}
fn accepted_weight(value: &str, token: &str) -> f32 {
let mut wildcard = None;
for coding in value.split(',') {
let mut parts = coding.split(';');
let name = parts.next().unwrap_or("").trim();
let q = parts
.find_map(|p| {
let p = p.trim();
p.as_bytes().get(..2).is_some_and(|b| b.eq_ignore_ascii_case(b"q=")).then(|| p[2..].trim().parse::<f32>().ok())
})
.flatten()
.filter(|q| (0.0..=1.0).contains(q))
.unwrap_or(1.0);
if name.eq_ignore_ascii_case(token) {
return q;
}
if name == "*" {
wildcard = Some(q);
}
}
wildcard.unwrap_or(0.0)
}
pub fn encode(encoding: Encoding, body: &[u8], level: Level) -> Option<Vec<u8>> {
match encoding {
Encoding::Gzip => gzip(body),
#[cfg(feature = "brotli")]
Encoding::Brotli => {
let mut out = Vec::with_capacity(body.len() / 3 + 64);
{
let mut writer = brotli::CompressorWriter::new(&mut out, 4096, BROTLI_QUALITY[level.index()], 22);
writer.write_all(body).ok()?;
writer.flush().ok()?;
}
Some(out)
}
#[cfg(feature = "zstd")]
Encoding::Zstd => zstd::bulk::compress(body, ZSTD_LEVEL[level.index()]).ok(),
#[allow(unreachable_patterns)]
_ => {
let _ = level;
None
}
}
}
pub fn mark_encoded(response: &mut Response, encoding: Encoding) {
response.headers.push(Header {
name: Header::_CONTENT_ENCODING.into(),
value: encoding.token().into(),
});
add_vary_accept_encoding(response);
}
pub(crate) fn is_compressible(content_type: &str) -> bool {
COMPRESSIBLE.iter().any(|mime| {
content_type.len() >= mime.len() && content_type.as_bytes()[..mime.len()].eq_ignore_ascii_case(mime.as_bytes())
})
}
pub(crate) fn gzip(body: &[u8]) -> Option<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(body).ok()?;
encoder.finish().ok()
}
fn add_vary_accept_encoding(response: &mut Response) {
let vary_pos = response.headers.iter().position(|h| h.name == Header::_VARY);
if let Some(i) = vary_pos {
let current = response.headers[i].value.clone();
if !current.to_lowercase().contains("accept-encoding") {
response.headers[i].value = format!("{}, Accept-Encoding", current).into();
}
} else {
response.headers.push(Header {
name: Header::_VARY.into(),
value: "Accept-Encoding".into(),
});
}
}