mini_static/reload.rs
1use std::path::Path;
2use std::pin::Pin;
3use std::task::{Context, Poll};
4
5use bytes::Bytes;
6use http_body::{Body, Frame};
7use tokio::sync::mpsc::UnboundedReceiver;
8
9use crate::error::StaticError;
10use crate::watcher::ChangeEvent;
11
12/// The request path `Server` serves the live-reload SSE stream on when
13/// [`crate::Server::with_live_reload`] is enabled.
14pub const LIVE_RELOAD_PATH: &str = "/__mini_static_reload";
15
16/// The type of change detected in a watched file.
17///
18/// Used by live-reload to determine what the browser should do when a file changes:
19/// CSS stylesheets are hot-swapped, while scripts and HTML require a full page reload.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum ChangeType {
22 /// A CSS stylesheet changed.
23 Css,
24 /// A JavaScript module changed.
25 Script,
26 /// An HTML page changed.
27 Html,
28 /// Some other file changed.
29 Other,
30}
31
32impl ChangeType {
33 /// Determine the change type from a file path's extension.
34 pub fn from_path(path: &Path) -> Self {
35 match path.extension().and_then(|e| e.to_str()) {
36 Some("css") => ChangeType::Css,
37 Some("js" | "mjs") => ChangeType::Script,
38 Some("html" | "htm") => ChangeType::Html,
39 _ => ChangeType::Other,
40 }
41 }
42
43 /// The string representation used in SSE event names and JSON.
44 pub fn as_str(&self) -> &'static str {
45 match self {
46 ChangeType::Css => "css",
47 ChangeType::Script => "script",
48 ChangeType::Html => "html",
49 ChangeType::Other => "other",
50 }
51 }
52}
53
54/// Encode a reload event as an SSE (Server-Sent Events) frame.
55///
56/// The frame follows the SSE text/event-stream format:
57/// ```text
58/// event: <change_type>
59/// data: {"type": "<change_type>"}
60///
61/// ```
62///
63/// This is the single canonical place the SSE frame format is defined.
64/// Callers that forward reload events over HTTP (e.g., mini-unified) call this
65/// function rather than re-implementing the byte-for-byte format.
66///
67/// The JSON payload is written directly rather than through a serializer: it is one
68/// fixed-shape object whose only value is one of [`ChangeType::as_str`]'s four literals,
69/// none of which contain a character JSON would need to escape. Pulling in a JSON
70/// dependency to emit eleven constant bytes is not a trade worth making.
71pub fn reload_event_frame(change_type: &ChangeType) -> Bytes {
72 let name = change_type.as_str();
73 Bytes::from(format!("event: {name}\ndata: {{\"type\":\"{name}\"}}\n\n"))
74}
75
76/// An `http_body::Body` that streams live-reload SSE frames to a single connected
77/// client, one [`ChangeEvent`] at a time, for as long as the underlying broadcast
78/// channel stays open.
79pub struct SseBody {
80 rx: UnboundedReceiver<ChangeEvent>,
81}
82
83impl SseBody {
84 pub(crate) fn new(rx: UnboundedReceiver<ChangeEvent>) -> Self {
85 SseBody { rx }
86 }
87}
88
89impl Body for SseBody {
90 type Data = Bytes;
91 type Error = StaticError;
92
93 fn poll_frame(
94 mut self: Pin<&mut Self>,
95 cx: &mut Context<'_>,
96 ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
97 match self.rx.poll_recv(cx) {
98 Poll::Ready(Some(event)) => Poll::Ready(Some(Ok(Frame::data(reload_event_frame(
99 &event.change_type,
100 ))))),
101 Poll::Ready(None) => Poll::Ready(None),
102 Poll::Pending => Poll::Pending,
103 }
104 }
105}
106
107/// The `<script>` tag `Server` injects into served HTML pages when live-reload is
108/// enabled (see [`inject_reload_script`]).
109///
110/// Opens an `EventSource` to [`LIVE_RELOAD_PATH`]. A `css` event hot-swaps every
111/// stylesheet `<link>` (cache-busted via a query param) without a full page reload;
112/// `script`, `html`, and `other` events reload the page, since there is no general way
113/// to hot-swap those in place.
114///
115/// # Panics
116///
117/// Never — the returned string is a fixed literal embedding [`LIVE_RELOAD_PATH`].
118fn reload_script_tag() -> String {
119 format!(
120 "<script>(function(){{\
121 var es=new EventSource(\"{LIVE_RELOAD_PATH}\");\
122 function reload(){{location.reload();}}\
123 es.addEventListener(\"css\",function(){{\
124 document.querySelectorAll('link[rel=\"stylesheet\"]').forEach(function(l){{\
125 var u=new URL(l.href);u.searchParams.set(\"_mr\",Date.now());l.href=u.toString();\
126 }});\
127 }});\
128 es.addEventListener(\"script\",reload);\
129 es.addEventListener(\"html\",reload);\
130 es.addEventListener(\"other\",reload);\
131 }})();</script>"
132 )
133}
134
135/// Insert the live-reload client script (see [`reload_script_tag`]) into an HTML
136/// document, immediately before the closing `</body>` tag if one is found (checking
137/// both `</body>` and `</BODY>`), otherwise appended at the end of the document.
138///
139/// Operates on raw bytes rather than parsing HTML — `mini-static` has no HTML parser
140/// and does not need one for a single fixed-string insertion.
141pub(crate) fn inject_reload_script(html: &mut Vec<u8>) {
142 let script = reload_script_tag();
143
144 let pos = find_subsequence(html, b"</body>").or_else(|| find_subsequence(html, b"</BODY>"));
145
146 match pos {
147 Some(pos) => {
148 html.splice(pos..pos, script.into_bytes());
149 }
150 None => html.extend_from_slice(script.as_bytes()),
151 }
152}
153
154/// Find the first occurrence of `needle` in `haystack`, if any.
155///
156/// Shared byte-search helper: both the live-reload and spa-mode script
157/// injectors splice a fixed `<script>` immediately before `</body>`, and
158/// both need this same substring scan to find it.
159pub(crate) fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
160 haystack.windows(needle.len()).position(|w| w == needle)
161}
162
163#[cfg(test)]
164#[path = "../tests/unit/reload.rs"]
165mod tests;