Skip to main content

ssg/server/
hmr.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Hot-module-reload protocol for `ssg dev` (issue #526).
5//!
6//! Defines the WebSocket message format the dev server pushes to every
7//! connected browser tab and the [`HmrBroadcaster`] that fans messages
8//! out across N tabs.
9//!
10//! # Protocol (AC5)
11//!
12//! Each frame is a JSON object:
13//!
14//! ```json
15//! { "type": "hmr-css",  "paths": ["assets/style.css"],  "sha": "abc..." }
16//! { "type": "hmr-html", "paths": ["/blog/foo/"],         "sha": "def..." }
17//! { "type": "reload",   "paths": [],                     "sha": "" }
18//! ```
19//!
20//! * `type` — one of `hmr-css`, `hmr-html`, `reload`.
21//!   * `hmr-css`: client swaps `<link>` href in-place (no reload, scroll
22//!     preserved, no FOUC). AC2.
23//!   * `hmr-html`: client fetches each path and swaps the `<main>` body
24//!     (or `<body>` if no `<main>`) — scroll position preserved. AC3/AC4.
25//!   * `reload`: client does a full `location.reload()` — used for
26//!     `<head>` / config / build-error recovery.
27//! * `paths` — affected paths. For `hmr-html` these are the rebuilt
28//!   page URLs the dep graph reported invalidated.
29//! * `sha` — short content hash so the client can de-dupe redundant
30//!   frames from rapid saves. Optional; empty string is fine.
31//!
32//! Frames are pushed as Text WebSocket messages from
33//! [`HmrBroadcaster::broadcast`]; the JS client in
34//! `crate::livereload` parses them.
35//!
36//! # Architecture
37//!
38//! ```text
39//! caller ──▶ broadcast() ──▶ for each registered Sender:
40//!                              try_send(json) ──▶ tab thread ──▶ ws.send()
41//! ```
42//!
43//! Each connected tab owns a `Sender<String>`; the broadcaster holds the
44//! receiving side in a `Mutex<Vec<Sender>>`. Send failures evict the
45//! tab from the list — there is no explicit unregister API, dead tabs
46//! garbage-collect on the next broadcast.
47
48use std::sync::Mutex;
49
50use serde::{Deserialize, Serialize};
51
52/// Message types pushed to the browser HMR client.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum HmrType {
56    /// Stylesheet swap — no reload, no FOUC.
57    HmrCss,
58    /// Partial page replacement — swap `<main>` body, preserve scroll.
59    HmrHtml,
60    /// Full `location.reload()`.
61    Reload,
62}
63
64impl HmrType {
65    /// The wire string the JS client matches on (`msg.type`).
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use ssg::hmr::HmrType;
71    /// assert_eq!(HmrType::HmrCss.wire(), "hmr-css");
72    /// assert_eq!(HmrType::Reload.wire(), "reload");
73    /// ```
74    #[must_use]
75    pub const fn wire(self) -> &'static str {
76        match self {
77            Self::HmrCss => "hmr-css",
78            Self::HmrHtml => "hmr-html",
79            Self::Reload => "reload",
80        }
81    }
82}
83
84/// A single HMR frame.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct HmrMessage {
87    /// Frame type.
88    #[serde(rename = "type")]
89    pub kind: HmrType,
90    /// Paths affected. For `hmr-html` these are page URLs; for
91    /// `hmr-css` these are asset URLs; for `reload` this may be empty.
92    pub paths: Vec<String>,
93    /// Short content hash. Optional — empty string is fine.
94    #[serde(default)]
95    pub sha: String,
96}
97
98impl HmrMessage {
99    /// Build a CSS-swap frame for one or more stylesheet paths.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use ssg::hmr::{HmrMessage, HmrType};
105    /// let m = HmrMessage::css(vec!["x.css".into()]);
106    /// assert_eq!(m.kind, HmrType::HmrCss);
107    /// assert_eq!(m.paths, vec!["x.css"]);
108    /// ```
109    #[must_use]
110    pub const fn css(paths: Vec<String>) -> Self {
111        Self {
112            kind: HmrType::HmrCss,
113            paths,
114            sha: String::new(),
115        }
116    }
117
118    /// Build an HTML-partial frame for one or more page URLs.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use ssg::hmr::{HmrMessage, HmrType};
124    /// let m = HmrMessage::html(vec!["/a/".into()]);
125    /// assert_eq!(m.kind, HmrType::HmrHtml);
126    /// ```
127    #[must_use]
128    pub const fn html(paths: Vec<String>) -> Self {
129        Self {
130            kind: HmrType::HmrHtml,
131            paths,
132            sha: String::new(),
133        }
134    }
135
136    /// Build a full-page-reload frame.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use ssg::hmr::{HmrMessage, HmrType};
142    /// let m = HmrMessage::reload();
143    /// assert_eq!(m.kind, HmrType::Reload);
144    /// assert!(m.paths.is_empty());
145    /// ```
146    #[must_use]
147    pub const fn reload() -> Self {
148        Self {
149            kind: HmrType::Reload,
150            paths: Vec::new(),
151            sha: String::new(),
152        }
153    }
154
155    /// Attach a content hash for client-side de-duplication.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use ssg::hmr::HmrMessage;
161    /// let m = HmrMessage::reload().with_sha("abc123");
162    /// assert_eq!(m.sha, "abc123");
163    /// ```
164    #[must_use]
165    pub fn with_sha(mut self, sha: impl Into<String>) -> Self {
166        self.sha = sha.into();
167        self
168    }
169
170    /// Serialise to the JSON wire format. Infallible — the struct is
171    /// always serialisable.
172    ///
173    /// # Panics
174    ///
175    /// Only if `serde_json` cannot serialise a primitive `Vec<String>`,
176    /// which is impossible.
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use ssg::hmr::HmrMessage;
182    /// let m = HmrMessage::css(vec!["a.css".into()]);
183    /// let json = m.to_json();
184    /// assert!(json.contains("\"type\":\"hmr-css\""));
185    /// ```
186    #[must_use]
187    pub fn to_json(&self) -> String {
188        serde_json::to_string(self).unwrap_or_else(|_| {
189            // Defensive: serialise a static reload as a last-ditch frame.
190            r#"{"type":"reload","paths":[],"sha":""}"#.to_string()
191        })
192    }
193}
194
195/// Per-tab callback that emits one HMR frame.
196///
197/// The broadcaster keeps a vector of these and invokes each on every
198/// `broadcast()`. Callbacks return `Ok(())` on a successful WebSocket
199/// send and `Err(())` to be evicted (closed socket, write error).
200pub type HmrSink = Box<dyn Fn(&str) -> Result<(), ()> + Send + Sync>;
201
202/// Fan-out HMR sender shared across the dev server.
203///
204/// Construct one per `ssg dev` process. Each connected tab registers
205/// an [`HmrSink`] via [`Self::subscribe`]; [`Self::broadcast`] pushes
206/// the same frame to every live tab and evicts any sink that errored.
207#[derive(Default)]
208pub struct HmrBroadcaster {
209    sinks: Mutex<Vec<HmrSink>>,
210}
211
212impl std::fmt::Debug for HmrBroadcaster {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        f.debug_struct("HmrBroadcaster")
215            .field("subscribers", &self.sinks.lock().map_or(0, |v| v.len()))
216            .finish()
217    }
218}
219
220impl HmrBroadcaster {
221    /// Construct an empty broadcaster.
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// use ssg::hmr::HmrBroadcaster;
227    /// let b = HmrBroadcaster::new();
228    /// assert_eq!(b.subscriber_count(), 0);
229    /// ```
230    #[must_use]
231    pub fn new() -> Self {
232        Self {
233            sinks: Mutex::new(Vec::new()),
234        }
235    }
236
237    /// Register a new tab. The sink is invoked on every subsequent
238    /// [`Self::broadcast`] until it returns `Err(())`.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// use ssg::hmr::HmrBroadcaster;
244    /// let b = HmrBroadcaster::new();
245    /// b.subscribe(Box::new(|_| Ok(())));
246    /// assert_eq!(b.subscriber_count(), 1);
247    /// ```
248    pub fn subscribe(&self, sink: HmrSink) {
249        if let Ok(mut g) = self.sinks.lock() {
250            g.push(sink);
251        }
252    }
253
254    /// Returns the current number of subscribed tabs. Useful for tests
255    /// and the dev-server status banner.
256    ///
257    /// # Examples
258    ///
259    /// ```
260    /// use ssg::hmr::HmrBroadcaster;
261    /// let b = HmrBroadcaster::new();
262    /// assert_eq!(b.subscriber_count(), 0);
263    /// ```
264    #[must_use]
265    pub fn subscriber_count(&self) -> usize {
266        self.sinks.lock().map_or(0, |g| g.len())
267    }
268
269    /// Push `msg` to every subscriber. Sinks that return `Err(())` are
270    /// evicted in-place.
271    ///
272    /// Returns the number of tabs that successfully received the frame.
273    ///
274    /// # Examples
275    ///
276    /// ```
277    /// use ssg::hmr::{HmrBroadcaster, HmrMessage};
278    /// let b = HmrBroadcaster::new();
279    /// b.subscribe(Box::new(|_| Ok(())));
280    /// assert_eq!(b.broadcast(&HmrMessage::reload()), 1);
281    /// ```
282    pub fn broadcast(&self, msg: &HmrMessage) -> usize {
283        let payload = msg.to_json();
284        let Ok(mut sinks) = self.sinks.lock() else {
285            return 0;
286        };
287        let mut delivered = 0usize;
288        // Walk in reverse so swap_remove preserves the unvisited prefix.
289        let mut i = sinks.len();
290        while i > 0 {
291            i -= 1;
292            match (sinks[i])(&payload) {
293                Ok(()) => delivered += 1,
294                Err(()) => {
295                    let _ = sinks.swap_remove(i);
296                }
297            }
298        }
299        delivered
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use std::sync::{
307        atomic::{AtomicUsize, Ordering},
308        Arc,
309    };
310
311    #[test]
312    fn hmr_type_wire_strings_match_spec() {
313        assert_eq!(HmrType::HmrCss.wire(), "hmr-css");
314        assert_eq!(HmrType::HmrHtml.wire(), "hmr-html");
315        assert_eq!(HmrType::Reload.wire(), "reload");
316    }
317
318    #[test]
319    fn hmr_message_css_serialises_to_kebab_type() {
320        let m = HmrMessage::css(vec!["assets/style.css".into()]);
321        let json = m.to_json();
322        assert!(json.contains(r#""type":"hmr-css""#));
323        assert!(json.contains(r#""paths":["assets/style.css"]"#));
324        assert!(json.contains(r#""sha":"""#));
325    }
326
327    #[test]
328    fn hmr_message_html_serialises_paths() {
329        let m = HmrMessage::html(vec!["/a/".into(), "/b/".into()]);
330        let json = m.to_json();
331        assert!(json.contains(r#""type":"hmr-html""#));
332        assert!(json.contains(r#""/a/""#));
333        assert!(json.contains(r#""/b/""#));
334    }
335
336    #[test]
337    fn hmr_message_reload_has_empty_paths() {
338        let m = HmrMessage::reload();
339        let json = m.to_json();
340        assert!(json.contains(r#""type":"reload""#));
341        assert!(json.contains(r#""paths":[]"#));
342    }
343
344    #[test]
345    fn hmr_message_with_sha_round_trips() {
346        let m = HmrMessage::html(vec!["/x/".into()]).with_sha("deadbeef");
347        let json = m.to_json();
348        let back: HmrMessage = serde_json::from_str(&json).unwrap();
349        assert_eq!(back, m);
350        assert_eq!(back.sha, "deadbeef");
351    }
352
353    #[test]
354    fn hmr_message_default_sha_omittable() {
355        // Without an explicit sha field on the wire, deserialise should
356        // still succeed thanks to `#[serde(default)]`.
357        let json = r#"{"type":"hmr-css","paths":["a.css"]}"#;
358        let m: HmrMessage = serde_json::from_str(json).unwrap();
359        assert_eq!(m.kind, HmrType::HmrCss);
360        assert_eq!(m.sha, "");
361    }
362
363    #[test]
364    fn broadcaster_new_has_zero_subscribers() {
365        let b = HmrBroadcaster::new();
366        assert_eq!(b.subscriber_count(), 0);
367    }
368
369    #[test]
370    fn broadcaster_default_has_zero_subscribers() {
371        let b = HmrBroadcaster::default();
372        assert_eq!(b.subscriber_count(), 0);
373    }
374
375    #[test]
376    fn subscribe_increments_count() {
377        let b = HmrBroadcaster::new();
378        b.subscribe(Box::new(|_| Ok(())));
379        b.subscribe(Box::new(|_| Ok(())));
380        assert_eq!(b.subscriber_count(), 2);
381        // Deliver one frame so both registered sinks actually run.
382        assert_eq!(b.broadcast(&HmrMessage::reload()), 2);
383    }
384
385    #[test]
386    fn broadcast_delivers_to_every_subscriber() {
387        let b = HmrBroadcaster::new();
388        let calls = Arc::new(AtomicUsize::new(0));
389        for _ in 0..3 {
390            let calls = Arc::clone(&calls);
391            b.subscribe(Box::new(move |_| {
392                let _ = calls.fetch_add(1, Ordering::SeqCst);
393                Ok(())
394            }));
395        }
396        let delivered = b.broadcast(&HmrMessage::reload());
397        assert_eq!(delivered, 3);
398        assert_eq!(calls.load(Ordering::SeqCst), 3);
399    }
400
401    #[test]
402    fn broadcast_evicts_failed_sinks() {
403        let b = HmrBroadcaster::new();
404        b.subscribe(Box::new(|_| Err(())));
405        b.subscribe(Box::new(|_| Ok(())));
406        b.subscribe(Box::new(|_| Err(())));
407
408        let delivered = b.broadcast(&HmrMessage::reload());
409        assert_eq!(delivered, 1);
410        // The two erroring sinks should have been evicted.
411        assert_eq!(b.subscriber_count(), 1);
412
413        // Second broadcast still sees the surviving subscriber.
414        let delivered2 = b.broadcast(&HmrMessage::reload());
415        assert_eq!(delivered2, 1);
416        assert_eq!(b.subscriber_count(), 1);
417    }
418
419    #[test]
420    fn broadcast_passes_json_payload_to_sinks() {
421        let b = HmrBroadcaster::new();
422        let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
423        let seen_clone = Arc::clone(&seen);
424        b.subscribe(Box::new(move |s| {
425            *seen_clone.lock().unwrap() = Some(s.to_string());
426            Ok(())
427        }));
428
429        let _ = b.broadcast(&HmrMessage::css(vec!["x.css".into()]));
430        let payload = seen.lock().unwrap().clone().unwrap();
431        assert!(payload.contains(r#""type":"hmr-css""#));
432        assert!(payload.contains("x.css"));
433    }
434
435    #[test]
436    fn broadcast_with_no_subscribers_is_noop() {
437        let b = HmrBroadcaster::new();
438        assert_eq!(b.broadcast(&HmrMessage::reload()), 0);
439    }
440
441    #[test]
442    fn broadcaster_debug_format_includes_count() {
443        let b = HmrBroadcaster::new();
444        b.subscribe(Box::new(|_| Ok(())));
445        let d = format!("{b:?}");
446        assert!(d.contains("HmrBroadcaster"));
447        assert!(d.contains('1'));
448        // Run the registered sink once so the closure body executes.
449        assert_eq!(b.broadcast(&HmrMessage::reload()), 1);
450    }
451
452    #[test]
453    fn broadcaster_recovers_from_poisoned_sink_lock() {
454        // Poison the internal sinks mutex, then exercise every lock
455        // site: subscribe silently no-ops, broadcast returns 0, the
456        // counters fall back to 0.
457        let b = HmrBroadcaster::new();
458        b.subscribe(Box::new(|_| Ok(())));
459        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
460            let _guard = b.sinks.lock().unwrap();
461            panic!("poison sinks");
462        }));
463
464        b.subscribe(Box::new(|_| Ok(()))); // if-let Ok fails: no-op
465        assert_eq!(b.subscriber_count(), 0, "poisoned lock reads as empty");
466        assert_eq!(b.broadcast(&HmrMessage::reload()), 0);
467        let d = format!("{b:?}");
468        assert!(d.contains('0'), "Debug falls back to 0: {d}");
469    }
470
471    #[test]
472    fn hmr_message_to_json_is_valid_json() {
473        let m =
474            HmrMessage::html(vec!["/a/".into(), "/b/".into()]).with_sha("h");
475        let v: serde_json::Value = serde_json::from_str(&m.to_json()).unwrap();
476        assert_eq!(v["type"], "hmr-html");
477        assert_eq!(v["paths"][0], "/a/");
478        assert_eq!(v["paths"][1], "/b/");
479        assert_eq!(v["sha"], "h");
480    }
481
482    #[test]
483    fn protocol_three_message_types_distinct() {
484        let css = HmrMessage::css(vec![]).to_json();
485        let html = HmrMessage::html(vec![]).to_json();
486        let reload = HmrMessage::reload().to_json();
487        assert!(css.contains("hmr-css"));
488        assert!(html.contains("hmr-html"));
489        assert!(reload.contains("reload"));
490        assert!(!reload.contains("hmr-"));
491    }
492
493    #[test]
494    fn hmr_type_is_copy() {
495        let t = HmrType::HmrHtml;
496        let _copy = t;
497        assert_eq!(t, HmrType::HmrHtml);
498    }
499
500    #[test]
501    fn hmr_type_clone_and_debug() {
502        // `assert_eq!` only invokes `Debug::fmt` on a *failing*
503        // comparison, so the derived `Clone`/`Debug` impls need an
504        // explicit exercise to be reached at all.
505        let t = HmrType::HmrCss;
506        #[allow(clippy::clone_on_copy)]
507        let cloned = t.clone();
508        assert_eq!(cloned, t);
509        let dbg = format!("{t:?}");
510        assert!(dbg.contains("HmrCss"), "unexpected Debug output: {dbg}");
511    }
512
513    #[test]
514    fn hmr_message_clone_and_debug() {
515        let m = HmrMessage::html(vec!["/a/".into()]).with_sha("abc");
516        let cloned = m.clone();
517        assert_eq!(cloned, m);
518        let dbg = format!("{m:?}");
519        assert!(dbg.contains("HmrHtml"), "unexpected Debug output: {dbg}");
520        assert!(dbg.contains("abc"), "unexpected Debug output: {dbg}");
521    }
522}