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
13pub const LIVE_RELOAD_PATH: &str = "/__mini_static_reload";
16
17#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum ChangeType {
23 Css,
25 Script,
27 Html,
29 Other,
31}
32
33impl ChangeType {
34 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 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
55pub 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
77pub 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
108fn 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
136pub(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 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 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}