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;
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/// Resolve the frontend directory. `$PHRONA_FRONTEND_DIR` overrides the
21/// compile-time default so packaged binaries can serve the assets from a
22/// stable path.
23pub fn frontend_dir() -> std::path::PathBuf {
24    std::env::var_os("PHRONA_FRONTEND_DIR")
25        .map(std::path::PathBuf::from)
26        .unwrap_or_else(|| std::path::PathBuf::from(FRONTEND_DIR))
27}
28
29fn embedded_asset(name: &str) -> Option<&'static [u8]> {
30    match name {
31        "index.html" => Some(EMBEDDED_INDEX.as_bytes()),
32        "style.css" => Some(EMBEDDED_CSS.as_bytes()),
33        "app.js" => Some(EMBEDDED_JS.as_bytes()),
34        _ => None,
35    }
36}
37
38fn ok_response(body: Vec<u8>, mime: &'static str) -> Response {
39    Response::builder()
40        .status(StatusCode::OK)
41        .header(header::CONTENT_TYPE, mime)
42        .body(Body::from(body))
43        .unwrap_or_else(|_| Response::new(Body::empty()))
44}
45
46fn serve(name: &str, mime: &'static str) -> Response {
47    match std::fs::read(frontend_dir().join(name)) {
48        Ok(body) => ok_response(body, mime),
49        Err(_) => match embedded_asset(name) {
50            Some(asset) => ok_response(asset.to_vec(), mime),
51            None => Response::builder()
52                .status(StatusCode::NOT_FOUND)
53                .body(Body::from("not found"))
54                .unwrap(),
55        },
56    }
57}
58
59/// Serve the SPA: known assets by path, everything else falls back to the
60/// app shell (client-side routing is not used, so this also serves "/").
61pub async fn index(req: axum::extract::Request) -> Response {
62    let p = req.uri().path().trim_start_matches('/');
63    match p {
64        "" => serve("index.html", "text/html; charset=utf-8"),
65        "style.css" => serve("style.css", "text/css; charset=utf-8"),
66        "app.js" => serve("app.js", "text/javascript; charset=utf-8"),
67        _ => serve("index.html", "text/html; charset=utf-8"),
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn embedded_assets_cover_the_ui() {
77        assert!(!EMBEDDED_INDEX.is_empty());
78        assert!(EMBEDDED_INDEX.contains("<html") || EMBEDDED_INDEX.contains("<!doctype"));
79        assert!(!EMBEDDED_CSS.is_empty());
80        assert!(!EMBEDDED_JS.is_empty());
81        for name in ["index.html", "style.css", "app.js"] {
82            assert!(embedded_asset(name).is_some(), "{name} missing");
83        }
84        assert!(embedded_asset("favicon.ico").is_none());
85    }
86
87    #[tokio::test]
88    async fn fallback_serves_embedded_when_dir_missing() {
89        let dir = frontend_dir();
90        let gone = dir.join("__definitely_missing__");
91        assert!(std::fs::read(&gone).is_err());
92        let resp = serve("app.js", "text/javascript; charset=utf-8");
93        assert_eq!(resp.status(), StatusCode::OK);
94        let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
95            .await
96            .unwrap();
97        assert!(!body.is_empty());
98        assert_eq!(body, EMBEDDED_JS.as_bytes());
99    }
100}