use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use std::sync::OnceLock;
#[must_use]
pub struct View(String);
impl View {
pub fn new(html: String) -> Self {
Self(html)
}
pub fn into_html(self) -> String {
self.0
}
}
impl IntoResponse for View {
fn into_response(self) -> Response {
let html = if dev_reload_enabled() {
inject_dev_reload_script(self.0)
} else {
self.0
};
([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}
}
fn dev_reload_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| std::env::var_os("LARUST_DEV_RELOAD").is_some())
}
const DEV_RELOAD_SCRIPT: &str = r#"<script>
(function () {
var opened = false;
var es = new EventSource('/__larust_dev');
es.onopen = function () {
// `location.replace`, not `location.reload` - `reload()` reissues the
// request with whatever HTTP method got this page here in the first
// place. A page that's the direct render of a POST (no redirect after
// it - a real, common pattern) would then silently *resubmit* that
// POST the instant any unrelated file save elsewhere triggers a
// rebuild, with a CSRF token that's had no reason to still be valid -
// landing on a bare, unstyled 419 with no way back. `replace` always
// performs a plain GET navigation instead, the standard technique for
// getting fresh content without resubmitting.
if (opened) location.replace(location.href);
opened = true;
};
// A named event, sent only for a static-asset-only change (see
// `larust_core::dev_reload`) - no rebuild happened, no page reload
// needed, just swap each stylesheet's own URL so the browser re-fetches
// it instead of serving its cached copy.
es.addEventListener('reload-assets', function () {
document.querySelectorAll('link[rel="stylesheet"]').forEach(function (link) {
var url = new URL(link.href);
url.searchParams.set('_r', Date.now());
link.href = url.href;
});
});
// Sent by `xr dev` for every rebuild *after* the very first one, for as
// long as it's still in flight (or just failed) - the process this page
// is talking to is, by design, the last known-good one and keeps right
// on serving throughout (zero-downtime), so `es.onopen`'s own reconnect-
// based reload above never fires during this window. Without this, a
// page whose content genuinely depends on the code currently being
// rebuilt would just look stale or broken with no explanation - this
// shows a small, non-blocking banner instead, cleared automatically once
// a real handoff happens (the reload above replaces the whole page) or
// replaced by the next status update.
es.addEventListener('build-status', function (event) {
var banner = document.getElementById('__larust_build_banner');
if (!banner) {
banner = document.createElement('div');
banner.id = '__larust_build_banner';
// Fixed to the *bottom*, not the top - a top banner would cover a
// page's own nav/header and get in the way of actually using the
// still-serving old page while a rebuild is in flight, defeating the
// whole point of showing it non-disruptively in the first place.
banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:2147483647;' +
'padding:.6rem 1rem;color:#fff;background:#f4513d;' +
'font:600 13px/1.4 system-ui,sans-serif;text-align:center;';
document.body.append(banner);
}
banner.textContent = event.data === 'failed'
? 'Build failed - check your terminal'
: 'Rebuilding… this page may be stale until the new build is live.';
});
})();
</script>"#;
fn inject_dev_reload_script(html: String) -> String {
match html.find("</body>") {
Some(index) => {
let mut out = String::with_capacity(html.len() + DEV_RELOAD_SCRIPT.len());
out.push_str(&html[..index]);
out.push_str(DEV_RELOAD_SCRIPT);
out.push_str(&html[index..]);
out
}
None => html + DEV_RELOAD_SCRIPT,
}
}
pub fn escape(s: &str) -> String {
escape_ascii_bytes(s, |b| match b {
b'&' => Some("&"),
b'<' => Some("<"),
b'>' => Some(">"),
b'"' => Some("""),
b'\'' => Some("'"),
_ => None,
})
}
fn escape_ascii_bytes(s: &str, replacement: impl Fn(u8) -> Option<&'static str>) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut run_start = 0;
for (i, &b) in bytes.iter().enumerate() {
if let Some(escaped) = replacement(b) {
out.push_str(&s[run_start..i]);
out.push_str(escaped);
run_start = i + 1;
}
}
out.push_str(&s[run_start..]);
out
}
pub fn js<T: serde::Serialize>(value: &T) -> Result<String, serde_json::Error> {
let json = serde_json::to_string(value)?;
let hex_escaped = hex_escape_for_html(&json);
let literal = serde_json::to_string(&hex_escaped)?;
let inner = &literal[1..literal.len() - 1];
Ok(format!("JSON.parse('{inner}')"))
}
fn hex_escape_for_html(s: &str) -> String {
escape_ascii_bytes(s, |b| match b {
b'<' => Some("\\u003C"),
b'>' => Some("\\u003E"),
b'&' => Some("\\u0026"),
b'\'' => Some("\\u0027"),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn into_html_returns_the_raw_rendered_string() {
let view = View::new("<p>hello</p>".to_string());
assert_eq!(view.into_html(), "<p>hello</p>");
}
#[test]
fn escapes_all_special_characters() {
assert_eq!(
escape(r#"<script>alert('&"x"')</script>"#),
"<script>alert('&"x"')</script>"
);
}
#[test]
fn leaves_plain_text_unchanged() {
assert_eq!(escape("hello world"), "hello world");
}
#[test]
fn dev_reload_script_is_injected_before_closing_body_tag() {
let html = "<html><body><h1>hi</h1></body></html>".to_string();
let out = inject_dev_reload_script(html);
assert!(out.contains("EventSource('/__larust_dev')"));
let script_pos = out.find("<script>").unwrap();
let body_close_pos = out.find("</body>").unwrap();
assert!(script_pos < body_close_pos);
assert!(out.ends_with("</html>"));
}
#[test]
fn dev_reload_script_listens_for_a_named_reload_assets_event() {
let html = "<html><body></body></html>".to_string();
let out = inject_dev_reload_script(html);
assert!(out.contains("addEventListener('reload-assets'"));
assert!(out.contains(r#"link[rel="stylesheet"]"#));
}
#[test]
fn dev_reload_script_listens_for_a_named_build_status_event() {
let html = "<html><body></body></html>".to_string();
let out = inject_dev_reload_script(html);
assert!(out.contains("addEventListener('build-status'"));
assert!(out.contains("__larust_build_banner"));
assert!(out.contains("Build failed"));
}
#[test]
fn dev_reload_script_is_appended_when_there_is_no_body_tag() {
let html = "<p>just a fragment</p>".to_string();
let out = inject_dev_reload_script(html);
assert!(out.starts_with("<p>just a fragment</p>"));
assert!(out.ends_with("</script>"));
}
fn decode_js_token(token: &str) -> serde_json::Value {
let inner = token
.strip_prefix("JSON.parse('")
.and_then(|s| s.strip_suffix("')"))
.expect("token must be JSON.parse('...')");
let hex_escaped: String =
serde_json::from_str(&format!("\"{inner}\"")).expect("valid JSON string literal");
let unescaped = hex_escaped
.replace("\\u003C", "<")
.replace("\\u003E", ">")
.replace("\\u0026", "&")
.replace("\\u0027", "'");
serde_json::from_str(&unescaped).expect("valid JSON")
}
#[test]
fn js_wraps_output_as_a_json_parse_call() {
let token = js(&42).unwrap();
assert!(token.starts_with("JSON.parse('"));
assert!(token.ends_with("')"));
}
#[test]
fn js_round_trips_a_plain_value() {
let token = js(&serde_json::json!({"id": 7, "name": "Alice"})).unwrap();
assert_eq!(
decode_js_token(&token),
serde_json::json!({"id": 7, "name": "Alice"})
);
}
#[test]
fn js_hex_escapes_angle_brackets_ampersand_and_apostrophe() {
let value = "</script>&'<b>'";
let token = js(&value).unwrap();
assert!(!token.contains('<'));
assert!(!token.contains('>'));
assert!(!token.contains('&'));
assert_eq!(token.matches('\'').count(), 2);
assert_eq!(decode_js_token(&token), serde_json::json!(value));
}
#[test]
fn js_output_never_contains_a_literal_close_script_tag() {
let token = js(&"</script><script>alert(1)</script>").unwrap();
assert!(!token.contains("</script>"));
assert_eq!(
decode_js_token(&token),
serde_json::json!("</script><script>alert(1)</script>")
);
}
}