mini-static 0.18.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::path::Path;
use std::pin::Pin;
use std::task::{Context, Poll};

use bytes::Bytes;
use http_body::{Body, Frame};
use tokio::sync::mpsc::UnboundedReceiver;

use crate::error::StaticError;
use crate::watcher::ChangeEvent;

/// The request path `Server` serves the live-reload SSE stream on when
/// [`crate::Server::with_live_reload`] is enabled.
pub const LIVE_RELOAD_PATH: &str = "/__mini_static_reload";

/// The type of change detected in a watched file.
///
/// Used by live-reload to determine what the browser should do when a file changes:
/// CSS stylesheets are hot-swapped, while scripts and HTML require a full page reload.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChangeType {
    /// A CSS stylesheet changed.
    Css,
    /// A JavaScript module changed.
    Script,
    /// An HTML page changed.
    Html,
    /// Some other file changed.
    Other,
}

impl ChangeType {
    /// Determine the change type from a file path's extension.
    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,
        }
    }

    /// The string representation used in SSE event names and JSON.
    pub fn as_str(&self) -> &'static str {
        match self {
            ChangeType::Css => "css",
            ChangeType::Script => "script",
            ChangeType::Html => "html",
            ChangeType::Other => "other",
        }
    }
}

/// Encode a reload event as an SSE (Server-Sent Events) frame.
///
/// The frame follows the SSE text/event-stream format:
/// ```text
/// event: <change_type>
/// data: {"type": "<change_type>"}
///
/// ```
///
/// This is the single canonical place the SSE frame format is defined.
/// Callers that forward reload events over HTTP (e.g., mini-unified) call this
/// function rather than re-implementing the byte-for-byte format.
///
/// The JSON payload is written directly rather than through a serializer: it is one
/// fixed-shape object whose only value is one of [`ChangeType::as_str`]'s four literals,
/// none of which contain a character JSON would need to escape. Pulling in a JSON
/// dependency to emit eleven constant bytes is not a trade worth making.
pub fn reload_event_frame(change_type: &ChangeType) -> Bytes {
    let name = change_type.as_str();
    Bytes::from(format!("event: {name}\ndata: {{\"type\":\"{name}\"}}\n\n"))
}

/// An `http_body::Body` that streams live-reload SSE frames to a single connected
/// client, one [`ChangeEvent`] at a time, for as long as the underlying broadcast
/// channel stays open.
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,
        }
    }
}

/// The `<script>` tag `Server` injects into served HTML pages when live-reload is
/// enabled (see [`inject_reload_script`]).
///
/// Opens an `EventSource` to [`LIVE_RELOAD_PATH`]. A `css` event hot-swaps every
/// stylesheet `<link>` (cache-busted via a query param) without a full page reload;
/// `script`, `html`, and `other` events reload the page, since there is no general way
/// to hot-swap those in place.
///
/// # Panics
///
/// Never — the returned string is a fixed literal embedding [`LIVE_RELOAD_PATH`].
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>"
    )
}

/// Insert the live-reload client script (see [`reload_script_tag`]) into an HTML
/// document, immediately before the closing `</body>` tag if one is found (checking
/// both `</body>` and `</BODY>`), otherwise appended at the end of the document.
///
/// Operates on raw bytes rather than parsing HTML — `mini-static` has no HTML parser
/// and does not need one for a single fixed-string insertion.
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() {
        // Disproves hardcoding a divergent path literal in the script: if someone edits
        // `LIVE_RELOAD_PATH` without updating `reload_script_tag()`, the injected client
        // would open an `EventSource` to a path the server never serves.
        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));
        // The script must land strictly before the closing tag, not after it.
        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");
    }
}