1use std::collections::HashMap;
12
13use facet::Facet;
14use hotmeal::StrTendril;
15
16#[cfg(feature = "tracing")]
17use tracing::debug;
18
19#[cfg(not(feature = "tracing"))]
20macro_rules! debug {
21 ($($tt:tt)*) => {};
22}
23
24#[cfg(feature = "vox")]
33#[vox::service]
34pub trait LiveReloadBrowser {
35 async fn on_event(&self, event: LiveReloadEvent);
37}
38
39#[cfg(feature = "vox")]
44#[vox::service]
45pub trait LiveReloadService {
46 async fn subscribe(&self, route: String);
48}
49
50#[derive(Debug, Clone, Facet)]
59#[repr(u8)]
60pub enum LiveReloadEvent {
61 Reload,
63 Patches {
65 route: String,
66 patches_blob: Vec<u8>,
67 },
68 HeadChanged { route: String },
70}
71
72impl LiveReloadEvent {
73 pub fn to_postcard(&self) -> Vec<u8> {
75 facet_postcard::to_vec(self).expect("LiveReloadEvent serialization should not fail")
76 }
77
78 pub fn from_postcard(bytes: &[u8]) -> Result<Self, facet_postcard::DeserializeError> {
80 facet_postcard::from_slice(bytes)
81 }
82}
83
84pub struct LiveReloadServer {
91 html_cache: HashMap<String, String>,
93 head_cache: HashMap<String, String>,
95}
96
97impl LiveReloadServer {
98 pub fn new() -> Self {
99 Self {
100 html_cache: HashMap::new(),
101 head_cache: HashMap::new(),
102 }
103 }
104
105 pub fn cache_html(&mut self, route: &str, html: &str) -> Option<String> {
107 self.html_cache.insert(route.to_owned(), html.to_owned())
108 }
109
110 pub fn cache_head_injections(&mut self, route: &str, injections: &str) -> bool {
112 let prev = self
113 .head_cache
114 .insert(route.to_owned(), injections.to_owned());
115 match prev {
116 Some(ref old) => old != injections,
117 None => !injections.is_empty(),
118 }
119 }
120
121 pub fn diff_route(&mut self, route: &str, new_html: &str) -> Option<LiveReloadEvent> {
123 let old_html = match self.html_cache.get(route) {
124 Some(old) => old.clone(),
125 None => {
126 debug!(
127 route,
128 "no cached HTML for route, caching and returning Reload"
129 );
130 self.html_cache
131 .insert(route.to_owned(), new_html.to_owned());
132 return Some(LiveReloadEvent::Reload);
133 }
134 };
135
136 if old_html == new_html {
137 return None;
138 }
139
140 let old_tendril = StrTendril::from(old_html.as_str());
141 let new_tendril = StrTendril::from(new_html);
142
143 match hotmeal::diff_html(&old_tendril, &new_tendril) {
144 Ok(patches) => {
145 if patches.is_empty() {
146 self.html_cache
148 .insert(route.to_owned(), new_html.to_owned());
149 return None;
150 }
151
152 let owned_patches: Vec<hotmeal::Patch<'static>> =
153 patches.into_iter().map(|p| p.into_owned()).collect();
154
155 let patches_blob = facet_postcard::to_vec(&owned_patches)
156 .expect("patch serialization should not fail");
157
158 debug!(
159 route,
160 num_patches = owned_patches.len(),
161 blob_size = patches_blob.len(),
162 "diff produced patches"
163 );
164
165 self.html_cache
167 .insert(route.to_owned(), new_html.to_owned());
168
169 Some(LiveReloadEvent::Patches {
170 route: route.to_owned(),
171 patches_blob,
172 })
173 }
174 Err(_e) => {
175 debug!(route, error = %_e, "diff failed, sending Reload");
176 self.html_cache
177 .insert(route.to_owned(), new_html.to_owned());
178 Some(LiveReloadEvent::Reload)
179 }
180 }
181 }
182
183 pub fn diff_route_with_head(
188 &mut self,
189 route: &str,
190 new_html: &str,
191 head_injections: &str,
192 ) -> Option<LiveReloadEvent> {
193 if self.cache_head_injections(route, head_injections) {
194 self.html_cache
196 .insert(route.to_owned(), new_html.to_owned());
197 return Some(LiveReloadEvent::HeadChanged {
198 route: route.to_owned(),
199 });
200 }
201
202 self.diff_route(route, new_html)
203 }
204
205 pub fn cached_routes(&self) -> Vec<String> {
207 self.html_cache.keys().cloned().collect()
208 }
209
210 pub fn remove_route(&mut self, route: &str) -> bool {
212 let html_removed = self.html_cache.remove(route).is_some();
213 let head_removed = self.head_cache.remove(route).is_some();
214 html_removed || head_removed
215 }
216
217 pub fn clear(&mut self) {
219 self.html_cache.clear();
220 self.head_cache.clear();
221 }
222}
223
224impl Default for LiveReloadServer {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230pub fn inject_into_head(html: &str, content: &str) -> String {
235 let lower = html.to_ascii_lowercase();
237 if let Some(head_start) = lower.find("<head") {
238 if let Some(head_end) = html[head_start..].find('>') {
240 let insert_pos = head_start + head_end + 1;
241 let mut result = String::with_capacity(html.len() + content.len());
242 result.push_str(&html[..insert_pos]);
243 result.push_str(content);
244 result.push_str(&html[insert_pos..]);
245 return result;
246 }
247 }
248
249 let mut result = String::with_capacity(html.len() + content.len());
251 result.push_str(content);
252 result.push_str(html);
253 result
254}
255
256pub fn loader_script(
264 wasm_js_url: &str,
265 wasm_url: &str,
266 mount_selector: &str,
267 ws_url: &str,
268) -> String {
269 format!(
270 r#"<script type="module">
271import init, {{ start_live_reload }} from "{wasm_js_url}";
272await init("{wasm_url}");
273start_live_reload("{ws_url}", "{mount_selector}");
274</script>"#,
275 )
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn cache_html_roundtrip() {
284 let mut server = LiveReloadServer::new();
285 assert!(server.cache_html("/", "<p>hello</p>").is_none());
286 assert_eq!(
287 server.cache_html("/", "<p>world</p>"),
288 Some("<p>hello</p>".to_owned())
289 );
290 }
291
292 #[test]
293 fn diff_route_no_cache_returns_reload() {
294 let mut server = LiveReloadServer::new();
295 let event = server.diff_route("/new", "<p>hello</p>");
296 assert!(matches!(event, Some(LiveReloadEvent::Reload)));
297 }
298
299 #[test]
300 fn diff_route_unchanged_returns_none() {
301 let mut server = LiveReloadServer::new();
302 let html = "<p>hello</p>";
303 server.cache_html("/", html);
304 assert!(server.diff_route("/", html).is_none());
305 }
306
307 #[test]
308 fn diff_route_produces_patches() {
309 let mut server = LiveReloadServer::new();
310 server.cache_html("/", "<p>hello</p>");
311 let event = server.diff_route("/", "<p>world</p>");
312 match event {
313 Some(LiveReloadEvent::Patches {
314 route,
315 patches_blob,
316 }) => {
317 assert_eq!(route, "/");
318 assert!(!patches_blob.is_empty());
319
320 let patches: Vec<hotmeal::Patch<'static>> =
322 facet_postcard::from_slice(&patches_blob).expect("should deserialize patches");
323 assert!(!patches.is_empty());
324 }
325 other => panic!("expected Patches, got {other:?}"),
326 }
327 }
328
329 #[test]
330 fn diff_route_with_head_detects_head_change() {
331 let mut server = LiveReloadServer::new();
332 server.cache_html("/", "<p>hello</p>");
333 server.cache_head_injections("/", "<link rel=\"stylesheet\" href=\"a.css\">");
334
335 let event = server.diff_route_with_head(
336 "/",
337 "<p>hello</p>",
338 "<link rel=\"stylesheet\" href=\"b.css\">",
339 );
340 assert!(matches!(
341 event,
342 Some(LiveReloadEvent::HeadChanged { route }) if route == "/"
343 ));
344 }
345
346 #[test]
347 fn diff_route_with_head_unchanged_diffs_body() {
348 let mut server = LiveReloadServer::new();
349 let head = "<link rel=\"stylesheet\" href=\"a.css\">";
350 server.cache_html("/", "<p>hello</p>");
351 server.cache_head_injections("/", head);
352
353 let event = server.diff_route_with_head("/", "<p>world</p>", head);
355 assert!(matches!(event, Some(LiveReloadEvent::Patches { .. })));
356 }
357
358 #[test]
359 fn remove_route_works() {
360 let mut server = LiveReloadServer::new();
361 server.cache_html("/a", "<p>a</p>");
362 server.cache_head_injections("/a", "head-a");
363 assert!(server.remove_route("/a"));
364 assert!(!server.remove_route("/a"));
365 assert!(server.cached_routes().is_empty());
366 }
367
368 #[test]
369 fn clear_removes_everything() {
370 let mut server = LiveReloadServer::new();
371 server.cache_html("/a", "a");
372 server.cache_html("/b", "b");
373 server.clear();
374 assert!(server.cached_routes().is_empty());
375 }
376
377 #[test]
378 fn inject_into_head_basic() {
379 let html = "<html><head><title>Test</title></head><body></body></html>";
380 let result = inject_into_head(html, "<link rel=\"stylesheet\">");
381 assert_eq!(
382 result,
383 "<html><head><link rel=\"stylesheet\"><title>Test</title></head><body></body></html>"
384 );
385 }
386
387 #[test]
388 fn inject_into_head_no_head_tag() {
389 let html = "<html><body>hi</body></html>";
390 let result = inject_into_head(html, "<style>body{}</style>");
391 assert_eq!(result, "<style>body{}</style><html><body>hi</body></html>");
392 }
393
394 #[test]
395 fn loader_script_output() {
396 let script = loader_script(
397 "/wasm.js",
398 "/wasm.wasm",
399 "#content",
400 "ws://localhost:3000/_lr",
401 );
402 assert!(script.contains("start_live_reload"));
403 assert!(script.contains("#content"));
404 assert!(script.contains("ws://localhost:3000/_lr"));
405 }
406
407 #[test]
408 fn event_postcard_roundtrip() {
409 let event = LiveReloadEvent::Patches {
410 route: "/test".to_owned(),
411 patches_blob: vec![1, 2, 3],
412 };
413 let bytes = event.to_postcard();
414 let decoded = LiveReloadEvent::from_postcard(&bytes).expect("should decode");
415 match decoded {
416 LiveReloadEvent::Patches {
417 route,
418 patches_blob,
419 } => {
420 assert_eq!(route, "/test");
421 assert_eq!(patches_blob, vec![1, 2, 3]);
422 }
423 other => panic!("expected Patches, got {other:?}"),
424 }
425
426 let reload_bytes = LiveReloadEvent::Reload.to_postcard();
428 let reload_decoded = LiveReloadEvent::from_postcard(&reload_bytes).expect("should decode");
429 assert!(matches!(reload_decoded, LiveReloadEvent::Reload));
430 }
431}