Skip to main content

mini_static/
reload.rs

1use std::path::Path;
2use std::pin::Pin;
3use std::task::{Context, Poll};
4
5use bytes::Bytes;
6use http_body::{Body, Frame};
7use tokio::sync::mpsc::UnboundedReceiver;
8
9use crate::error::StaticError;
10use crate::watcher::ChangeEvent;
11
12/// The request path `Server` serves the live-reload SSE stream on when
13/// [`crate::Server::with_live_reload`] is enabled.
14pub const LIVE_RELOAD_PATH: &str = "/__mini_static_reload";
15
16/// The type of change detected in a watched file.
17///
18/// Used by live-reload to determine what the browser should do when a file changes:
19/// CSS stylesheets are hot-swapped, while scripts and HTML require a full page reload.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum ChangeType {
22    /// A CSS stylesheet changed.
23    Css,
24    /// A JavaScript module changed.
25    Script,
26    /// An HTML page changed.
27    Html,
28    /// Some other file changed.
29    Other,
30}
31
32impl ChangeType {
33    /// Determine the change type from a file path's extension.
34    pub fn from_path(path: &Path) -> Self {
35        match path.extension().and_then(|e| e.to_str()) {
36            Some("css") => ChangeType::Css,
37            Some("js" | "mjs") => ChangeType::Script,
38            Some("html" | "htm") => ChangeType::Html,
39            _ => ChangeType::Other,
40        }
41    }
42
43    /// The string representation used in SSE event names and JSON.
44    pub fn as_str(&self) -> &'static str {
45        match self {
46            ChangeType::Css => "css",
47            ChangeType::Script => "script",
48            ChangeType::Html => "html",
49            ChangeType::Other => "other",
50        }
51    }
52}
53
54/// Encode a reload event as an SSE (Server-Sent Events) frame.
55///
56/// The frame follows the SSE text/event-stream format:
57/// ```text
58/// event: <change_type>
59/// data: {"type": "<change_type>"}
60///
61/// ```
62///
63/// This is the single canonical place the SSE frame format is defined.
64/// Callers that forward reload events over HTTP (e.g., mini-unified) call this
65/// function rather than re-implementing the byte-for-byte format.
66///
67/// The JSON payload is written directly rather than through a serializer: it is one
68/// fixed-shape object whose only value is one of [`ChangeType::as_str`]'s four literals,
69/// none of which contain a character JSON would need to escape. Pulling in a JSON
70/// dependency to emit eleven constant bytes is not a trade worth making.
71pub fn reload_event_frame(change_type: &ChangeType) -> Bytes {
72    let name = change_type.as_str();
73    Bytes::from(format!("event: {name}\ndata: {{\"type\":\"{name}\"}}\n\n"))
74}
75
76/// An `http_body::Body` that streams live-reload SSE frames to a single connected
77/// client, one [`ChangeEvent`] at a time, for as long as the underlying broadcast
78/// channel stays open.
79pub struct SseBody {
80    rx: UnboundedReceiver<ChangeEvent>,
81}
82
83impl SseBody {
84    pub(crate) fn new(rx: UnboundedReceiver<ChangeEvent>) -> Self {
85        SseBody { rx }
86    }
87}
88
89impl Body for SseBody {
90    type Data = Bytes;
91    type Error = StaticError;
92
93    fn poll_frame(
94        mut self: Pin<&mut Self>,
95        cx: &mut Context<'_>,
96    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
97        match self.rx.poll_recv(cx) {
98            Poll::Ready(Some(event)) => Poll::Ready(Some(Ok(Frame::data(reload_event_frame(
99                &event.change_type,
100            ))))),
101            Poll::Ready(None) => Poll::Ready(None),
102            Poll::Pending => Poll::Pending,
103        }
104    }
105}
106
107/// The `<script>` tag `Server` injects into served HTML pages when live-reload is
108/// enabled (see [`inject_reload_script`]).
109///
110/// Opens an `EventSource` to [`LIVE_RELOAD_PATH`]. A `css` event hot-swaps every
111/// stylesheet `<link>` (cache-busted via a query param) without a full page reload;
112/// `script`, `html`, and `other` events reload the page, since there is no general way
113/// to hot-swap those in place.
114///
115/// # Panics
116///
117/// Never — the returned string is a fixed literal embedding [`LIVE_RELOAD_PATH`].
118fn reload_script_tag() -> String {
119    format!(
120        "<script>(function(){{\
121			var es=new EventSource(\"{LIVE_RELOAD_PATH}\");\
122			function reload(){{location.reload();}}\
123			es.addEventListener(\"css\",function(){{\
124				document.querySelectorAll('link[rel=\"stylesheet\"]').forEach(function(l){{\
125					var u=new URL(l.href);u.searchParams.set(\"_mr\",Date.now());l.href=u.toString();\
126				}});\
127			}});\
128			es.addEventListener(\"script\",reload);\
129			es.addEventListener(\"html\",reload);\
130			es.addEventListener(\"other\",reload);\
131		}})();</script>"
132    )
133}
134
135/// Insert the live-reload client script (see [`reload_script_tag`]) into an HTML
136/// document, immediately before the closing `</body>` tag if one is found (checking
137/// both `</body>` and `</BODY>`), otherwise appended at the end of the document.
138///
139/// Operates on raw bytes rather than parsing HTML — `mini-static` has no HTML parser
140/// and does not need one for a single fixed-string insertion.
141pub(crate) fn inject_reload_script(html: &mut Vec<u8>) {
142    let script = reload_script_tag();
143
144    let pos = find_subsequence(html, b"</body>").or_else(|| find_subsequence(html, b"</BODY>"));
145
146    match pos {
147        Some(pos) => {
148            html.splice(pos..pos, script.into_bytes());
149        }
150        None => html.extend_from_slice(script.as_bytes()),
151    }
152}
153
154/// Find the first occurrence of `needle` in `haystack`, if any.
155///
156/// Shared byte-search helper: both the live-reload and spa-mode script
157/// injectors splice a fixed `<script>` immediately before `</body>`, and
158/// both need this same substring scan to find it.
159pub(crate) fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
160    haystack.windows(needle.len()).position(|w| w == needle)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn reload_event_frame_formats_css_correctly() {
169        let frame = reload_event_frame(&ChangeType::Css);
170        let s = String::from_utf8(frame.to_vec()).unwrap();
171        assert_eq!(s, "event: css\ndata: {\"type\":\"css\"}\n\n");
172    }
173
174    #[test]
175    fn reload_event_frame_formats_script_correctly() {
176        let frame = reload_event_frame(&ChangeType::Script);
177        let s = String::from_utf8(frame.to_vec()).unwrap();
178        assert_eq!(s, "event: script\ndata: {\"type\":\"script\"}\n\n");
179    }
180
181    #[test]
182    fn reload_event_frame_formats_html_correctly() {
183        let frame = reload_event_frame(&ChangeType::Html);
184        let s = String::from_utf8(frame.to_vec()).unwrap();
185        assert_eq!(s, "event: html\ndata: {\"type\":\"html\"}\n\n");
186    }
187
188    #[test]
189    fn reload_event_frame_formats_other_correctly() {
190        let frame = reload_event_frame(&ChangeType::Other);
191        let s = String::from_utf8(frame.to_vec()).unwrap();
192        assert_eq!(s, "event: other\ndata: {\"type\":\"other\"}\n\n");
193    }
194
195    #[test]
196    fn change_type_from_path_css() {
197        assert_eq!(
198            ChangeType::from_path(Path::new("style.css")),
199            ChangeType::Css
200        );
201        assert_eq!(
202            ChangeType::from_path(Path::new("dir/main.css")),
203            ChangeType::Css
204        );
205    }
206
207    #[test]
208    fn change_type_from_path_script() {
209        assert_eq!(
210            ChangeType::from_path(Path::new("app.js")),
211            ChangeType::Script
212        );
213        assert_eq!(
214            ChangeType::from_path(Path::new("mod.mjs")),
215            ChangeType::Script
216        );
217        assert_eq!(
218            ChangeType::from_path(Path::new("dir/lib.js")),
219            ChangeType::Script
220        );
221    }
222
223    #[test]
224    fn change_type_from_path_html() {
225        assert_eq!(
226            ChangeType::from_path(Path::new("index.html")),
227            ChangeType::Html
228        );
229        assert_eq!(
230            ChangeType::from_path(Path::new("page.htm")),
231            ChangeType::Html
232        );
233        assert_eq!(
234            ChangeType::from_path(Path::new("dir/file.html")),
235            ChangeType::Html
236        );
237    }
238
239    #[test]
240    fn change_type_from_path_other() {
241        assert_eq!(
242            ChangeType::from_path(Path::new("image.png")),
243            ChangeType::Other
244        );
245        assert_eq!(
246            ChangeType::from_path(Path::new("data.json")),
247            ChangeType::Other
248        );
249        assert_eq!(
250            ChangeType::from_path(Path::new("README")),
251            ChangeType::Other
252        );
253    }
254
255    #[test]
256    fn reload_script_tag_embeds_the_live_reload_path() {
257        // Disproves hardcoding a divergent path literal in the script: if someone edits
258        // `LIVE_RELOAD_PATH` without updating `reload_script_tag()`, the injected client
259        // would open an `EventSource` to a path the server never serves.
260        assert!(reload_script_tag().contains(LIVE_RELOAD_PATH));
261    }
262
263    #[test]
264    fn inject_reload_script_inserts_before_closing_body_tag() {
265        let mut html = b"<html><body><h1>hi</h1></body></html>".to_vec();
266        inject_reload_script(&mut html);
267        let s = String::from_utf8(html).unwrap();
268
269        assert!(s.starts_with("<html><body><h1>hi</h1>"));
270        assert!(s.ends_with("</body></html>"));
271        assert!(s.contains(LIVE_RELOAD_PATH));
272        // The script must land strictly before the closing tag, not after it.
273        assert!(s.find("<script>").unwrap() < s.find("</body>").unwrap());
274    }
275
276    #[test]
277    fn inject_reload_script_handles_uppercase_closing_tag() {
278        let mut html = b"<HTML><BODY>hi</BODY></HTML>".to_vec();
279        inject_reload_script(&mut html);
280        let s = String::from_utf8(html).unwrap();
281
282        assert!(s.find("<script>").unwrap() < s.find("</BODY>").unwrap());
283    }
284
285    #[test]
286    fn inject_reload_script_appends_when_no_body_tag_present() {
287        let mut html = b"<h1>fragment, no body tag</h1>".to_vec();
288        inject_reload_script(&mut html);
289        let s = String::from_utf8(html).unwrap();
290
291        assert!(s.starts_with("<h1>fragment, no body tag</h1>"));
292        assert!(s.ends_with("</script>"));
293    }
294
295    #[tokio::test]
296    async fn sse_body_yields_a_frame_per_broadcast_event() {
297        use crate::watcher::Broadcaster;
298        use http_body_util::BodyExt;
299        use std::path::PathBuf;
300
301        let broadcaster = Broadcaster::new();
302        let rx = broadcaster.subscribe();
303        let mut body = SseBody::new(rx);
304
305        broadcaster.broadcast(ChangeEvent {
306            path: PathBuf::from("style.css"),
307            change_type: ChangeType::Css,
308        });
309
310        let frame = body
311            .frame()
312            .await
313            .expect("stream ended early")
314            .expect("frame error");
315        let data = frame.into_data().unwrap();
316        assert_eq!(&data[..], b"event: css\ndata: {\"type\":\"css\"}\n\n");
317    }
318}