Skip to main content

hotmeal_server/
lib.rs

1//! Server-side live-reload infrastructure for hotmeal.
2//!
3//! Provides transport-agnostic HTML diffing and patch delivery. The core abstraction
4//! is `LiveReloadServer`, which caches HTML per route, diffs on change, and produces
5//! `LiveReloadEvent` messages that can be serialized and sent over any transport
6//! (WebSocket, SSE, etc.).
7//!
8//! The client side lives in `hotmeal-wasm` which deserializes events and applies
9//! patches to a mount point in the browser DOM.
10
11use 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// ============================================================================
25// RPC Service Definitions (requires "vox" feature)
26// ============================================================================
27
28/// Service implemented by the browser, called by the server to push events.
29///
30/// The server calls `on_event()` on connected browsers whenever content changes
31/// for a route they've subscribed to.
32#[cfg(feature = "vox")]
33#[vox::service]
34pub trait LiveReloadBrowser {
35    /// Called by the server when a live-reload event occurs.
36    async fn on_event(&self, event: LiveReloadEvent);
37}
38
39/// Service implemented by the server, called by the browser to subscribe.
40///
41/// After the browser calls `subscribe(route)`, the server will push
42/// `LiveReloadEvent`s via `LiveReloadBrowser::on_event()` on that connection.
43#[cfg(feature = "vox")]
44#[vox::service]
45pub trait LiveReloadService {
46    /// Subscribe to live-reload events for a route.
47    async fn subscribe(&self, route: String);
48}
49
50// ============================================================================
51// Event types
52// ============================================================================
53
54/// Events produced by the live-reload server.
55///
56/// These are serialized with postcard and sent to the browser client,
57/// which deserializes them in `hotmeal-wasm`.
58#[derive(Debug, Clone, Facet)]
59#[repr(u8)]
60pub enum LiveReloadEvent {
61    /// Full page reload needed.
62    Reload,
63    /// DOM patches for a route (postcard-serialized `Vec<Patch<'static>>`).
64    Patches {
65        route: String,
66        patches_blob: Vec<u8>,
67    },
68    /// Head injections changed — full reload required.
69    HeadChanged { route: String },
70}
71
72impl LiveReloadEvent {
73    /// Serialize this event to postcard bytes.
74    pub fn to_postcard(&self) -> Vec<u8> {
75        facet_postcard::to_vec(self).expect("LiveReloadEvent serialization should not fail")
76    }
77
78    /// Deserialize a `LiveReloadEvent` from postcard bytes.
79    pub fn from_postcard(bytes: &[u8]) -> Result<Self, facet_postcard::DeserializeError> {
80        facet_postcard::from_slice(bytes)
81    }
82}
83
84/// Server-side live-reload state.
85///
86/// Caches HTML and head injections per route, diffs new HTML against the cache,
87/// and produces `LiveReloadEvent` messages.
88///
89/// Transport-agnostic — callers are responsible for delivering events to clients.
90pub struct LiveReloadServer {
91    /// Cached HTML per route.
92    html_cache: HashMap<String, String>,
93    /// Cached head injections per route.
94    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    /// Cache HTML for a route (call when serving). Returns previous HTML if any.
106    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    /// Cache head injections. Returns true if they changed.
111    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    /// Diff new HTML against cache. Returns event to send, or None if unchanged.
122    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                    // Diff produced no patches — HTML is semantically identical
147                    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                // Update cache
166                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    /// Diff with head injection tracking combined.
184    ///
185    /// If head injections changed, returns `HeadChanged` (requires full reload).
186    /// Otherwise diffs body HTML and returns `Patches` or `None`.
187    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            // Head changed — must full reload, but still update HTML cache
195            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    /// All cached route keys.
206    pub fn cached_routes(&self) -> Vec<String> {
207        self.html_cache.keys().cloned().collect()
208    }
209
210    /// Remove a route from cache.
211    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    /// Clear all caches.
218    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
230/// Inject content after the opening `<head>` tag.
231///
232/// Searches for `<head>` (case-insensitive) and injects `content` immediately after it.
233/// If no `<head>` tag is found, prepends content at the start.
234pub fn inject_into_head(html: &str, content: &str) -> String {
235    // Find <head> or <head ...> tag (case-insensitive)
236    let lower = html.to_ascii_lowercase();
237    if let Some(head_start) = lower.find("<head") {
238        // Find the closing '>' of the <head> tag
239        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    // No <head> tag found — prepend
250    let mut result = String::with_capacity(html.len() + content.len());
251    result.push_str(content);
252    result.push_str(html);
253    result
254}
255
256/// Generate a `<script>` tag that loads hotmeal-wasm and starts live-reload.
257///
258/// Arguments:
259/// - `wasm_js_url`: URL to the generated `hotmeal_wasm.js` glue code
260/// - `wasm_url`: URL to the `.wasm` binary
261/// - `mount_selector`: CSS selector for the mount point element (e.g. `"body"` or `"#content"`)
262/// - `ws_url`: WebSocket URL for live-reload connection (e.g. `"ws://localhost:3000/_lr"`)
263pub 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                // Verify the blob deserializes
321                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        // Same head, different body
354        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        // Also test Reload variant
427        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}