use std::path::Path;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use http_body::{Body, Frame};
use serde_json::json;
use tokio::sync::mpsc::UnboundedReceiver;
use crate::error::StaticError;
use crate::watcher::ChangeEvent;
pub const LIVE_RELOAD_PATH: &str = "/__mini_static_reload";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChangeType {
Css,
Script,
Html,
Other,
}
impl ChangeType {
pub fn from_path(path: &Path) -> Self {
match path.extension().and_then(|e| e.to_str()) {
Some("css") => ChangeType::Css,
Some("js" | "mjs") => ChangeType::Script,
Some("html" | "htm") => ChangeType::Html,
_ => ChangeType::Other,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ChangeType::Css => "css",
ChangeType::Script => "script",
ChangeType::Html => "html",
ChangeType::Other => "other",
}
}
}
pub fn reload_event_frame(change_type: &ChangeType) -> Bytes {
let data = json!({ "type": change_type.as_str() });
let msg = format!(
"event: {}\ndata: {}\n\n",
change_type.as_str(),
data
);
Bytes::from(msg)
}
pub struct SseBody {
rx: UnboundedReceiver<ChangeEvent>,
}
impl SseBody {
pub(crate) fn new(rx: UnboundedReceiver<ChangeEvent>) -> Self {
SseBody { rx }
}
}
impl Body for SseBody {
type Data = Bytes;
type Error = StaticError;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
match self.rx.poll_recv(cx) {
Poll::Ready(Some(event)) => {
Poll::Ready(Some(Ok(Frame::data(reload_event_frame(&event.change_type)))))
}
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
fn reload_script_tag() -> String {
format!(
"<script>(function(){{\
var es=new EventSource(\"{LIVE_RELOAD_PATH}\");\
function reload(){{location.reload();}}\
es.addEventListener(\"css\",function(){{\
document.querySelectorAll('link[rel=\"stylesheet\"]').forEach(function(l){{\
var u=new URL(l.href);u.searchParams.set(\"_mr\",Date.now());l.href=u.toString();\
}});\
}});\
es.addEventListener(\"script\",reload);\
es.addEventListener(\"html\",reload);\
es.addEventListener(\"other\",reload);\
}})();</script>"
)
}
pub(crate) fn inject_reload_script(html: &mut Vec<u8>) {
let script = reload_script_tag();
let pos = find_subsequence(html, b"</body>").or_else(|| find_subsequence(html, b"</BODY>"));
match pos {
Some(pos) => {
html.splice(pos..pos, script.into_bytes());
}
None => html.extend_from_slice(script.as_bytes()),
}
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reload_event_frame_formats_css_correctly() {
let frame = reload_event_frame(&ChangeType::Css);
let s = String::from_utf8(frame.to_vec()).unwrap();
assert_eq!(s, "event: css\ndata: {\"type\":\"css\"}\n\n");
}
#[test]
fn reload_event_frame_formats_script_correctly() {
let frame = reload_event_frame(&ChangeType::Script);
let s = String::from_utf8(frame.to_vec()).unwrap();
assert_eq!(s, "event: script\ndata: {\"type\":\"script\"}\n\n");
}
#[test]
fn reload_event_frame_formats_html_correctly() {
let frame = reload_event_frame(&ChangeType::Html);
let s = String::from_utf8(frame.to_vec()).unwrap();
assert_eq!(s, "event: html\ndata: {\"type\":\"html\"}\n\n");
}
#[test]
fn reload_event_frame_formats_other_correctly() {
let frame = reload_event_frame(&ChangeType::Other);
let s = String::from_utf8(frame.to_vec()).unwrap();
assert_eq!(s, "event: other\ndata: {\"type\":\"other\"}\n\n");
}
#[test]
fn change_type_from_path_css() {
assert_eq!(ChangeType::from_path(Path::new("style.css")), ChangeType::Css);
assert_eq!(ChangeType::from_path(Path::new("dir/main.css")), ChangeType::Css);
}
#[test]
fn change_type_from_path_script() {
assert_eq!(ChangeType::from_path(Path::new("app.js")), ChangeType::Script);
assert_eq!(ChangeType::from_path(Path::new("mod.mjs")), ChangeType::Script);
assert_eq!(ChangeType::from_path(Path::new("dir/lib.js")), ChangeType::Script);
}
#[test]
fn change_type_from_path_html() {
assert_eq!(ChangeType::from_path(Path::new("index.html")), ChangeType::Html);
assert_eq!(ChangeType::from_path(Path::new("page.htm")), ChangeType::Html);
assert_eq!(ChangeType::from_path(Path::new("dir/file.html")), ChangeType::Html);
}
#[test]
fn change_type_from_path_other() {
assert_eq!(ChangeType::from_path(Path::new("image.png")), ChangeType::Other);
assert_eq!(ChangeType::from_path(Path::new("data.json")), ChangeType::Other);
assert_eq!(ChangeType::from_path(Path::new("README")), ChangeType::Other);
}
#[test]
fn reload_script_tag_embeds_the_live_reload_path() {
assert!(reload_script_tag().contains(LIVE_RELOAD_PATH));
}
#[test]
fn inject_reload_script_inserts_before_closing_body_tag() {
let mut html = b"<html><body><h1>hi</h1></body></html>".to_vec();
inject_reload_script(&mut html);
let s = String::from_utf8(html).unwrap();
assert!(s.starts_with("<html><body><h1>hi</h1>"));
assert!(s.ends_with("</body></html>"));
assert!(s.contains(LIVE_RELOAD_PATH));
assert!(s.find("<script>").unwrap() < s.find("</body>").unwrap());
}
#[test]
fn inject_reload_script_handles_uppercase_closing_tag() {
let mut html = b"<HTML><BODY>hi</BODY></HTML>".to_vec();
inject_reload_script(&mut html);
let s = String::from_utf8(html).unwrap();
assert!(s.find("<script>").unwrap() < s.find("</BODY>").unwrap());
}
#[test]
fn inject_reload_script_appends_when_no_body_tag_present() {
let mut html = b"<h1>fragment, no body tag</h1>".to_vec();
inject_reload_script(&mut html);
let s = String::from_utf8(html).unwrap();
assert!(s.starts_with("<h1>fragment, no body tag</h1>"));
assert!(s.ends_with("</script>"));
}
#[tokio::test]
async fn sse_body_yields_a_frame_per_broadcast_event() {
use crate::watcher::Broadcaster;
use http_body_util::BodyExt;
use std::path::PathBuf;
let broadcaster = Broadcaster::new();
let rx = broadcaster.subscribe();
let mut body = SseBody::new(rx);
broadcaster.broadcast(ChangeEvent {
path: PathBuf::from("style.css"),
change_type: ChangeType::Css,
});
let frame = body.frame().await.expect("stream ended early").expect("frame error");
let data = frame.into_data().unwrap();
assert_eq!(&data[..], b"event: css\ndata: {\"type\":\"css\"}\n\n");
}
}