Skip to main content

sova_compress/
lib.rs

1//! Response compression for Sova (Express [`compression`](https://expressjs.com/en/resources/middleware/compression.html)-style).
2//!
3//! Supports `br`, `gzip`, and `deflate`. Bodies are buffered then compressed
4//! (not streamed chunk-by-chunk).
5//!
6//! ```ignore
7//! app.install(Compress::new().threshold(1024).level(6));
8//! ```
9
10use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder};
11use async_compression::Level;
12use bytes::Bytes;
13use http::header::{CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, VARY};
14use http::Method;
15use sova_core::extend::{named, with_leaked};
16use sova_core::{App, Next, Plugin, Request, Response};
17use std::io::Cursor;
18use std::sync::Arc;
19use tokio::io::{AsyncReadExt, BufReader};
20
21/// `true` → consider compression (Express `filter`).
22pub type Filter = Arc<dyn Fn(&Request, &Response) -> bool + Send + Sync>;
23
24/// Gzip / Deflate / Brotli response compression.
25pub struct Compress {
26    threshold: usize,
27    /// zlib level for gzip/deflate (`0..=9`, default `6`).
28    level: i32,
29    /// Brotli quality (`0..=11`, Express default `4`).
30    brotli_quality: i32,
31    filter: Filter,
32}
33
34impl Compress {
35    pub fn new() -> Self {
36        Self {
37            threshold: 1024,
38            level: 6,
39            brotli_quality: 4,
40            filter: Arc::new(Self::default_filter),
41        }
42    }
43
44    /// Minimum body size in bytes before compressing (Express default: 1 KiB).
45    pub fn threshold(mut self, bytes: usize) -> Self {
46        self.threshold = bytes;
47        self
48    }
49
50    /// Gzip/deflate compression level `0..=9` (default `6`).
51    pub fn level(mut self, level: i32) -> Self {
52        self.level = level.clamp(0, 9);
53        self
54    }
55
56    /// Brotli quality `0..=11` (default `4`, as in Express).
57    pub fn brotli_quality(mut self, q: i32) -> Self {
58        self.brotli_quality = q.clamp(0, 11);
59        self
60    }
61
62    /// Replace the filter (`true` → consider compression).
63    pub fn filter<F>(mut self, f: F) -> Self
64    where
65        F: Fn(&Request, &Response) -> bool + Send + Sync + 'static,
66    {
67        self.filter = Arc::new(f);
68        self
69    }
70
71    /// Default filter: no `x-no-compression`, compressible `Content-Type`
72    /// (same role as Express `compression.filter` + `compressible`).
73    pub fn default_filter(req: &Request, res: &Response) -> bool {
74        if req.header("x-no-compression").is_some() {
75            return false;
76        }
77        let Some(ct) = res
78            .headers()
79            .get(CONTENT_TYPE)
80            .and_then(|v| v.to_str().ok())
81        else {
82            return false;
83        };
84        is_compressible(ct)
85    }
86}
87
88impl Default for Compress {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Plugin for Compress {
95    fn id(&self) -> &'static str {
96        "compress"
97    }
98
99    fn meta(&self) -> sova_core::PluginMeta {
100        sova_core::PluginMeta::new("Compress")
101            .description("gzip / deflate / brotli response compression")
102            .version(env!("CARGO_PKG_VERSION"))
103    }
104
105    fn install(self, app: &mut App) {
106        app.use_middleware(named(
107            "compress",
108            with_leaked(self, |cfg, req, next| async move { run(cfg, req, next).await }),
109        ));
110    }
111}
112
113async fn run(cfg: &'static Compress, req: Request, next: Next) -> Response {
114    let accept = req
115        .header("accept-encoding")
116        .unwrap_or("")
117        .to_ascii_lowercase();
118    let is_head = req.method == Method::HEAD;
119    let filter_req = snapshot_request(&req);
120    let mut res = next(req).await;
121
122    // Express varies on Accept-Encoding when the client sent it.
123    if !accept.is_empty() {
124        append_vary(&mut res, "Accept-Encoding");
125    }
126
127    if is_head || res.status_code().as_u16() == 206 {
128        return res;
129    }
130    if !should_transform(&res) {
131        return res;
132    }
133    if res.headers().get(CONTENT_ENCODING).is_some() {
134        return res;
135    }
136    if !(cfg.filter)(&filter_req, &res) {
137        return res;
138    }
139
140    let Some(encoding) = negotiate(&accept) else {
141        return res;
142    };
143
144    // ponytail: buffer whole body; stream wrap if SSE/large downloads need it.
145    let body = res.take_body();
146    let data = match body.collect().await {
147        Ok(b) => b,
148        Err(_) => return res,
149    };
150
151    if data.len() < cfg.threshold {
152        res.set_body(data);
153        return res;
154    }
155
156    match encode(encoding, &data, cfg.level, cfg.brotli_quality).await {
157        Ok(out) => {
158            res.headers_mut().remove(CONTENT_LENGTH);
159            if let Ok(v) = encoding.parse() {
160                res.headers_mut().insert(CONTENT_ENCODING, v);
161            }
162            weaken_etag(&mut res);
163            res.set_body(out);
164            res
165        }
166        Err(_) => {
167            res.set_body(data);
168            res
169        }
170    }
171}
172
173fn snapshot_request(req: &Request) -> Request {
174    let mut b = Request::builder()
175        .method(req.method.clone())
176        .path(req.path.clone());
177    for (name, value) in req.headers.iter() {
178        if let Ok(v) = value.to_str() {
179            b = b.header(name.as_str(), v);
180        }
181    }
182    b.build()
183}
184
185fn should_transform(res: &Response) -> bool {
186    let Some(cc) = res
187        .headers()
188        .get(CACHE_CONTROL)
189        .and_then(|v| v.to_str().ok())
190    else {
191        return true;
192    };
193    !cc.to_ascii_lowercase()
194        .split(',')
195        .any(|d| d.trim() == "no-transform")
196}
197
198fn append_vary(res: &mut Response, token: &str) {
199    let existing = res
200        .headers()
201        .get(VARY)
202        .and_then(|v| v.to_str().ok())
203        .unwrap_or("")
204        .to_string();
205    if existing
206        .split(',')
207        .any(|t| t.trim().eq_ignore_ascii_case(token))
208    {
209        return;
210    }
211    let value = if existing.is_empty() {
212        token.to_string()
213    } else {
214        format!("{existing}, {token}")
215    };
216    if let Ok(v) = value.parse() {
217        res.headers_mut().insert(VARY, v);
218    }
219}
220
221/// Rough port of the `compressible` npm heuristics used by Express.
222fn is_compressible(content_type: &str) -> bool {
223    let ct = content_type
224        .split(';')
225        .next()
226        .unwrap_or(content_type)
227        .trim()
228        .to_ascii_lowercase();
229    if ct.is_empty() {
230        return false;
231    }
232    if ct.starts_with("text/") || ct == "image/svg+xml" {
233        return true;
234    }
235    if ct.starts_with("image/")
236        || ct.starts_with("audio/")
237        || ct.starts_with("video/")
238        || ct.contains("zip")
239        || ct.contains("gzip")
240        || ct.contains("octet-stream")
241        || ct.contains("wasm")
242    {
243        return false;
244    }
245    matches!(
246        ct.as_str(),
247        "application/json"
248            | "application/javascript"
249            | "application/ecmascript"
250            | "application/xml"
251            | "application/x-www-form-urlencoded"
252            | "application/graphql"
253            | "application/ld+json"
254            | "application/manifest+json"
255            | "application/vnd.api+json"
256            | "application/xhtml+xml"
257            | "application/rss+xml"
258            | "application/atom+xml"
259            | "font/ttf"
260            | "font/otf"
261    ) || ct.ends_with("+json")
262        || ct.ends_with("+xml")
263        || ct.ends_with("+text")
264}
265
266fn weaken_etag(res: &mut Response) {
267    let Some(tag) = res.headers().get(ETAG).and_then(|v| v.to_str().ok()) else {
268        return;
269    };
270    if tag.starts_with("W/") {
271        return;
272    }
273    let weak = format!("W/{tag}");
274    if let Ok(v) = weak.parse() {
275        res.headers_mut().insert(ETAG, v);
276    }
277}
278
279fn negotiate(accept: &str) -> Option<&'static str> {
280    // Preferred order on equal q (Express: br, gzip).
281    const CANDIDATES: &[&str] = &["br", "gzip", "deflate"];
282    let mut best: Option<(&'static str, f32, usize)> = None;
283    for part in accept.split(',') {
284        let mut it = part.trim().split(';');
285        let coding = it.next().unwrap_or("").trim();
286        let idx = match coding {
287            "br" => 0,
288            "gzip" => 1,
289            "deflate" => 2,
290            _ => continue,
291        };
292        let mut q = 1.0_f32;
293        for param in it {
294            let p = param.trim();
295            if let Some(v) = p.strip_prefix("q=") {
296                q = v.parse().unwrap_or(0.0);
297            }
298        }
299        if q <= 0.0 {
300            continue;
301        }
302        let coding = CANDIDATES[idx];
303        match best {
304            None => best = Some((coding, q, idx)),
305            Some((_, prev_q, prev_idx)) => {
306                if q > prev_q || (q == prev_q && idx < prev_idx) {
307                    best = Some((coding, q, idx));
308                }
309            }
310        }
311    }
312    best.map(|(c, _, _)| c)
313}
314
315async fn encode(encoding: &str, data: &Bytes, level: i32, brotli_q: i32) -> std::io::Result<Bytes> {
316    let reader = BufReader::new(Cursor::new(data.clone()));
317    let mut out = Vec::new();
318    match encoding {
319        "br" => {
320            let mut enc = BrotliEncoder::with_quality(reader, Level::Precise(brotli_q));
321            enc.read_to_end(&mut out).await?;
322        }
323        "deflate" => {
324            let mut enc = DeflateEncoder::with_quality(reader, Level::Precise(level));
325            enc.read_to_end(&mut out).await?;
326        }
327        _ => {
328            let mut enc = GzipEncoder::with_quality(reader, Level::Precise(level));
329            enc.read_to_end(&mut out).await?;
330        }
331    }
332    Ok(Bytes::from(out))
333}