1use axum::http::header::CONTENT_TYPE;
2use axum::response::{IntoResponse, Response};
3use std::sync::OnceLock;
4
5#[must_use]
7pub struct View(String);
8
9impl View {
10 pub fn new(html: String) -> Self {
11 Self(html)
12 }
13
14 pub fn into_html(self) -> String {
19 self.0
20 }
21}
22
23impl IntoResponse for View {
24 fn into_response(self) -> Response {
25 let html = if dev_reload_enabled() {
26 inject_dev_reload_script(self.0)
27 } else {
28 self.0
29 };
30 ([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
31 }
32}
33
34fn dev_reload_enabled() -> bool {
38 static ENABLED: OnceLock<bool> = OnceLock::new();
39 *ENABLED.get_or_init(|| std::env::var_os("LARUST_DEV_RELOAD").is_some())
40}
41
42const DEV_RELOAD_SCRIPT: &str = r#"<script>
43(function () {
44 var opened = false;
45 var es = new EventSource('/__larust_dev');
46 es.onopen = function () {
47 // `location.replace`, not `location.reload` - `reload()` reissues the
48 // request with whatever HTTP method got this page here in the first
49 // place. A page that's the direct render of a POST (no redirect after
50 // it - a real, common pattern) would then silently *resubmit* that
51 // POST the instant any unrelated file save elsewhere triggers a
52 // rebuild, with a CSRF token that's had no reason to still be valid -
53 // landing on a bare, unstyled 419 with no way back. `replace` always
54 // performs a plain GET navigation instead, the standard technique for
55 // getting fresh content without resubmitting.
56 if (opened) location.replace(location.href);
57 opened = true;
58 };
59 // A named event, sent only for a static-asset-only change (see
60 // `larust_core::dev_reload`) - no rebuild happened, no page reload
61 // needed, just swap each stylesheet's own URL so the browser re-fetches
62 // it instead of serving its cached copy.
63 es.addEventListener('reload-assets', function () {
64 document.querySelectorAll('link[rel="stylesheet"]').forEach(function (link) {
65 var url = new URL(link.href);
66 url.searchParams.set('_r', Date.now());
67 link.href = url.href;
68 });
69 });
70 // Sent by `xr dev` for every rebuild *after* the very first one, for as
71 // long as it's still in flight (or just failed) - the process this page
72 // is talking to is, by design, the last known-good one and keeps right
73 // on serving throughout (zero-downtime), so `es.onopen`'s own reconnect-
74 // based reload above never fires during this window. Without this, a
75 // page whose content genuinely depends on the code currently being
76 // rebuilt would just look stale or broken with no explanation - this
77 // shows a small, non-blocking banner instead, cleared automatically once
78 // a real handoff happens (the reload above replaces the whole page) or
79 // replaced by the next status update.
80 es.addEventListener('build-status', function (event) {
81 var banner = document.getElementById('__larust_build_banner');
82 if (!banner) {
83 banner = document.createElement('div');
84 banner.id = '__larust_build_banner';
85 // Fixed to the *bottom*, not the top - a top banner would cover a
86 // page's own nav/header and get in the way of actually using the
87 // still-serving old page while a rebuild is in flight, defeating the
88 // whole point of showing it non-disruptively in the first place.
89 banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:2147483647;' +
90 'padding:.6rem 1rem;color:#fff;background:#f4513d;' +
91 'font:600 13px/1.4 system-ui,sans-serif;text-align:center;';
92 document.body.append(banner);
93 }
94 banner.textContent = event.data === 'failed'
95 ? 'Build failed - check your terminal'
96 : 'Rebuilding… this page may be stale until the new build is live.';
97 });
98})();
99</script>"#;
100
101fn inject_dev_reload_script(html: String) -> String {
104 match html.find("</body>") {
105 Some(index) => {
106 let mut out = String::with_capacity(html.len() + DEV_RELOAD_SCRIPT.len());
107 out.push_str(&html[..index]);
108 out.push_str(DEV_RELOAD_SCRIPT);
109 out.push_str(&html[index..]);
110 out
111 }
112 None => html + DEV_RELOAD_SCRIPT,
113 }
114}
115
116pub fn escape(s: &str) -> String {
137 escape_ascii_bytes(s, |b| match b {
138 b'&' => Some("&"),
139 b'<' => Some("<"),
140 b'>' => Some(">"),
141 b'"' => Some("""),
142 b'\'' => Some("'"),
143 _ => None,
144 })
145}
146
147fn escape_ascii_bytes(s: &str, replacement: impl Fn(u8) -> Option<&'static str>) -> String {
152 let bytes = s.as_bytes();
153 let mut out = String::with_capacity(s.len());
154 let mut run_start = 0;
155 for (i, &b) in bytes.iter().enumerate() {
156 if let Some(escaped) = replacement(b) {
157 out.push_str(&s[run_start..i]);
158 out.push_str(escaped);
159 run_start = i + 1;
160 }
161 }
162 out.push_str(&s[run_start..]);
163 out
164}
165
166pub fn js<T: serde::Serialize>(value: &T) -> Result<String, serde_json::Error> {
184 let json = serde_json::to_string(value)?;
185 let hex_escaped = hex_escape_for_html(&json);
186 let literal = serde_json::to_string(&hex_escaped)?;
187 let inner = &literal[1..literal.len() - 1];
188 Ok(format!("JSON.parse('{inner}')"))
189}
190
191fn hex_escape_for_html(s: &str) -> String {
197 escape_ascii_bytes(s, |b| match b {
198 b'<' => Some("\\u003C"),
199 b'>' => Some("\\u003E"),
200 b'&' => Some("\\u0026"),
201 b'\'' => Some("\\u0027"),
202 _ => None,
203 })
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn into_html_returns_the_raw_rendered_string() {
212 let view = View::new("<p>hello</p>".to_string());
213 assert_eq!(view.into_html(), "<p>hello</p>");
214 }
215
216 #[test]
217 fn escapes_all_special_characters() {
218 assert_eq!(
219 escape(r#"<script>alert('&"x"')</script>"#),
220 "<script>alert('&"x"')</script>"
221 );
222 }
223
224 #[test]
225 fn leaves_plain_text_unchanged() {
226 assert_eq!(escape("hello world"), "hello world");
227 }
228
229 #[test]
230 fn dev_reload_script_is_injected_before_closing_body_tag() {
231 let html = "<html><body><h1>hi</h1></body></html>".to_string();
232 let out = inject_dev_reload_script(html);
233 assert!(out.contains("EventSource('/__larust_dev')"));
234 let script_pos = out.find("<script>").unwrap();
237 let body_close_pos = out.find("</body>").unwrap();
238 assert!(script_pos < body_close_pos);
239 assert!(out.ends_with("</html>"));
240 }
241
242 #[test]
243 fn dev_reload_script_listens_for_a_named_reload_assets_event() {
244 let html = "<html><body></body></html>".to_string();
245 let out = inject_dev_reload_script(html);
246 assert!(out.contains("addEventListener('reload-assets'"));
247 assert!(out.contains(r#"link[rel="stylesheet"]"#));
248 }
249
250 #[test]
251 fn dev_reload_script_listens_for_a_named_build_status_event() {
252 let html = "<html><body></body></html>".to_string();
253 let out = inject_dev_reload_script(html);
254 assert!(out.contains("addEventListener('build-status'"));
255 assert!(out.contains("__larust_build_banner"));
256 assert!(out.contains("Build failed"));
257 }
258
259 #[test]
260 fn dev_reload_script_is_appended_when_there_is_no_body_tag() {
261 let html = "<p>just a fragment</p>".to_string();
262 let out = inject_dev_reload_script(html);
263 assert!(out.starts_with("<p>just a fragment</p>"));
264 assert!(out.ends_with("</script>"));
265 }
266
267 fn decode_js_token(token: &str) -> serde_json::Value {
270 let inner = token
271 .strip_prefix("JSON.parse('")
272 .and_then(|s| s.strip_suffix("')"))
273 .expect("token must be JSON.parse('...')");
274 let hex_escaped: String =
279 serde_json::from_str(&format!("\"{inner}\"")).expect("valid JSON string literal");
280 let unescaped = hex_escaped
281 .replace("\\u003C", "<")
282 .replace("\\u003E", ">")
283 .replace("\\u0026", "&")
284 .replace("\\u0027", "'");
285 serde_json::from_str(&unescaped).expect("valid JSON")
286 }
287
288 #[test]
289 fn js_wraps_output_as_a_json_parse_call() {
290 let token = js(&42).unwrap();
291 assert!(token.starts_with("JSON.parse('"));
292 assert!(token.ends_with("')"));
293 }
294
295 #[test]
296 fn js_round_trips_a_plain_value() {
297 let token = js(&serde_json::json!({"id": 7, "name": "Alice"})).unwrap();
298 assert_eq!(
299 decode_js_token(&token),
300 serde_json::json!({"id": 7, "name": "Alice"})
301 );
302 }
303
304 #[test]
305 fn js_hex_escapes_angle_brackets_ampersand_and_apostrophe() {
306 let value = "</script>&'<b>'";
307 let token = js(&value).unwrap();
308 assert!(!token.contains('<'));
311 assert!(!token.contains('>'));
312 assert!(!token.contains('&'));
313 assert_eq!(token.matches('\'').count(), 2);
317 assert_eq!(decode_js_token(&token), serde_json::json!(value));
318 }
319
320 #[test]
321 fn js_output_never_contains_a_literal_close_script_tag() {
322 let token = js(&"</script><script>alert(1)</script>").unwrap();
323 assert!(!token.contains("</script>"));
324 assert_eq!(
325 decode_js_token(&token),
326 serde_json::json!("</script><script>alert(1)</script>")
327 );
328 }
329}