Skip to main content

rama_http/service/web/endpoint/response/
partial_updates.rs

1//! Streaming HTML response for [Chrome declarative partial updates].
2//!
3//! Flushes a shell (with `<?marker name="…">` placeholders) immediately,
4//! then emits one `<template for="name">…</template>` body chunk per
5//! fragment as each fragment future resolves — in completion order, not
6//! declaration order. Each chunk is followed by `\n<wbr>` so the [official
7//! polyfill] can swap every fragment at its own arrival rather than one
8//! step behind: it defers a swap while the template has no
9//! `nextElementSibling`, and `<wbr>` (an invisible HTMLElement) is the
10//! smallest thing that satisfies that check. Mirrors the spirit of
11//! [Google's photo-album demo].
12//!
13//! [Chrome declarative partial updates]: https://developer.chrome.com/blog/declarative-partial-updates
14//! [official polyfill]: https://github.com/GoogleChromeLabs/template-for-polyfill
15//! [Google's photo-album demo]: https://github.com/GoogleChromeLabs/web-perf-demos/blob/main/patching-demos/server.js
16
17use rama_core::bytes::Bytes;
18use rama_core::error::BoxError;
19use rama_core::futures::FutureExt;
20use rama_core::futures::future::BoxFuture;
21use rama_core::futures::stream::{self, StreamExt};
22use rama_http_headers::ContentType;
23use rama_http_types::{Body, Response};
24
25use crate::protocols::html::{IntoHtml, template};
26
27use super::{Headers, IntoResponse};
28
29/// A streaming HTML response that fills `<?marker name="…">` placeholders
30/// out-of-order as fragment futures complete.
31///
32/// Pair the shell with [`crate::protocols::html::marker`] to emit the processing
33/// instructions; this response wraps each fragment's rendered HTML in a
34/// `<template for="name">…</template>` block as it resolves.
35#[must_use]
36pub struct PartialUpdates<H> {
37    shell: H,
38    fragments: Vec<Fragment>,
39}
40
41/// Type-erased renderer: a closure that writes the rendered HTML of a
42/// resolved fragment into a target buffer. Implements [`IntoHtml`] (via
43/// the blanket impl for `FnOnce(&mut String)`), so it slots straight into
44/// the `template!` macro at chunk-emit time — no early `into_string()`,
45/// no manual escaping.
46type FragmentRender = Box<dyn FnOnce(&mut String) + Send + 'static>;
47
48struct Fragment {
49    name: &'static str,
50    future: BoxFuture<'static, Result<FragmentRender, BoxError>>,
51}
52
53impl<H: IntoHtml> PartialUpdates<H> {
54    /// Wrap an HTML `shell` for streaming.
55    pub fn new(shell: H) -> Self {
56        Self {
57            shell,
58            fragments: Vec::new(),
59        }
60    }
61
62    /// Add a fragment whose rendered HTML will be flushed inside a
63    /// `<template for="name">…</template>` block once `fut` resolves.
64    ///
65    /// Whatever the future yields is rendered through [`IntoHtml`] — i.e.
66    /// scalar strings are HTML-escaped, [`PreEscaped`] passes through verbatim,
67    /// and macro-built nodes compose normally.
68    ///
69    /// [`PreEscaped`]: crate::protocols::html::PreEscaped
70    pub fn fragment<F, T>(mut self, name: &'static str, fut: F) -> Self
71    where
72        F: Future<Output = T> + Send + 'static,
73        T: IntoHtml + Send + 'static,
74    {
75        self.fragments.push(Fragment {
76            name,
77            future: async move {
78                let value = fut.await;
79                let render: FragmentRender = Box::new(move |buf| value.escape_and_write(buf));
80                Ok(render)
81            }
82            .boxed(),
83        });
84        self
85    }
86
87    /// Like [`Self::fragment`] but the future returns a `Result`; on `Err`
88    /// the body stream terminates with that error.
89    pub fn try_fragment<F, T, E>(mut self, name: &'static str, fut: F) -> Self
90    where
91        F: Future<Output = Result<T, E>> + Send + 'static,
92        T: IntoHtml + Send + 'static,
93        E: Into<BoxError> + Send + 'static,
94    {
95        self.fragments.push(Fragment {
96            name,
97            future: async move {
98                let value = fut.await.map_err(Into::into)?;
99                let render: FragmentRender = Box::new(move |buf| value.escape_and_write(buf));
100                Ok(render)
101            }
102            .boxed(),
103        });
104        self
105    }
106}
107
108impl<H> IntoResponse for PartialUpdates<H>
109where
110    H: IntoHtml + Send + 'static,
111{
112    fn into_response(self) -> Response {
113        let shell = self.shell.into_string();
114        let shell_chunk = stream::once(async move { Ok::<_, BoxError>(Bytes::from(shell)) });
115
116        let frag_count = self.fragments.len().max(1);
117        let fragment_chunks = stream::iter(self.fragments)
118            .map(|Fragment { name, future }| async move {
119                let render = future.await?;
120                let mut s = template!(r#for = name, render).into_string();
121                s.push_str("\n<wbr>");
122                Ok::<_, BoxError>(Bytes::from(s))
123            })
124            .buffer_unordered(frag_count);
125
126        (
127            Headers::single(ContentType::html_utf8()),
128            Body::from_stream(shell_chunk.chain(fragment_chunks)),
129        )
130            .into_response()
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::protocols::html::{html, marker, p};
138    use std::time::Duration;
139    use tokio::time::{Instant, sleep};
140
141    #[tokio::test(start_paused = true)]
142    async fn shell_arrives_before_slowest_fragment() {
143        let shell = html!(p!(marker("slow")));
144        let res = PartialUpdates::new(shell)
145            .fragment("slow", async {
146                sleep(Duration::from_millis(500)).await;
147                "ok"
148            })
149            .into_response();
150
151        let mut body = res.into_body();
152        let t0 = Instant::now();
153        let first = body.chunk().await.unwrap().unwrap();
154        let t_first = t0.elapsed();
155
156        assert!(
157            t_first < Duration::from_millis(50),
158            "shell should flush immediately, got {t_first:?}"
159        );
160        let first = std::str::from_utf8(&first).unwrap();
161        assert!(first.contains(r#"<?marker name="slow">"#));
162        assert!(!first.contains("<template for="));
163
164        let second = body.chunk().await.unwrap().unwrap();
165        let t_second = t0.elapsed();
166        assert!(
167            t_second >= Duration::from_millis(500),
168            "fragment should wait for its delay, got {t_second:?}"
169        );
170        assert_eq!(
171            std::str::from_utf8(&second).unwrap(),
172            "<template for=\"slow\">ok</template>\n<wbr>"
173        );
174
175        assert!(body.chunk().await.unwrap().is_none());
176    }
177
178    #[tokio::test(start_paused = true)]
179    async fn fragment_macro_node_keeps_tags() {
180        // A macro-built node must NOT have its element tags escaped — only
181        // the dynamic string content inside should be escaped.
182        let shell = html!(p!(marker("x")));
183        let res = PartialUpdates::new(shell)
184            .fragment("x", async { p!("<not-a-tag>") })
185            .into_response();
186        let mut body = res.into_body();
187        let _shell = body.chunk().await.unwrap().unwrap();
188        let tpl = body.chunk().await.unwrap().unwrap();
189        assert_eq!(
190            std::str::from_utf8(&tpl).unwrap(),
191            "<template for=\"x\"><p>&lt;not-a-tag&gt;</p></template>\n<wbr>"
192        );
193    }
194
195    #[tokio::test(start_paused = true)]
196    async fn fragment_name_is_html_escaped() {
197        let shell = html!(p!(marker("a<b")));
198        let res = PartialUpdates::new(shell)
199            .fragment("a<b", async { "ok" })
200            .into_response();
201        let mut body = res.into_body();
202        let _shell = body.chunk().await.unwrap().unwrap();
203        let tpl = body.chunk().await.unwrap().unwrap();
204        assert_eq!(
205            std::str::from_utf8(&tpl).unwrap(),
206            "<template for=\"a&lt;b\">ok</template>\n<wbr>"
207        );
208    }
209
210    #[tokio::test(start_paused = true)]
211    async fn fragments_stream_in_completion_order() {
212        let shell = html!(p!(marker("a"), marker("b"), marker("c")));
213        let res = PartialUpdates::new(shell)
214            .fragment("a", async {
215                sleep(Duration::from_millis(600)).await;
216                "A"
217            })
218            .fragment("b", async {
219                sleep(Duration::from_millis(100)).await;
220                "B"
221            })
222            .fragment("c", async {
223                sleep(Duration::from_millis(300)).await;
224                "C"
225            })
226            .into_response();
227
228        let mut body = res.into_body();
229        let t0 = Instant::now();
230
231        let mut chunks: Vec<(Duration, String)> = Vec::new();
232        while let Some(chunk) = body.chunk().await.unwrap() {
233            chunks.push((
234                t0.elapsed(),
235                String::from_utf8(chunk.to_vec()).expect("utf8"),
236            ));
237        }
238
239        assert_eq!(chunks.len(), 4, "shell + 3 fragments");
240        assert!(chunks[0].1.contains(r#"<?marker name="a">"#));
241        assert_eq!(chunks[1].1, "<template for=\"b\">B</template>\n<wbr>");
242        assert_eq!(chunks[2].1, "<template for=\"c\">C</template>\n<wbr>");
243        assert_eq!(chunks[3].1, "<template for=\"a\">A</template>\n<wbr>");
244
245        let spread = chunks[3].0.checked_sub(chunks[1].0).unwrap();
246        assert!(
247            spread >= Duration::from_millis(400),
248            "fragment chunks must arrive spread out over time, got {spread:?}"
249        );
250    }
251}