1#![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#[derive(PartialEq, Eq, Clone, Copy, Debug, Ord, PartialOrd)]
56#[non_exhaustive]
57pub enum CompressionAlgorithm {
58 Brotli,
60
61 Gzip,
63
64 Zstd,
66
67 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#[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 brotli_level: Level::Precise(4),
134 gzip_level: Level::Default,
135 zstd_level: Level::Default,
136 }
137 }
138}
139
140impl Compression {
141 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 pub fn with_algorithms(mut self, algorithms: &[CompressionAlgorithm]) -> Self {
154 self.set_algorithms(algorithms);
155 self
156 }
157
158 pub fn with_brotli_level(mut self, level: Level) -> Self {
163 self.brotli_level = level;
164 self
165 }
166
167 pub fn with_gzip_level(mut self, level: Level) -> Self {
170 self.gzip_level = level;
171 self
172 }
173
174 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#[derive(PartialEq, Eq, Clone, Copy, Debug)]
216enum Coding {
217 Algorithm(CompressionAlgorithm),
218 Wildcard,
219}
220
221fn 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
249fn 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#[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 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 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 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 #[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 if conn.response_headers().get_str(ContentEncoding).is_some() {
412 return conn;
413 }
414
415 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
450pub fn compression() -> Compression {
452 Compression::new()
453}