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