Skip to main content

larust_view/
runtime.rs

1use axum::http::header::CONTENT_TYPE;
2use axum::response::{IntoResponse, Response};
3use std::sync::OnceLock;
4
5/// A rendered template. What `view!(...)` (in `larust-macros`) produces.
6#[must_use]
7pub struct View(String);
8
9impl View {
10    pub fn new(html: String) -> Self {
11        Self(html)
12    }
13
14    /// The raw rendered HTML, for a caller that isn't building an HTTP
15    /// response (e.g. an email body) - deliberately bypasses
16    /// `into_response()`'s dev-reload script injection, which only makes
17    /// sense for a page a browser tab is polling for a live-reload signal.
18    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
34/// Checked once per process (not once per request) - `xr dev` sets
35/// `LARUST_DEV_RELOAD` only on the child process it spawns itself, so this
36/// is `false` for the lifetime of any normal `cargo run`.
37fn 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
101/// Injects the live-reload client just before `</body>` - falling back to
102/// appending it if a page doesn't have one (a fragment response, say).
103fn 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
116/// HTML-escapes a string for safe interpolation (`{{ }}`, not `{!! !!}`).
117///
118/// Every dynamic value in every rendered template passes through this -
119/// a genuinely hot path, so it's worth not paying for what a naive
120/// per-`char` loop actually costs: a `chars()` iterator decodes each
121/// Unicode scalar and calls `String::push` (its own UTF-8 re-encode) even
122/// for the overwhelming majority of input that's plain, nothing-to-escape
123/// text. Scanning `s`'s raw bytes instead and bulk-`push_str`-ing each
124/// unescaped run in one shot (rather than one `push` per character) means
125/// an all-plain-text value - the common case - costs one `memcpy`-shaped
126/// copy of the whole string, not N individual char pushes.
127///
128/// Byte-indexing here (not `char_indices()`) is safe *only* because every
129/// character this function ever escapes (`&`, `<`, `>`, `"`, `'`) is
130/// single-byte ASCII, and UTF-8 guarantees no byte belonging to a
131/// multi-byte sequence can ever equal a single-byte ASCII code point -
132/// so every index this loop slices `s` at is already guaranteed to sit on
133/// a real `char` boundary, never mid-sequence. This is not true in
134/// general for arbitrary byte values; if this function ever needs to
135/// escape a non-ASCII character, this approach would need revisiting.
136pub fn escape(s: &str) -> String {
137    escape_ascii_bytes(s, |b| match b {
138        b'&' => Some("&amp;"),
139        b'<' => Some("&lt;"),
140        b'>' => Some("&gt;"),
141        b'"' => Some("&quot;"),
142        b'\'' => Some("&#x27;"),
143        _ => None,
144    })
145}
146
147/// The byte-scan-and-bulk-copy loop `escape`/`hex_escape_for_html` both
148/// use - `replacement` maps a byte needing escaping to its output text,
149/// or `None` to leave it alone; see `escape`'s own doc comment for why
150/// scanning bytes (not `char`s) is safe here specifically.
151fn 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
166/// `@js($expr)`'s JS-safe (not HTML-escaped) serialization - drops a
167/// `Serialize` value into inline JavaScript as a `JSON.parse('...')` call,
168/// mirroring Laravel's `Illuminate\Support\Js::from()` two-layer mechanism
169/// faithfully:
170///
171/// 1. JSON-encode the value, then hex-escape `<`, `>`, `&`, `'` in the
172///    result (`JSON_HEX_TAG`/`JSON_HEX_AMP`/`JSON_HEX_APOS` equivalents) -
173///    this is what makes it safe to embed inside an HTML attribute or a
174///    `<script>` block without e.g. a `"</script>"` string value breaking
175///    out of context.
176/// 2. Wrap that hex-escaped string as `JSON.parse('...')`, JSON-re-encoding
177///    it (which escapes backslashes/newlines/quotes - including the `\u...`
178///    sequences step 1 just introduced, and the delimiting `'` itself) and
179///    stripping the outer `"..."` quotes that encoding pass adds. What's
180///    left is exactly the text that belongs between the single quotes of
181///    `JSON.parse('...')`, so the whole token can be spliced directly into
182///    surrounding source with no caller-added quoting.
183pub 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
191/// `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS` equivalent - deliberately
192/// narrower than [`escape`] (no `"` - that's handled by `js`'s own second
193/// JSON-encoding pass, not here). Same byte-scan approach as `escape`, for
194/// the same reason (see its doc comment) - `<`, `>`, `&`, `'` are all
195/// single-byte ASCII, so byte-index slicing is safe here too.
196fn 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            "&lt;script&gt;alert(&#x27;&amp;&quot;x&quot;&#x27;)&lt;/script&gt;"
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        // The script comes before </body>, not after it - the rest of the
235        // document (the closing tags) must still follow the script.
236        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    /// Reverses `js`'s two encoding passes so a test can assert on the
268    /// *value* a browser would actually see, not the escaped token text.
269    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        // `inner` is valid JSON-string-literal content (produced by `js`'s
275        // own second `serde_json::to_string` pass) - wrapping it back in
276        // `"..."` lets `serde_json` reverse that escaping for us instead of
277        // hand-rolling a JS-string unescaper.
278        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        // The raw characters must never appear literally in the token -
309        // that's the whole point of hex-escaping before either JSON pass.
310        assert!(!token.contains('<'));
311        assert!(!token.contains('>'));
312        assert!(!token.contains('&'));
313        // `'` is allowed to appear only as the two literal quotes framing
314        // `JSON.parse(' ... ')` - never inside the payload, or it would
315        // terminate the JS string literal early.
316        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}