Skip to main content

trillium_compression/
lib.rs

1//! Body compression for trillium.rs
2//!
3//! Currently, this crate only supports compressing outbound bodies with
4//! the zstd, brotli, and gzip algorithms (in order of preference),
5//! although more algorithms may be added in the future. The correct
6//! algorithm will be selected based on the Accept-Encoding header sent by
7//! the client, if one exists.
8//!
9//! Defaults are tuned for HTTP transport: brotli at quality 4 (matching
10//! nginx/caddy/Cloudflare). To opt into stronger or weaker compression,
11//! see [`Compression::with_brotli_level`], [`Compression::with_gzip_level`],
12//! and [`Compression::with_zstd_level`].
13//!
14//! Responses with `Content-Encoding` already set (e.g. precompressed
15//! sidecars) are passed through unchanged. Responses with already-
16//! compressed `Content-Type` (images, video, audio, fonts, archives) are
17//! skipped by default.
18#![forbid(unsafe_code)]
19#![deny(
20    missing_copy_implementations,
21    rustdoc::missing_crate_level_docs,
22    missing_debug_implementations,
23    nonstandard_style,
24    unused_qualifications
25)]
26#![warn(missing_docs)]
27
28#[cfg(test)]
29#[doc = include_str!("../README.md")]
30mod readme {}
31
32#[cfg(feature = "client")]
33pub mod client;
34
35pub use async_compression::Level;
36#[cfg(feature = "client")]
37use async_compression::futures::bufread::{BrotliDecoder, GzipDecoder, ZstdDecoder};
38use async_compression::futures::bufread::{BrotliEncoder, GzipEncoder, ZstdEncoder};
39use futures_lite::{
40    AsyncBufRead, AsyncReadExt,
41    io::{BufReader, Cursor},
42};
43use std::{
44    collections::BTreeSet,
45    fmt::{self, Display, Formatter},
46    str::FromStr,
47};
48use trillium::{
49    Body, Conn, Handler, HeaderValues,
50    KnownHeaderName::{AcceptEncoding, ContentEncoding, ContentLength, ContentType, Vary},
51    conn_unwrap,
52};
53
54/// Algorithms supported by this crate
55#[derive(PartialEq, Eq, Clone, Copy, Debug, Ord, PartialOrd)]
56#[non_exhaustive]
57pub enum CompressionAlgorithm {
58    /// Brotli algorithm
59    Brotli,
60
61    /// Gzip algorithm
62    Gzip,
63
64    /// Zstd algorithm
65    Zstd,
66
67    /// The identity content-coding: no transformation. Set this on a client conn's state to opt
68    /// a single request out of a configured default request encoding.
69    Identity,
70}
71
72impl CompressionAlgorithm {
73    fn as_str(&self) -> &'static str {
74        match self {
75            CompressionAlgorithm::Brotli => "br",
76            CompressionAlgorithm::Gzip => "gzip",
77            CompressionAlgorithm::Zstd => "zstd",
78            CompressionAlgorithm::Identity => "identity",
79        }
80    }
81
82    fn from_str_exact(s: &str) -> Option<Self> {
83        match s {
84            "br" => Some(CompressionAlgorithm::Brotli),
85            "gzip" => Some(CompressionAlgorithm::Gzip),
86            "x-gzip" => Some(CompressionAlgorithm::Gzip),
87            "zstd" => Some(CompressionAlgorithm::Zstd),
88            "identity" => Some(CompressionAlgorithm::Identity),
89            _ => None,
90        }
91    }
92}
93
94impl AsRef<str> for CompressionAlgorithm {
95    fn as_ref(&self) -> &str {
96        self.as_str()
97    }
98}
99
100impl Display for CompressionAlgorithm {
101    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106impl FromStr for CompressionAlgorithm {
107    type Err = String;
108
109    fn from_str(s: &str) -> Result<Self, Self::Err> {
110        Self::from_str_exact(s)
111            .or_else(|| Self::from_str_exact(&s.to_ascii_lowercase()))
112            .ok_or_else(|| format!("unrecognized coding {s}"))
113    }
114}
115
116/// Trillium handler for compression
117#[derive(Clone, Debug)]
118pub struct Compression {
119    algorithms: BTreeSet<CompressionAlgorithm>,
120    brotli_level: Level,
121    gzip_level: Level,
122    zstd_level: Level,
123}
124
125impl Default for Compression {
126    fn default() -> Self {
127        use CompressionAlgorithm::*;
128        Self {
129            algorithms: [Zstd, Brotli, Gzip].into_iter().collect(),
130            // q11 (async-compression default) is ~10x slower than q4 with
131            // only a few percent better ratio — bad fit for the response
132            // hot path. Match nginx/caddy transport defaults.
133            brotli_level: Level::Precise(4),
134            gzip_level: Level::Default,
135            zstd_level: Level::Default,
136        }
137    }
138}
139
140impl Compression {
141    /// constructs a new compression handler
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    fn set_algorithms(&mut self, algos: &[CompressionAlgorithm]) {
147        self.algorithms = algos.iter().copied().collect();
148    }
149
150    /// sets the compression algorithms that this handler will
151    /// use. the default of Zstd, Brotli, Gzip is recommended. Note that the
152    /// order is ignored.
153    pub fn with_algorithms(mut self, algorithms: &[CompressionAlgorithm]) -> Self {
154        self.set_algorithms(algorithms);
155        self
156    }
157
158    /// sets the brotli compression level. The default is `Level::Precise(4)`,
159    /// matching common reverse proxy transport defaults. `Level::Default`
160    /// resolves to brotli quality 11, which is much slower for marginal
161    /// size gains.
162    pub fn with_brotli_level(mut self, level: Level) -> Self {
163        self.brotli_level = level;
164        self
165    }
166
167    /// sets the gzip compression level. The default is `Level::Default`,
168    /// which resolves to gzip level 6.
169    pub fn with_gzip_level(mut self, level: Level) -> Self {
170        self.gzip_level = level;
171        self
172    }
173
174    /// sets the zstd compression level. The default is `Level::Default`,
175    /// which resolves to zstd level 3.
176    pub fn with_zstd_level(mut self, level: Level) -> Self {
177        self.zstd_level = level;
178        self
179    }
180
181    fn levels(&self) -> Levels {
182        Levels {
183            brotli: self.brotli_level,
184            gzip: self.gzip_level,
185            zstd: self.zstd_level,
186        }
187    }
188
189    fn negotiate(&self, header: &str) -> Option<CompressionAlgorithm> {
190        let entries = parse_accept_encoding(header);
191        let wildcard = entries
192            .iter()
193            .find_map(|&(coding, q)| (coding == Coding::Wildcard).then_some(q));
194
195        let mut candidates = self
196            .algorithms
197            .iter()
198            .filter_map(|&algo| {
199                let q = entries
200                    .iter()
201                    .find_map(|&(coding, q)| (coding == Coding::Algorithm(algo)).then_some(q))
202                    .or(wildcard)?;
203                (q > 0).then_some((algo, q))
204            })
205            .collect::<Vec<_>>();
206
207        candidates.sort_by(|(algo_a, a), (algo_b, b)| b.cmp(a).then(algo_a.cmp(algo_b)));
208        candidates.first().map(|&(algo, _)| algo)
209    }
210}
211
212/// A single entry in an `Accept-Encoding` header: either a content-coding this crate implements,
213/// or the `*` wildcard, which supplies a quality value for every coding not named elsewhere in
214/// the header.
215#[derive(PartialEq, Eq, Clone, Copy, Debug)]
216enum Coding {
217    Algorithm(CompressionAlgorithm),
218    Wildcard,
219}
220
221/// Parses `Accept-Encoding` into codings and quality values in thousandths, discarding codings
222/// this crate does not implement. A `q` that does not parse is treated as absent, and an absent
223/// `q` is the RFC 9110 default of 1.
224fn parse_accept_encoding(header: &str) -> Vec<(Coding, u16)> {
225    header
226        .split(',')
227        .filter_map(|s| {
228            let mut params = s.trim().split(';');
229            let coding = match params.next()?.trim() {
230                "*" => Coding::Wildcard,
231                algo => Coding::Algorithm(algo.parse().ok()?),
232            };
233
234            let q = params
235                .find_map(|param| {
236                    let param = param.trim();
237                    let q = param
238                        .strip_prefix("q=")
239                        .or_else(|| param.strip_prefix("Q="))?;
240                    q.parse::<f32>().ok()
241                })
242                .unwrap_or(1.0);
243
244            Some((coding, (q.clamp(0.0, 1.0) * 1000.0) as u16))
245        })
246        .collect()
247}
248
249/// Returns true if the content-type identifies a payload that is already
250/// compressed and should be passed through. The list covers image/audio/
251/// video binary formats, web fonts, and common archive formats. Plain-
252/// text-y types like `image/svg+xml` and `application/wasm` are intentionally
253/// not skipped.
254fn is_already_compressed(content_type: &str) -> bool {
255    let primary = content_type
256        .split(';')
257        .next()
258        .unwrap_or(content_type)
259        .trim();
260    matches!(
261        primary,
262        "image/png"
263            | "image/jpeg"
264            | "image/jpg"
265            | "image/gif"
266            | "image/webp"
267            | "image/avif"
268            | "image/heic"
269            | "image/heif"
270            | "image/apng"
271            | "image/x-icon"
272            | "video/mp4"
273            | "video/webm"
274            | "video/ogg"
275            | "video/quicktime"
276            | "video/x-msvideo"
277            | "audio/mpeg"
278            | "audio/ogg"
279            | "audio/webm"
280            | "audio/aac"
281            | "audio/flac"
282            | "audio/mp4"
283            | "font/woff"
284            | "font/woff2"
285            | "application/zip"
286            | "application/gzip"
287            | "application/x-gzip"
288            | "application/x-bzip2"
289            | "application/x-xz"
290            | "application/x-7z-compressed"
291            | "application/x-rar-compressed"
292            | "application/zstd"
293    ) || primary.starts_with("video/")
294        || primary.starts_with("audio/")
295}
296
297/// Per-algorithm compression levels, threaded into the encode helpers.
298#[derive(Clone, Copy, Debug)]
299pub(crate) struct Levels {
300    brotli: Level,
301    gzip: Level,
302    zstd: Level,
303}
304
305impl Default for Levels {
306    fn default() -> Self {
307        Self {
308            brotli: Level::Precise(4),
309            gzip: Level::Default,
310            zstd: Level::Default,
311        }
312    }
313}
314
315impl CompressionAlgorithm {
316    /// Apply this content-coding to `body`, returning the (possibly unchanged) body and whether
317    /// encoding was actually applied. The identity coding, an empty body, and any static body that
318    /// fails to shrink are returned untouched with `false`.
319    pub(crate) async fn encode(self, body: Body, levels: Levels) -> (Body, bool) {
320        if self == Self::Identity {
321            return (body, false);
322        }
323
324        if body.is_static() {
325            let bytes = body.static_bytes().unwrap();
326            match self.encode_static(bytes, levels).await {
327                Some(data) if data.len() < bytes.len() => {
328                    log::trace!(
329                        "compressed {} body {} → {} bytes",
330                        self.as_str(),
331                        bytes.len(),
332                        data.len()
333                    );
334                    (Body::new_static(data), true)
335                }
336                _ => (body, false),
337            }
338        } else if body.is_streaming() {
339            (
340                self.encode_streaming(BufReader::new(body.into_reader()), levels),
341                true,
342            )
343        } else {
344            (body, false)
345        }
346    }
347
348    /// Compress in-memory `bytes`, or `None` for the identity coding or on encoder error.
349    async fn encode_static(self, bytes: &[u8], levels: Levels) -> Option<Vec<u8>> {
350        let mut data = vec![];
351        let result = match self {
352            Self::Identity => return None,
353            Self::Zstd => {
354                ZstdEncoder::with_quality(Cursor::new(bytes), levels.zstd)
355                    .read_to_end(&mut data)
356                    .await
357            }
358            Self::Brotli => {
359                BrotliEncoder::with_quality(Cursor::new(bytes), levels.brotli)
360                    .read_to_end(&mut data)
361                    .await
362            }
363            Self::Gzip => {
364                GzipEncoder::with_quality(Cursor::new(bytes), levels.gzip)
365                    .read_to_end(&mut data)
366                    .await
367            }
368        };
369        result.ok().map(|_| data)
370    }
371
372    /// Wrap `reader` in a streaming encoder for this content-coding; identity passes through.
373    fn encode_streaming(self, reader: impl AsyncBufRead + Send + 'static, levels: Levels) -> Body {
374        match self {
375            Self::Identity => Body::new_streaming(reader, None),
376            Self::Zstd => Body::new_streaming(ZstdEncoder::with_quality(reader, levels.zstd), None),
377            Self::Brotli => {
378                Body::new_streaming(BrotliEncoder::with_quality(reader, levels.brotli), None)
379            }
380            Self::Gzip => Body::new_streaming(GzipEncoder::with_quality(reader, levels.gzip), None),
381        }
382    }
383
384    /// Wrap `reader` in a streaming decoder for this content-coding; identity passes through.
385    #[cfg(feature = "client")]
386    pub(crate) fn decode_streaming(self, reader: impl AsyncBufRead + Send + 'static) -> Body {
387        match self {
388            Self::Identity => Body::new_streaming(reader, None),
389            Self::Zstd => Body::new_streaming(ZstdDecoder::new(reader), None),
390            Self::Brotli => Body::new_streaming(BrotliDecoder::new(reader), None),
391            Self::Gzip => Body::new_streaming(GzipDecoder::new(reader), None),
392        }
393    }
394}
395
396impl Handler for Compression {
397    async fn run(&self, mut conn: Conn) -> Conn {
398        if let Some(header) = conn
399            .request_headers()
400            .get_str(AcceptEncoding)
401            .and_then(|h| self.negotiate(h))
402        {
403            conn.insert_state(header);
404        }
405        conn
406    }
407
408    async fn before_send(&self, mut conn: Conn) -> Conn {
409        // Already encoded upstream (precompressed sidecar, or another
410        // middleware ahead of us) — leave it alone.
411        if conn.response_headers().get_str(ContentEncoding).is_some() {
412            return conn;
413        }
414
415        // Skip already-compressed payloads (images, fonts, archives, ...).
416        if conn
417            .response_headers()
418            .get_str(ContentType)
419            .is_some_and(is_already_compressed)
420        {
421            return conn;
422        }
423
424        let Some(algo) = conn.state::<CompressionAlgorithm>().copied() else {
425            return conn;
426        };
427
428        let body = conn_unwrap!(conn.take_response_body(), conn);
429        let (body, compression_used) = algo.encode(body, self.levels()).await;
430
431        if compression_used {
432            let vary = conn
433                .response_headers()
434                .get_str(Vary)
435                .map(|vary| HeaderValues::from(format!("{vary}, Accept-Encoding")))
436                .unwrap_or_else(|| HeaderValues::from("Accept-Encoding"));
437
438            conn.response_headers_mut().extend([
439                (ContentEncoding, HeaderValues::from(algo.as_str())),
440                (Vary, vary),
441            ]);
442
443            conn.response_headers_mut().remove(ContentLength);
444        }
445
446        conn.with_body(body)
447    }
448}
449
450/// Alias for [`Compression::new`](crate::Compression::new)
451pub fn compression() -> Compression {
452    Compression::new()
453}