Skip to main content

trillium_html_rewriter/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(
3    clippy::dbg_macro,
4    missing_copy_implementations,
5    rustdoc::missing_crate_level_docs,
6    missing_debug_implementations,
7    missing_docs,
8    nonstandard_style,
9    unused_qualifications
10)]
11#![doc = include_str!("../README.md")]
12
13use lol_async::rewrite;
14pub use lol_async::{Settings, html};
15use mime::Mime;
16use std::{
17    fmt::{self, Debug, Formatter},
18    future::{Future, ready},
19    pin::Pin,
20    str::FromStr,
21    sync::Arc,
22};
23use trillium::{
24    Body, Conn, Handler,
25    KnownHeaderName::{ContentLength, ContentType},
26};
27
28/// A trillium [`Handler`] that rewrites HTML response bodies with
29/// [`lol-html`](https://docs.rs/lol-html), using [`lol-async`](https://docs.rs/lol-async).
30///
31/// It wraps the response produced by other handlers: in [`before_send`](Handler::before_send) it
32/// inspects the outgoing `Content-Type` and, if the mime subtype is `html` (e.g. `text/html`),
33/// replaces the response body with a streaming rewrite driven by the [`Settings`] returned from
34/// the settings function passed to [`new`](HtmlRewriter::new),
35/// [`new_with_conn`](HtmlRewriter::new_with_conn), or [`new_async`](HtmlRewriter::new_async).
36/// Responses with any other content type (or none) are passed through unchanged.
37pub struct HtmlRewriter {
38    settings: Arc<dyn ErasedSettingsFn>,
39}
40
41/// An async function from [`&Conn`](Conn) to [`Settings`], as accepted by
42/// [`HtmlRewriter::new_async`].
43///
44/// This trait is implemented for any `async` closure or `async fn` that takes a `&Conn` and
45/// returns `Settings<'static, 'static>`, as well as for plain closures that return such a future.
46/// The lifetime parameter allows the future to borrow the `Conn` across `.await` points; because
47/// of that, the bound in [`HtmlRewriter::new_async`] is the higher-ranked
48/// `F: for<'a> SettingsFn<'a>`.
49///
50/// You should not need to implement or name this trait directly — write one of the closure forms
51/// documented on [`HtmlRewriter::new_async`].
52#[diagnostic::on_unimplemented(
53    message = "`{Self}` is not an async settings builder",
54    label = "expected an async function from `&Conn` to `Settings<'static, 'static>`",
55    note = "if the settings don't require awaiting anything, use `HtmlRewriter::new` or \
56            `HtmlRewriter::new_with_conn` instead of `HtmlRewriter::new_async`",
57    note = "write an async closure with an annotated parameter: `async |conn: &Conn| {{ .. }}`, \
58            not `|conn| async move {{ .. }}` — and the `&Conn` annotation is required for \
59            inference",
60    note = "async closures that capture state don't implement `Fn`; to use captured state, write \
61            a plain closure returning an async block that owns its data: `move |conn: &Conn| {{ \
62            let data = data.clone(); async move {{ .. }} }}`",
63    note = "the returned future must be `Send`"
64)]
65pub trait SettingsFn<'a>: Send + Sync + 'static {
66    /// The future returned by [`call`](SettingsFn::call).
67    type Fut: Future<Output = Settings<'static, 'static>> + Send + 'a;
68
69    /// Build the [`Settings`] that will rewrite the response on this [`Conn`].
70    fn call(&self, conn: &'a Conn) -> Self::Fut;
71}
72
73impl<'a, F, Fut> SettingsFn<'a> for F
74where
75    F: Fn(&'a Conn) -> Fut + Send + Sync + 'static,
76    Fut: Future<Output = Settings<'static, 'static>> + Send + 'a,
77{
78    type Fut = Fut;
79
80    fn call(&self, conn: &'a Conn) -> Fut {
81        self(conn)
82    }
83}
84
85type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
86
87/// Object-safe form of [`SettingsFn`] for storage in [`HtmlRewriter`].
88trait ErasedSettingsFn: Send + Sync {
89    fn call<'a>(&'a self, conn: &'a Conn) -> BoxFuture<'a, Settings<'static, 'static>>;
90}
91
92impl<F> ErasedSettingsFn for F
93where
94    F: for<'a> SettingsFn<'a>,
95{
96    fn call<'a>(&'a self, conn: &'a Conn) -> BoxFuture<'a, Settings<'static, 'static>> {
97        Box::pin(SettingsFn::call(self, conn))
98    }
99}
100
101impl Debug for HtmlRewriter {
102    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
103        f.debug_struct("HtmlRewriter").finish()
104    }
105}
106
107impl Handler for HtmlRewriter {
108    async fn before_send(&self, mut conn: Conn) -> Conn {
109        let html = conn
110            .response_headers()
111            .get_str(ContentType)
112            .and_then(|c| Mime::from_str(c).ok())
113            .map(|m| m.subtype() == "html")
114            .unwrap_or_default();
115
116        if html && let Some(body) = conn.take_response_body() {
117            let settings = self.settings.call(&conn).await;
118            let reader = rewrite(body, settings);
119            conn.response_headers_mut().remove(ContentLength); // we no longer know the content length, if we ever did
120            conn.with_body(Body::new_streaming(reader, None))
121        } else {
122            conn
123        }
124    }
125}
126
127impl HtmlRewriter {
128    /// Construct a new html rewriter that applies the same rewrite to every response.
129    ///
130    /// A function — rather than a `Settings` value — is required because `lol-html`'s content
131    /// handlers are single-use; it is invoked once per rewritten response to produce a fresh set
132    /// of handlers. Build the settings with [`Settings::new_send()`] as the base (its handlers are
133    /// `Send`, as required here) and populate `element_content_handlers` /
134    /// `document_content_handlers`. See [`lol_async::html::Settings`] and the
135    /// [`lol-html`](https://docs.rs/lol-html) docs for the full rewriting API.
136    ///
137    /// ```
138    /// # use trillium_html_rewriter::{HtmlRewriter, Settings, html::{element, html_content::ContentType}};
139    /// HtmlRewriter::new(|| {
140    ///     Settings::new_send().append_element_content_handler(element!("body", |el| {
141    ///         el.prepend(r#"<script src="/analytics.js"></script>"#, ContentType::Html);
142    ///         Ok(())
143    ///     }))
144    /// });
145    /// ```
146    ///
147    /// To vary the rewrite based on the request or response, see
148    /// [`new_with_conn`](Self::new_with_conn); to await async work while building the settings,
149    /// see [`new_async`](Self::new_async).
150    pub fn new(f: impl Fn() -> Settings<'static, 'static> + Send + Sync + 'static) -> Self {
151        Self {
152            settings: Arc::new(move |_: &Conn| ready(f())),
153        }
154    }
155
156    /// Construct a new html rewriter from a function that builds [`Settings`] for a given
157    /// [`Conn`].
158    ///
159    /// The function receives the conn whose response is about to be rewritten, so the rewrite can
160    /// depend on the request path, headers, or [state](Conn::state). Like all three constructors,
161    /// the function is invoked once per rewritten response — see [`new`](Self::new) for why a
162    /// function is required and how to build the settings — and it only runs for responses that
163    /// are actually rewritten, so no work is done for non-html responses.
164    ///
165    /// Data read from the conn must be *moved* into the content handlers, which outlive the conn
166    /// borrow:
167    ///
168    /// ```
169    /// # use trillium_html_rewriter::{HtmlRewriter, Settings, html::{element, html_content::ContentType}};
170    /// HtmlRewriter::new_with_conn(|conn| {
171    ///     let path = conn.path().to_string();
172    ///     Settings::new_send().append_element_content_handler(element!("head", move |el| {
173    ///         el.prepend(&format!(r#"<link rel="canonical" href="{path}">"#), ContentType::Html);
174    ///         Ok(())
175    ///     }))
176    /// });
177    /// ```
178    ///
179    /// To await async work while building the settings, see [`new_async`](Self::new_async).
180    pub fn new_with_conn(
181        f: impl Fn(&Conn) -> Settings<'static, 'static> + Send + Sync + 'static,
182    ) -> Self {
183        Self {
184            settings: Arc::new(move |conn: &Conn| ready(f(conn))),
185        }
186    }
187
188    /// Construct a new html rewriter from an async function that builds [`Settings`] for a given
189    /// [`Conn`].
190    ///
191    /// Like [`new_with_conn`](Self::new_with_conn), the function receives the conn whose response
192    /// is about to be rewritten; because it is async, it can also await while borrowing the conn,
193    /// so the settings can incorporate the result of async work such as a database query or an
194    /// http request. Like all three constructors, the function is invoked once per rewritten
195    /// response — see [`new`](Self::new) for why a function is required and how to build the
196    /// settings.
197    ///
198    /// # Supported forms
199    ///
200    /// The parameter type annotation `: &Conn` is required on closures — inference cannot supply
201    /// it through the higher-ranked [`SettingsFn`] bound.
202    ///
203    /// An async closure (or equivalently a named `async fn(&Conn) -> Settings<'static, 'static>`)
204    /// may borrow the conn across `.await`, but must not capture its environment:
205    ///
206    /// ```
207    /// # use trillium_html_rewriter::{HtmlRewriter, Settings, html::{element, html_content::ContentType}};
208    /// # use trillium::Conn;
209    /// # async fn canonical_url(path: &str) -> String { format!("https://example.com{path}") }
210    /// HtmlRewriter::new_async(async |conn: &Conn| {
211    ///     let url = canonical_url(conn.path()).await;
212    ///     Settings::new_send().append_element_content_handler(element!("head", move |el| {
213    ///         el.prepend(&format!(r#"<link rel="canonical" href="{url}">"#), ContentType::Html);
214    ///         Ok(())
215    ///     }))
216    /// });
217    /// ```
218    ///
219    /// To use captured state (a client handle, configuration, …), write a plain closure that
220    /// clones what it needs — from its environment and from the conn — into an async block it
221    /// returns. In this form the future cannot borrow the conn:
222    ///
223    /// ```
224    /// # use trillium_html_rewriter::{HtmlRewriter, Settings, html::{element, html_content::ContentType}};
225    /// # use trillium::Conn;
226    /// # #[derive(Clone)] struct Client;
227    /// # impl Client { async fn fetch_banner(&self, path: String) -> String { path } }
228    /// # let client = Client;
229    /// HtmlRewriter::new_async(move |conn: &Conn| {
230    ///     let client = client.clone();
231    ///     let path = conn.path().to_string();
232    ///     async move {
233    ///         let banner = client.fetch_banner(path).await;
234    ///         Settings::new_send().append_element_content_handler(element!("body", move |el| {
235    ///             el.prepend(&banner, ContentType::Html);
236    ///             Ok(())
237    ///         }))
238    ///     }
239    /// });
240    /// ```
241    ///
242    /// Note that in either form, data destined for the content handlers must be *moved* into
243    /// them, as they outlive the settings-building future.
244    pub fn new_async<F>(f: F) -> Self
245    where
246        F: for<'a> SettingsFn<'a>,
247    {
248        Self {
249            settings: Arc::new(f),
250        }
251    }
252}