Skip to main content

http_handle/
compression.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2023 - 2026 HTTP Handle
3
4//! Response compression policy.
5//!
6//! Without a policy the server keeps its long-standing behaviour: it
7//! serves a precompressed sibling (`.br`, `.zst`, `.gz`) of a file when
8//! the client accepts that encoding. Setting a [`CompressionPolicy`]
9//! with [`ServerBuilder::compression_policy`](crate::ServerBuilder::compression_policy)
10//! adds the checks below, in order; the first that applies decides:
11//!
12//! 1. the policy, or the matched route ([`Route::compression`](crate::routing::Route::compression)),
13//!    disables compression: the plain file is served;
14//! 2. the client accepts none of `br`, `zstd` or `gzip` (`q=0` counts as
15//!    not accepted);
16//! 3. the content type is not in [`CompressionPolicy::compressible_types`],
17//!    which leaves already-compressed formats such as PNG or ZIP alone;
18//! 4. the file is smaller than [`CompressionPolicy::min_size_bytes`];
19//! 5. an accepted precompressed sibling exists: it is served;
20//! 6. with the `compression` feature and [`CompressionPolicy::dynamic_gzip`],
21//!    and no `Range` header, the body is gzip-compressed on the fly.
22//!
23//! Each decision is counted; [`stats`] returns the counters and the
24//! `/metrics` endpoint prints them.
25//!
26//! # Examples
27//!
28//! ```rust
29//! use http_handle::compression::CompressionPolicy;
30//! use http_handle::Server;
31//!
32//! let server = Server::builder()
33//!     .address("127.0.0.1:0")
34//!     .document_root(".")
35//!     .compression_policy(CompressionPolicy {
36//!         min_size_bytes: 512,
37//!         ..CompressionPolicy::default()
38//!     })
39//!     .build()
40//!     .expect("server");
41//! assert!(server.compression_policy().is_some());
42//! ```
43
44use crate::request::Request;
45use crate::server::negotiate_precompressed_variant;
46use serde::{Deserialize, Serialize};
47use std::path::{Path, PathBuf};
48use std::sync::atomic::{AtomicUsize, Ordering};
49
50/// When and how responses are compressed.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(default)]
53pub struct CompressionPolicy {
54    /// `false` serves every file uncompressed. Defaults to `true`.
55    pub enabled: bool,
56    /// Files smaller than this many bytes are served uncompressed.
57    /// Defaults to 1024.
58    pub min_size_bytes: u64,
59    /// Compress with gzip on the fly when no precompressed sibling
60    /// exists. Needs the `compression` feature; ignored without it.
61    /// Defaults to `true`.
62    pub dynamic_gzip: bool,
63    /// Content-type prefixes that may be compressed.
64    pub compressible_types: Vec<String>,
65}
66
67impl Default for CompressionPolicy {
68    fn default() -> Self {
69        Self {
70            enabled: true,
71            min_size_bytes: 1024,
72            dynamic_gzip: true,
73            compressible_types: [
74                "text/",
75                "application/javascript",
76                "application/json",
77                "application/manifest+json",
78                "application/typescript",
79                "application/wasm",
80                "application/x-yaml",
81                "application/toml",
82                "application/xml",
83                "image/svg+xml",
84            ]
85            .map(String::from)
86            .to_vec(),
87        }
88    }
89}
90
91/// Why a response was or was not compressed.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub(crate) enum Outcome {
94    Precompressed,
95    Dynamic,
96    Disabled,
97    ClientDeclined,
98    Type,
99    Size,
100    NoVariant,
101}
102
103const OUTCOMES: usize = 7;
104static COUNTERS: [AtomicUsize; OUTCOMES] =
105    [const { AtomicUsize::new(0) }; OUTCOMES];
106
107impl Outcome {
108    const ALL: [Outcome; OUTCOMES] = [
109        Outcome::Precompressed,
110        Outcome::Dynamic,
111        Outcome::Disabled,
112        Outcome::ClientDeclined,
113        Outcome::Type,
114        Outcome::Size,
115        Outcome::NoVariant,
116    ];
117
118    fn label(self) -> &'static str {
119        match self {
120            Outcome::Precompressed => "precompressed",
121            Outcome::Dynamic => "dynamic",
122            Outcome::Disabled => "disabled",
123            Outcome::ClientDeclined => "client",
124            Outcome::Type => "type",
125            Outcome::Size => "size",
126            Outcome::NoVariant => "no_variant",
127        }
128    }
129
130    pub(crate) fn record(self) {
131        let _ = COUNTERS[self as usize].fetch_add(1, Ordering::Relaxed);
132    }
133}
134
135/// Counts of compression decisions since the process started.
136#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
137pub struct CompressionStats {
138    /// Served a precompressed sibling.
139    pub precompressed: usize,
140    /// Compressed with gzip on the fly.
141    pub dynamic: usize,
142    /// Skipped: disabled by the policy or the route.
143    pub skipped_disabled: usize,
144    /// Skipped: the client accepts no supported encoding.
145    pub skipped_client: usize,
146    /// Skipped: content type not compressible.
147    pub skipped_type: usize,
148    /// Skipped: below the minimum size.
149    pub skipped_size: usize,
150    /// Skipped: no precompressed sibling and no dynamic compression.
151    pub skipped_no_variant: usize,
152}
153
154/// Returns the compression decision counters.
155pub fn stats() -> CompressionStats {
156    let get = |o: Outcome| COUNTERS[o as usize].load(Ordering::Relaxed);
157    CompressionStats {
158        precompressed: get(Outcome::Precompressed),
159        dynamic: get(Outcome::Dynamic),
160        skipped_disabled: get(Outcome::Disabled),
161        skipped_client: get(Outcome::ClientDeclined),
162        skipped_type: get(Outcome::Type),
163        skipped_size: get(Outcome::Size),
164        skipped_no_variant: get(Outcome::NoVariant),
165    }
166}
167
168/// Prometheus text lines for the `/metrics` endpoint.
169pub(crate) fn metrics_lines() -> String {
170    Outcome::ALL
171        .iter()
172        .map(|o| {
173            format!(
174                "http_handle_compression_total{{outcome=\"{}\"}} {}\n",
175                o.label(),
176                COUNTERS[*o as usize].load(Ordering::Relaxed)
177            )
178        })
179        .collect()
180}
181
182/// Whether the `Accept-Encoding` value accepts `coding` with a non-zero
183/// quality. An explicit entry for the coding wins over `*`.
184pub(crate) fn accepts(accept_encoding: &str, coding: &str) -> bool {
185    let mut wildcard = None;
186    for entry in accept_encoding.split(',') {
187        let mut fields = entry.split(';');
188        let name = fields.next().unwrap_or_default().trim();
189        let quality = fields
190            .filter_map(|p| p.trim().strip_prefix("q="))
191            .find_map(|q| q.trim().parse::<f32>().ok())
192            .unwrap_or(1.0);
193        if name.eq_ignore_ascii_case(coding) {
194            return quality > 0.0;
195        }
196        if name == "*" {
197            wildcard = Some(quality > 0.0);
198        }
199    }
200    wildcard.unwrap_or(false)
201}
202
203/// The checks that run before a variant is looked for: `None` means
204/// compression may go ahead.
205pub(crate) fn precheck(
206    policy: &CompressionPolicy,
207    accept_encoding: Option<&str>,
208    content_type: &str,
209    len: u64,
210) -> Option<Outcome> {
211    let accepted = accept_encoding.is_some_and(|accept| {
212        ["br", "zstd", "zst", "gzip"]
213            .iter()
214            .any(|coding| accepts(accept, coding))
215    });
216    if !policy.enabled {
217        Some(Outcome::Disabled)
218    } else if !accepted {
219        Some(Outcome::ClientDeclined)
220    } else if !policy
221        .compressible_types
222        .iter()
223        .any(|prefix| content_type.starts_with(prefix.as_str()))
224    {
225        Some(Outcome::Type)
226    } else if len < policy.min_size_bytes {
227        Some(Outcome::Size)
228    } else {
229        None
230    }
231}
232
233/// Whether to gzip the body on the fly.
234pub(crate) fn wants_dynamic_gzip(
235    policy: &CompressionPolicy,
236    accept_encoding: Option<&str>,
237    has_range: bool,
238) -> bool {
239    cfg!(feature = "compression")
240        && policy.dynamic_gzip
241        && !has_range
242        && accept_encoding.is_some_and(|accept| accepts(accept, "gzip"))
243}
244
245/// Gzip-compresses `body`.
246#[cfg(feature = "compression")]
247pub(crate) fn gzip(body: &[u8]) -> std::io::Result<Vec<u8>> {
248    use flate2::{Compression, write::GzEncoder};
249    use std::io::Write;
250    let mut encoder =
251        GzEncoder::new(Vec::new(), Compression::default());
252    encoder.write_all(body)?;
253    encoder.finish()
254}
255
256/// Without the `compression` feature dynamic gzip is never chosen.
257#[cfg(not(feature = "compression"))]
258pub(crate) fn gzip(_body: &[u8]) -> std::io::Result<Vec<u8>> {
259    Err(std::io::Error::other(
260        "built without the compression feature",
261    ))
262}
263
264/// Which compression rules apply to one request.
265#[derive(Clone, Copy, Debug)]
266pub(crate) enum Mode<'a> {
267    /// No policy: precompressed siblings only, nothing counted.
268    Legacy,
269    /// Switched off by the matched route.
270    Off,
271    /// The server's policy.
272    Policy(&'a CompressionPolicy),
273}
274
275impl<'a> Mode<'a> {
276    /// The mode for a server policy and a route's override.
277    pub(crate) fn new(
278        policy: Option<&'a CompressionPolicy>,
279        route_override: Option<bool>,
280    ) -> Self {
281        match (route_override, policy) {
282            (Some(false), _) => Mode::Off,
283            (_, Some(policy)) => Mode::Policy(policy),
284            (_, None) => Mode::Legacy,
285        }
286    }
287}
288
289/// The representation chosen for a file.
290#[derive(Debug)]
291pub(crate) enum Encoding {
292    /// The file as stored.
293    Identity,
294    /// A precompressed sibling and its `Content-Encoding`.
295    Precompressed(PathBuf, &'static str),
296    /// The file gzip-compressed on the fly.
297    Gzip,
298}
299
300impl Encoding {
301    /// The `Content-Encoding` value, if any.
302    pub(crate) fn header(&self) -> Option<&'static str> {
303        match self {
304            Encoding::Identity => None,
305            Encoding::Precompressed(_, coding) => Some(coding),
306            Encoding::Gzip => Some("gzip"),
307        }
308    }
309}
310
311/// Chooses how to encode the file at `path` (of `len` bytes) for
312/// `request`, and counts the decision under a policy.
313pub(crate) fn select(
314    mode: Mode<'_>,
315    request: &Request,
316    path: &Path,
317    canonical_root: &Path,
318    content_type: &str,
319    len: u64,
320) -> Encoding {
321    let accept = request.header("accept-encoding");
322    let policy = match mode {
323        Mode::Off => {
324            Outcome::Disabled.record();
325            return Encoding::Identity;
326        }
327        Mode::Legacy => None,
328        Mode::Policy(policy) => Some(policy),
329    };
330    if let Some(skip) =
331        policy.and_then(|p| precheck(p, accept, content_type, len))
332    {
333        skip.record();
334        return Encoding::Identity;
335    }
336    if let (sibling, Some(coding)) =
337        negotiate_precompressed_variant(path, accept, canonical_root)
338    {
339        if policy.is_some() {
340            Outcome::Precompressed.record();
341        }
342        return Encoding::Precompressed(sibling, coding);
343    }
344    let Some(policy) = policy else {
345        return Encoding::Identity;
346    };
347    if wants_dynamic_gzip(
348        policy,
349        accept,
350        request.header("range").is_some(),
351    ) {
352        Outcome::Dynamic.record();
353        return Encoding::Gzip;
354    }
355    Outcome::NoVariant.record();
356    Encoding::Identity
357}