Skip to main content

larust_core/
error_pages.rs

1//! Process-wide error-page registry. Same `OnceLock`-backed process-wide-
2//! state idiom `crate::debug` (and `crates/larust-orm/src/pool.rs`) uses -
3//! needed for the same reason `debug::is_enabled()` is: `AppError::
4//! into_response()` is a stateless `IntoResponse` impl with no access to
5//! `AppState`, so this is the only way for it to reach app-registered
6//! content.
7//!
8//! `default_not_found_html`/`default_internal_html` are `pub`, not
9//! `pub(crate)`, because `larust-macros`' `error_view!` macro (expanded
10//! inside an *app's* crate, not this one) calls them directly as the
11//! "no `resources/views/errors/{code}.blade.xr` file exists" fallback for
12//! 404/500 - one canonical implementation of what the default page looks
13//! like, shared by both the always-on registry fallback below and the
14//! opt-in override macro, rather than two copies that could drift apart.
15
16use std::sync::OnceLock;
17
18/// Rendered once (at `Application::with_error_pages` call time, not per
19/// request) and cached as plain `String`s rather than a render closure or
20/// a `larust_view::View` - these pages have no per-request dynamic content
21/// (no session, no user), and this crate has no dependency on the view
22/// engine at all (see `Cargo.toml`), so there's nothing a closure would buy
23/// over pre-rendering once.
24pub struct ErrorPages {
25    pub not_found: String,
26    pub internal: String,
27}
28
29static PAGES: OnceLock<ErrorPages> = OnceLock::new();
30
31/// Idempotent - a second call is a silent no-op, same reasoning as
32/// `debug::set`: there's no meaningful conflict to report for state this
33/// coarse.
34pub(crate) fn set(pages: ErrorPages) {
35    let _ = PAGES.set(pages);
36}
37
38pub(crate) fn not_found_html() -> String {
39    PAGES
40        .get()
41        .map(|p| p.not_found.clone())
42        .unwrap_or_else(default_not_found_html)
43}
44
45pub(crate) fn internal_html() -> String {
46    PAGES
47        .get()
48        .map(|p| p.internal.clone())
49        .unwrap_or_else(default_internal_html)
50}
51
52/// The framework's own built-in 404 page - styled to match the demo app's
53/// branding (see `page_shell`'s own doc comment for the exact source of
54/// the color tokens/copy this mirrors).
55pub fn default_not_found_html() -> String {
56    page_shell(
57        "404",
58        "Page not found",
59        "The page you're looking for doesn't exist, or it's moved somewhere else.",
60    )
61}
62
63/// The framework's own built-in 500 page. Production-mode only - debug
64/// mode (`APP_DEBUG=true`) never reaches this; see `error::debug_page`.
65pub fn default_internal_html() -> String {
66    page_shell(
67        "500",
68        "Something went wrong",
69        "An unexpected error occurred on our end. We're looking into it.",
70    )
71}
72
73/// Self-contained (no external CSS/JS, no build step) - same reasoning as
74/// `error::debug_page`: this has to render standalone even as the very
75/// first response a broken app ever produces. Colors, font stack, the
76/// ">_" brand mark, and the footer credit line are copied from `demo/
77/// public/styles/style.css`'s `:root`/`[data-theme="dark"]` tokens and
78/// `demo/resources/views/layouts/app.blade.xr`'s header/footer markup -
79/// the actual reference the "look like the demo app" ask points at. Unlike
80/// the demo layout's own cookie-persisted `data-theme` (which needs a
81/// `CookieJar` this call site doesn't have - see the module doc comment),
82/// light/dark here is `prefers-color-scheme`, decided by the browser with
83/// no server-side state at all.
84fn page_shell(status: &str, title: &str, message: &str) -> String {
85    let html = format!(
86        r#"<!doctype html>
87<html lang="en">
88<head>
89<meta charset="utf-8">
90<meta name="viewport" content="width=device-width, initial-scale=1">
91<title>{status} {title}</title>
92<style>
93  :root {{
94    --ink: #202124; --muted: #6b6d73; --paper: #fffdf9; --canvas: #f4f0e8;
95    --line: #e4ddd2; --brand: #f4513d; --brand-dark: #cf3628;
96  }}
97  @media (prefers-color-scheme: dark) {{
98    :root {{
99      --ink: #f6f1e8; --muted: #b8b1a8; --paper: #272522; --canvas: #181716;
100      --line: #45413b; --brand: #ff735f; --brand-dark: #ff8a79;
101    }}
102  }}
103  * {{ box-sizing: border-box; }}
104  body {{
105    margin: 0; min-height: 100vh; display: flex; flex-direction: column;
106    color: var(--ink); background: var(--canvas);
107    font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
108    line-height: 1.5;
109  }}
110  a {{ color: inherit; }}
111  main {{
112    flex: 1; display: flex; flex-direction: column; align-items: center;
113    justify-content: center; text-align: center; padding: 40px 20px;
114  }}
115  .brand {{
116    display: inline-flex; gap: 10px; align-items: center; margin-bottom: 32px;
117    font-size: 1.15rem; font-weight: 800; letter-spacing: -.04em; text-decoration: none;
118  }}
119  .brand-mark {{
120    display: grid; place-items: center; width: 30px; height: 30px; color: #fff;
121    background: var(--brand); border-radius: 9px 9px 9px 2px;
122    font-family: ui-monospace, monospace; font-size: .9rem;
123  }}
124  .status {{
125    font-size: clamp(3rem, 10vw, 5rem); font-weight: 800; letter-spacing: -.03em;
126    color: var(--brand); margin: 0;
127  }}
128  h1 {{ font-size: 1.5rem; margin: 8px 0 12px; }}
129  p.message {{ color: var(--muted); max-width: 40ch; margin: 0 0 28px; }}
130  .home-link {{
131    display: inline-block; padding: 12px 22px; border-radius: 10px; font-weight: 700;
132    color: #fff; background: var(--brand); text-decoration: none;
133  }}
134  .home-link:hover {{ background: var(--brand-dark); }}
135  footer {{
136    padding: 24px 0 34px; text-align: center; color: var(--muted);
137    font-size: .82rem; border-top: 1px solid var(--line);
138  }}
139  .wallaby {{ color: var(--brand); font-weight: 700; }}
140</style>
141</head>
142<body>
143<main>
144  <a class="brand" href="/"><span class="brand-mark">&gt;_</span><span>larust</span></a>
145  <p class="status">{status}</p>
146  <h1>{title}</h1>
147  <p class="message">{message}</p>
148  <a class="home-link" href="/">Go back home</a>
149</main>
150<footer>Larust by <span class="wallaby">Wallaby Designs</span></footer>
151</body>
152</html>"#
153    );
154    if dev_reload_enabled() {
155        inject_dev_reload_script(html)
156    } else {
157        html
158    }
159}
160
161/// Checked once per process (not once per request, since these pages are
162/// themselves only ever rendered once - see this module's own doc comment
163/// on `ErrorPages`) - `xr dev` sets `LARUST_DEV_RELOAD` only on the child
164/// process it spawns itself, so this is `false` for the lifetime of any
165/// normal `cargo run`. Independent of - and a deliberate small duplicate
166/// of - `larust_view::runtime`'s own identically-named check: this crate
167/// has no dependency on `larust-view` at all (see this module's own doc
168/// comment), so sharing the one-line check isn't worth a new cross-crate
169/// edge for.
170fn dev_reload_enabled() -> bool {
171    static ENABLED: OnceLock<bool> = OnceLock::new();
172    *ENABLED.get_or_init(|| std::env::var_os("LARUST_DEV_RELOAD").is_some())
173}
174
175/// A small, independent copy of `larust_view::runtime`'s own dev-reload
176/// client script, scoped to what the framework's own fallback error pages
177/// specifically need - see that module's own copy for the fuller version
178/// (asset-reload handling included) real Blade-rendered pages get. Kept
179/// separate rather than shared for the same reason `dev_reload_enabled`
180/// above is duplicated: no `larust-view` dependency from this crate.
181///
182/// Listens for two independent signals over the same `/__larust_dev` SSE
183/// connection every dev-reload-enabled page already opens:
184/// - **Reconnect** (`es.onopen` firing a second time) - a real handoff
185///   happened, so a fresh reload picks up whatever page this route now
186///   resolves to (which may no longer be a 404 at all).
187/// - **`build-status`** (`crate::dev_reload::broadcast_build_status`) - a
188///   rebuild is in flight or just failed, for every rebuild after the
189///   first one, the whole time the *old*, still-good process (this one)
190///   keeps serving - see `dev_reload`'s own module doc comment for why
191///   that means this page could otherwise sit there looking like a
192///   confident, final answer instead of a stale one.
193const DEV_RELOAD_SCRIPT: &str = r#"<script>
194(function () {
195  var opened = false;
196  var es = new EventSource('/__larust_dev');
197  es.onopen = function () {
198    // `location.replace`, not `location.reload` - `reload()` reissues the
199    // request with whatever HTTP method got this page here in the first
200    // place. A page that's the direct render of a POST (no redirect after
201    // it - a real, common pattern) would then silently *resubmit* that
202    // POST the instant any unrelated file save elsewhere triggers a
203    // rebuild, with a CSRF token that's had no reason to still be valid -
204    // landing on a bare, unstyled 419 with no way back. `replace` always
205    // performs a plain GET navigation instead, the standard technique for
206    // getting fresh content without resubmitting.
207    if (opened) location.replace(location.href);
208    opened = true;
209  };
210  es.addEventListener('build-status', function (event) {
211    var banner = document.getElementById('__larust_build_banner');
212    if (!banner) {
213      banner = document.createElement('div');
214      banner.id = '__larust_build_banner';
215      // Fixed to the *bottom*, not the top - a top banner would cover a
216      // page's own nav/header and get in the way of actually using the
217      // still-serving old page while a rebuild is in flight, defeating the
218      // whole point of showing it non-disruptively in the first place.
219      banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:2147483647;' +
220        'padding:.6rem 1rem;color:#fff;background:#f4513d;' +
221        'font:600 13px/1.4 system-ui,sans-serif;text-align:center;';
222      document.body.append(banner);
223    }
224    banner.textContent = event.data === 'failed'
225      ? 'Build failed - check your terminal'
226      : 'Rebuilding… this page may be stale until the new build is live.';
227  });
228})();
229</script>"#;
230
231/// Injects [`DEV_RELOAD_SCRIPT`] just before `</body>` - `page_shell`'s own
232/// template always has one, so the "no `</body>` found" fallback
233/// `larust_view::runtime::inject_dev_reload_script` needs for an arbitrary
234/// caller-supplied page never actually applies here, but the function
235/// stays total (never panics on unexpected input) rather than assuming
236/// that invariant.
237fn inject_dev_reload_script(html: String) -> String {
238    match html.find("</body>") {
239        Some(index) => {
240            let mut out = String::with_capacity(html.len() + DEV_RELOAD_SCRIPT.len());
241            out.push_str(&html[..index]);
242            out.push_str(DEV_RELOAD_SCRIPT);
243            out.push_str(&html[index..]);
244            out
245        }
246        None => html + DEV_RELOAD_SCRIPT,
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn default_not_found_html_carries_the_404_status_and_brand_mark() {
256        let html = default_not_found_html();
257        assert!(html.contains("404"));
258        assert!(html.contains("Page not found"));
259        assert!(html.contains("larust"));
260    }
261
262    #[test]
263    fn default_internal_html_carries_the_500_status_and_no_leaked_detail() {
264        let html = default_internal_html();
265        assert!(html.contains("500"));
266        assert!(html.contains("Something went wrong"));
267        // The whole point of this page: it's a static, canned message -
268        // never anything sourced from a real error's own detail.
269        assert!(!html.contains("Caused by"));
270    }
271
272    // `inject_dev_reload_script` is tested directly (a pure function, no
273    // `LARUST_DEV_RELOAD` env var involved) rather than through
274    // `default_not_found_html`/`dev_reload_enabled` - the latter caches its
275    // env-var read in a process-wide `OnceLock` on first call, so toggling
276    // the env var from a test would be order-dependent on whatever other
277    // test in this binary happened to read it first (the exact hazard
278    // `larust_core::config()`'s own "first-writer-wins" doc comment warns
279    // about for the identical pattern elsewhere in this crate).
280
281    #[test]
282    fn inject_dev_reload_script_is_placed_before_closing_body_tag() {
283        let html = "<html><body><h1>hi</h1></body></html>".to_string();
284        let out = inject_dev_reload_script(html);
285        assert!(out.contains("EventSource('/__larust_dev')"));
286        let script_pos = out.find("<script>").unwrap();
287        let body_close_pos = out.find("</body>").unwrap();
288        assert!(script_pos < body_close_pos);
289        assert!(out.ends_with("</html>"));
290    }
291
292    #[test]
293    fn inject_dev_reload_script_listens_for_a_named_build_status_event() {
294        let html = "<html><body></body></html>".to_string();
295        let out = inject_dev_reload_script(html);
296        assert!(out.contains("addEventListener('build-status'"));
297        assert!(out.contains("__larust_build_banner"));
298        assert!(out.contains("Build failed"));
299    }
300}