use std::sync::Mutex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HmrType {
HmrCss,
HmrHtml,
Reload,
}
impl HmrType {
#[must_use]
pub const fn wire(self) -> &'static str {
match self {
Self::HmrCss => "hmr-css",
Self::HmrHtml => "hmr-html",
Self::Reload => "reload",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HmrMessage {
#[serde(rename = "type")]
pub kind: HmrType,
pub paths: Vec<String>,
#[serde(default)]
pub sha: String,
}
impl HmrMessage {
#[must_use]
pub const fn css(paths: Vec<String>) -> Self {
Self {
kind: HmrType::HmrCss,
paths,
sha: String::new(),
}
}
#[must_use]
pub const fn html(paths: Vec<String>) -> Self {
Self {
kind: HmrType::HmrHtml,
paths,
sha: String::new(),
}
}
#[must_use]
pub const fn reload() -> Self {
Self {
kind: HmrType::Reload,
paths: Vec::new(),
sha: String::new(),
}
}
#[must_use]
pub fn with_sha(mut self, sha: impl Into<String>) -> Self {
self.sha = sha.into();
self
}
#[must_use]
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
r#"{"type":"reload","paths":[],"sha":""}"#.to_string()
})
}
}
pub type HmrSink = Box<dyn Fn(&str) -> Result<(), ()> + Send + Sync>;
#[derive(Default)]
pub struct HmrBroadcaster {
sinks: Mutex<Vec<HmrSink>>,
}
impl std::fmt::Debug for HmrBroadcaster {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HmrBroadcaster")
.field("subscribers", &self.sinks.lock().map_or(0, |v| v.len()))
.finish()
}
}
impl HmrBroadcaster {
#[must_use]
pub fn new() -> Self {
Self {
sinks: Mutex::new(Vec::new()),
}
}
pub fn subscribe(&self, sink: HmrSink) {
if let Ok(mut g) = self.sinks.lock() {
g.push(sink);
}
}
#[must_use]
pub fn subscriber_count(&self) -> usize {
self.sinks.lock().map_or(0, |g| g.len())
}
pub fn broadcast(&self, msg: &HmrMessage) -> usize {
let payload = msg.to_json();
let Ok(mut sinks) = self.sinks.lock() else {
return 0;
};
let mut delivered = 0usize;
let mut i = sinks.len();
while i > 0 {
i -= 1;
match (sinks[i])(&payload) {
Ok(()) => delivered += 1,
Err(()) => {
let _ = sinks.swap_remove(i);
}
}
}
delivered
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
#[test]
fn hmr_type_wire_strings_match_spec() {
assert_eq!(HmrType::HmrCss.wire(), "hmr-css");
assert_eq!(HmrType::HmrHtml.wire(), "hmr-html");
assert_eq!(HmrType::Reload.wire(), "reload");
}
#[test]
fn hmr_message_css_serialises_to_kebab_type() {
let m = HmrMessage::css(vec!["assets/style.css".into()]);
let json = m.to_json();
assert!(json.contains(r#""type":"hmr-css""#));
assert!(json.contains(r#""paths":["assets/style.css"]"#));
assert!(json.contains(r#""sha":"""#));
}
#[test]
fn hmr_message_html_serialises_paths() {
let m = HmrMessage::html(vec!["/a/".into(), "/b/".into()]);
let json = m.to_json();
assert!(json.contains(r#""type":"hmr-html""#));
assert!(json.contains(r#""/a/""#));
assert!(json.contains(r#""/b/""#));
}
#[test]
fn hmr_message_reload_has_empty_paths() {
let m = HmrMessage::reload();
let json = m.to_json();
assert!(json.contains(r#""type":"reload""#));
assert!(json.contains(r#""paths":[]"#));
}
#[test]
fn hmr_message_with_sha_round_trips() {
let m = HmrMessage::html(vec!["/x/".into()]).with_sha("deadbeef");
let json = m.to_json();
let back: HmrMessage = serde_json::from_str(&json).unwrap();
assert_eq!(back, m);
assert_eq!(back.sha, "deadbeef");
}
#[test]
fn hmr_message_default_sha_omittable() {
let json = r#"{"type":"hmr-css","paths":["a.css"]}"#;
let m: HmrMessage = serde_json::from_str(json).unwrap();
assert_eq!(m.kind, HmrType::HmrCss);
assert_eq!(m.sha, "");
}
#[test]
fn broadcaster_new_has_zero_subscribers() {
let b = HmrBroadcaster::new();
assert_eq!(b.subscriber_count(), 0);
}
#[test]
fn broadcaster_default_has_zero_subscribers() {
let b = HmrBroadcaster::default();
assert_eq!(b.subscriber_count(), 0);
}
#[test]
fn subscribe_increments_count() {
let b = HmrBroadcaster::new();
b.subscribe(Box::new(|_| Ok(())));
b.subscribe(Box::new(|_| Ok(())));
assert_eq!(b.subscriber_count(), 2);
assert_eq!(b.broadcast(&HmrMessage::reload()), 2);
}
#[test]
fn broadcast_delivers_to_every_subscriber() {
let b = HmrBroadcaster::new();
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..3 {
let calls = Arc::clone(&calls);
b.subscribe(Box::new(move |_| {
let _ = calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}));
}
let delivered = b.broadcast(&HmrMessage::reload());
assert_eq!(delivered, 3);
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn broadcast_evicts_failed_sinks() {
let b = HmrBroadcaster::new();
b.subscribe(Box::new(|_| Err(())));
b.subscribe(Box::new(|_| Ok(())));
b.subscribe(Box::new(|_| Err(())));
let delivered = b.broadcast(&HmrMessage::reload());
assert_eq!(delivered, 1);
assert_eq!(b.subscriber_count(), 1);
let delivered2 = b.broadcast(&HmrMessage::reload());
assert_eq!(delivered2, 1);
assert_eq!(b.subscriber_count(), 1);
}
#[test]
fn broadcast_passes_json_payload_to_sinks() {
let b = HmrBroadcaster::new();
let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let seen_clone = Arc::clone(&seen);
b.subscribe(Box::new(move |s| {
*seen_clone.lock().unwrap() = Some(s.to_string());
Ok(())
}));
let _ = b.broadcast(&HmrMessage::css(vec!["x.css".into()]));
let payload = seen.lock().unwrap().clone().unwrap();
assert!(payload.contains(r#""type":"hmr-css""#));
assert!(payload.contains("x.css"));
}
#[test]
fn broadcast_with_no_subscribers_is_noop() {
let b = HmrBroadcaster::new();
assert_eq!(b.broadcast(&HmrMessage::reload()), 0);
}
#[test]
fn broadcaster_debug_format_includes_count() {
let b = HmrBroadcaster::new();
b.subscribe(Box::new(|_| Ok(())));
let d = format!("{b:?}");
assert!(d.contains("HmrBroadcaster"));
assert!(d.contains('1'));
assert_eq!(b.broadcast(&HmrMessage::reload()), 1);
}
#[test]
fn broadcaster_recovers_from_poisoned_sink_lock() {
let b = HmrBroadcaster::new();
b.subscribe(Box::new(|_| Ok(())));
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = b.sinks.lock().unwrap();
panic!("poison sinks");
}));
b.subscribe(Box::new(|_| Ok(()))); assert_eq!(b.subscriber_count(), 0, "poisoned lock reads as empty");
assert_eq!(b.broadcast(&HmrMessage::reload()), 0);
let d = format!("{b:?}");
assert!(d.contains('0'), "Debug falls back to 0: {d}");
}
#[test]
fn hmr_message_to_json_is_valid_json() {
let m =
HmrMessage::html(vec!["/a/".into(), "/b/".into()]).with_sha("h");
let v: serde_json::Value = serde_json::from_str(&m.to_json()).unwrap();
assert_eq!(v["type"], "hmr-html");
assert_eq!(v["paths"][0], "/a/");
assert_eq!(v["paths"][1], "/b/");
assert_eq!(v["sha"], "h");
}
#[test]
fn protocol_three_message_types_distinct() {
let css = HmrMessage::css(vec![]).to_json();
let html = HmrMessage::html(vec![]).to_json();
let reload = HmrMessage::reload().to_json();
assert!(css.contains("hmr-css"));
assert!(html.contains("hmr-html"));
assert!(reload.contains("reload"));
assert!(!reload.contains("hmr-"));
}
#[test]
fn hmr_type_is_copy() {
let t = HmrType::HmrHtml;
let _copy = t;
assert_eq!(t, HmrType::HmrHtml);
}
#[test]
fn hmr_type_clone_and_debug() {
let t = HmrType::HmrCss;
#[allow(clippy::clone_on_copy)]
let cloned = t.clone();
assert_eq!(cloned, t);
let dbg = format!("{t:?}");
assert!(dbg.contains("HmrCss"), "unexpected Debug output: {dbg}");
}
#[test]
fn hmr_message_clone_and_debug() {
let m = HmrMessage::html(vec!["/a/".into()]).with_sha("abc");
let cloned = m.clone();
assert_eq!(cloned, m);
let dbg = format!("{m:?}");
assert!(dbg.contains("HmrHtml"), "unexpected Debug output: {dbg}");
assert!(dbg.contains("abc"), "unexpected Debug output: {dbg}");
}
}