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
12pub const LIVE_RELOAD_PATH: &str = "/__mini_static_reload";
15
16#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum ChangeType {
22 Css,
24 Script,
26 Html,
28 Other,
30}
31
32impl ChangeType {
33 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 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
54pub 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
76pub 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
107fn 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
135pub(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
154pub(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 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 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}