tower_minify_html/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use bytes::Bytes;
4// use futures::future::BoxFuture;
5use http::{Request, Response, header};
6use http_body_util::{BodyExt, Full, combinators::UnsyncBoxBody};
7use std::{
8    future::Future,
9    pin::Pin,
10    task::{Context, Poll},
11};
12use tower::{Layer, Service};
13use tracing::{debug, error};
14
15#[cfg(feature = "standard")]
16pub use minify_html::Cfg;
17
18#[cfg(feature = "onepass")]
19pub use minify_html_onepass::Cfg as OnePassCfg;
20
21#[cfg(not(any(feature = "standard", feature = "onepass")))]
22compile_error!("Either feature 'standard' or 'onepass' (or both) must be enabled");
23
24/// The minification backend to use.
25#[derive(Clone, Copy, Debug)]
26pub enum Backend {
27    /// Use the standard `minify-html` backend.
28    #[cfg(feature = "standard")]
29    Standard,
30    /// Use the `minify-html-onepass` backend.
31    #[cfg(feature = "onepass")]
32    Onepass,
33}
34
35impl Default for Backend {
36    fn default() -> Self {
37        #[cfg(feature = "standard")]
38        return Backend::Standard;
39        #[cfg(all(feature = "onepass", not(feature = "standard")))]
40        return Backend::Onepass;
41    }
42}
43
44/// A Tower layer for minifying HTML responses.
45#[derive(Clone)]
46pub struct MinifyHtmlLayer {
47    backend: Backend,
48    #[cfg(feature = "standard")]
49    standard_config: minify_html::Cfg,
50    #[cfg(feature = "onepass")]
51    // minify_html_onepass::Cfg is not Clone, so wrap in Arc. See https://github.com/wilsonzlin/minify-html/pull/267.
52    onepass_config: std::sync::Arc<minify_html_onepass::Cfg>,
53}
54
55impl MinifyHtmlLayer {
56    /// Create a new [`MinifyHtmlLayerBuilder`].
57    pub fn builder() -> MinifyHtmlLayerBuilder {
58        MinifyHtmlLayerBuilder::default()
59    }
60
61    /// Create a new [`MinifyHtmlLayer`] with the standard backend and the given configuration.
62    #[cfg(feature = "standard")]
63    pub fn new(config: minify_html::Cfg) -> Self {
64        Self::builder().standard_config(config).build()
65    }
66}
67
68/// A builder for [`MinifyHtmlLayer`].
69#[derive(Default)]
70pub struct MinifyHtmlLayerBuilder {
71    backend: Backend,
72    #[cfg(feature = "standard")]
73    standard_config: minify_html::Cfg,
74    #[cfg(feature = "onepass")]
75    onepass_config: minify_html_onepass::Cfg,
76}
77
78impl MinifyHtmlLayerBuilder {
79    /// Set the minification backend.
80    pub fn backend(mut self, backend: Backend) -> Self {
81        self.backend = backend;
82        self
83    }
84
85    /// Set the configuration for the standard backend.
86    #[cfg(feature = "standard")]
87    pub fn standard_config(mut self, config: minify_html::Cfg) -> Self {
88        self.standard_config = config;
89        self
90    }
91
92    /// Set the configuration for the onepass backend.
93    #[cfg(feature = "onepass")]
94    pub fn onepass_config(mut self, config: minify_html_onepass::Cfg) -> Self {
95        self.onepass_config = config;
96        self
97    }
98
99    /// Build the [`MinifyHtmlLayer`].
100    pub fn build(self) -> MinifyHtmlLayer {
101        MinifyHtmlLayer {
102            backend: self.backend,
103            #[cfg(feature = "standard")]
104            standard_config: self.standard_config,
105            #[cfg(feature = "onepass")]
106            onepass_config: std::sync::Arc::new(self.onepass_config),
107        }
108    }
109}
110
111impl<S> Layer<S> for MinifyHtmlLayer {
112    type Service = MinifyHtml<S>;
113
114    fn layer(&self, inner: S) -> Self::Service {
115        MinifyHtml {
116            inner,
117            backend: self.backend,
118            #[cfg(feature = "standard")]
119            standard_config: self.standard_config.clone(),
120            #[cfg(feature = "onepass")]
121            onepass_config: self.onepass_config.clone(),
122        }
123    }
124}
125
126/// A Tower service for minifying HTML responses.
127#[derive(Clone)]
128pub struct MinifyHtml<S> {
129    inner: S,
130    backend: Backend,
131    #[cfg(feature = "standard")]
132    standard_config: minify_html::Cfg,
133    #[cfg(feature = "onepass")]
134    onepass_config: std::sync::Arc<minify_html_onepass::Cfg>,
135}
136
137impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for MinifyHtml<S>
138where
139    S: Service<Request<ReqBody>, Response = Response<ResBody>> + Send + 'static,
140    S::Future: Send + 'static,
141    ResBody: BodyExt<Data = Bytes> + Send + 'static,
142    ResBody::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
143{
144    type Response = Response<UnsyncBoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>>;
145    type Error = S::Error;
146    type Future =
147        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
148
149    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
150        self.inner.poll_ready(cx)
151    }
152
153    fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
154        let response_future = self.inner.call(request);
155        let backend = self.backend;
156        #[cfg(feature = "standard")]
157        let standard_config = self.standard_config.clone();
158        #[cfg(feature = "onepass")]
159        let onepass_config = self.onepass_config.clone();
160
161        Box::pin(async move {
162            let response = response_future.await?;
163
164            let is_html = response
165                .headers()
166                .get(header::CONTENT_TYPE)
167                .and_then(|v| v.to_str().ok())
168                .map(|v| v.contains("text/html"))
169                .unwrap_or(false);
170
171            if !is_html {
172                return Ok(response.map(|b| b.map_err(|e| e.into()).boxed_unsync()));
173            }
174
175            let (mut parts, body) = response.into_parts();
176
177            // Remove Content-Length as it changes
178            parts.headers.remove(header::CONTENT_LENGTH);
179
180            let bytes = match body.collect().await {
181                Ok(c) => c.to_bytes(),
182                Err(_e) => {
183                    error!("Failed to collect response body for minification");
184                    return Ok(error_500_response());
185                }
186            };
187
188            let minified = match backend {
189                #[cfg(feature = "standard")]
190                Backend::Standard => minify_html::minify(&bytes, &standard_config),
191
192                #[cfg(feature = "onepass")]
193                Backend::Onepass => {
194                    let mut vec = bytes.to_vec();
195                    match minify_html_onepass::in_place(&mut vec, &onepass_config) {
196                        Ok(len) => {
197                            vec.truncate(len);
198                            vec
199                        }
200                        Err(_) => return Ok(error_500_response()),
201                    }
202                }
203            };
204
205            debug!(
206                "HTML minified: original size {} bytes, minified size {} bytes",
207                bytes.len(),
208                minified.len()
209            );
210
211            let new_body = Full::new(Bytes::from(minified))
212                .map_err(|_e| unreachable!("Full body never errors"))
213                .boxed_unsync();
214
215            Ok(Response::from_parts(parts, new_body))
216        })
217    }
218}
219
220fn error_500_response() -> Response<UnsyncBoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>>
221{
222    Response::builder()
223        .status(500)
224        .body(
225            Full::from("Internal Server Error")
226                .map_err(|e| e.into())
227                .boxed_unsync(),
228        )
229        .unwrap()
230}