Skip to main content

phrona_api/
frontend.rs

1//! Serves the static web frontend.
2//!
3//! `index.html`, `style.css` and `app.js` live in this crate's `assets/`
4//! directory and are served from disk when present, so editing does not
5//! require rebuilds. If `$PHRONA_FRONTEND_DIR` is unset or unreadable, or the
6//! local `assets/` folder is missing (standalone release binaries,
7//! containers), the assets embedded at compile time via `include_str!` are
8//! served instead — UI routes never 404.
9
10use axum::body::{Body, Bytes};
11use axum::http::{StatusCode, header};
12use axum::response::Response;
13
14const FRONTEND_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets");
15
16const EMBEDDED_INDEX: &str = include_str!("../assets/index.html");
17const EMBEDDED_CSS: &str = include_str!("../assets/style.css");
18const EMBEDDED_JS: &str = include_str!("../assets/app.js");
19
20/// Minimal inline SVG favicon (magnifier over the phrona "p" roundel), so
21/// `/favicon.ico` answers with an icon instead of the SPA shell.
22const FAVICON_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4f6df5"/><circle cx="14" cy="14" r="7" fill="none" stroke="#fff" stroke-width="3"/><line x1="19.5" y1="19.5" x2="26" y2="26" stroke="#fff" stroke-width="3" stroke-linecap="round"/></svg>"##;
23
24/// Resolve the frontend directory. `$PHRONA_FRONTEND_DIR` overrides the
25/// compile-time default so packaged binaries can serve the assets from a
26/// stable path.
27pub fn frontend_dir() -> std::path::PathBuf {
28    std::env::var_os("PHRONA_FRONTEND_DIR")
29        .map(std::path::PathBuf::from)
30        .unwrap_or_else(|| std::path::PathBuf::from(FRONTEND_DIR))
31}
32
33fn embedded_asset(name: &str) -> Option<&'static [u8]> {
34    match name {
35        "index.html" => Some(EMBEDDED_INDEX.as_bytes()),
36        "style.css" => Some(EMBEDDED_CSS.as_bytes()),
37        "app.js" => Some(EMBEDDED_JS.as_bytes()),
38        _ => None,
39    }
40}
41
42fn ok_response(body: Body, mime: &'static str) -> Response {
43    Response::builder()
44        .status(StatusCode::OK)
45        .header(header::CONTENT_TYPE, mime)
46        .body(body)
47        .unwrap_or_else(|_| Response::new(Body::empty()))
48}
49
50fn serve(name: &str, mime: &'static str) -> Response {
51    match std::fs::read(frontend_dir().join(name)) {
52        Ok(body) => ok_response(Body::from(body), mime),
53        // embedded assets are `&'static [u8]`: served without copying,
54        // instead of cloning them into a fresh Vec per request
55        Err(_) => match embedded_asset(name) {
56            Some(asset) => ok_response(Body::from(Bytes::from_static(asset)), mime),
57            None => Response::builder()
58                .status(StatusCode::NOT_FOUND)
59                .body(Body::from("not found"))
60                .unwrap(),
61        },
62    }
63}
64
65/// Serve the SPA: known assets by path, everything else falls back to the
66/// app shell (client-side routing is not used, so this also serves "/").
67pub async fn index(req: axum::extract::Request) -> Response {
68    let p = req.uri().path().trim_start_matches('/');
69    match p {
70        "" => serve("index.html", "text/html; charset=utf-8"),
71        "style.css" => serve("style.css", "text/css; charset=utf-8"),
72        "app.js" => serve("app.js", "text/javascript; charset=utf-8"),
73        // browsers hit this blindly; answer with a real icon instead of
74        // falling through to the SPA shell
75        "favicon.ico" | "favicon.svg" => ok_response(
76            Body::from(Bytes::from_static(FAVICON_SVG.as_bytes())),
77            "image/svg+xml",
78        ),
79        _ => serve("index.html", "text/html; charset=utf-8"),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn embedded_assets_cover_the_ui() {
89        assert!(!EMBEDDED_INDEX.is_empty());
90        assert!(EMBEDDED_INDEX.contains("<html") || EMBEDDED_INDEX.contains("<!doctype"));
91        assert!(!EMBEDDED_CSS.is_empty());
92        assert!(!EMBEDDED_JS.is_empty());
93        for name in ["index.html", "style.css", "app.js"] {
94            assert!(embedded_asset(name).is_some(), "{name} missing");
95        }
96        assert!(embedded_asset("favicon.ico").is_none());
97    }
98
99    #[tokio::test]
100    async fn fallback_serves_embedded_when_dir_missing() {
101        let dir = frontend_dir();
102        let gone = dir.join("__definitely_missing__");
103        assert!(std::fs::read(&gone).is_err());
104        let resp = serve("app.js", "text/javascript; charset=utf-8");
105        assert_eq!(resp.status(), StatusCode::OK);
106        let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
107            .await
108            .unwrap();
109        assert!(!body.is_empty());
110        assert_eq!(body, EMBEDDED_JS.as_bytes());
111    }
112}