Skip to main content

construct/gateway/
static_files.rs

1//! Static file serving for the embedded web dashboard.
2//!
3//! Uses `rust-embed` to bundle the `web/dist/` directory into the binary at compile time.
4
5use axum::{
6    extract::State,
7    http::{StatusCode, Uri, header},
8    response::{IntoResponse, Response},
9};
10use rust_embed::Embed;
11
12use super::AppState;
13
14#[derive(Embed)]
15#[folder = "web/dist/"]
16struct WebAssets;
17
18/// Serve static files from `/_app/*` path
19pub async fn handle_static(uri: Uri) -> Response {
20    let path = uri
21        .path()
22        .strip_prefix("/_app/")
23        .unwrap_or(uri.path())
24        .trim_start_matches('/');
25
26    serve_embedded_file(path)
27}
28
29/// SPA fallback: serve index.html for any non-API, non-static GET request.
30/// Injects `window.__CONSTRUCT_BASE__` so the frontend knows the path prefix.
31pub async fn handle_spa_fallback(State(state): State<AppState>) -> Response {
32    let Some(content) = WebAssets::get("index.html") else {
33        return (
34            StatusCode::SERVICE_UNAVAILABLE,
35            "Web dashboard not available. Build it with: cd web && npm ci && npm run build",
36        )
37            .into_response();
38    };
39
40    let html = String::from_utf8_lossy(&content.data);
41
42    // Inject path prefix for the SPA and rewrite asset paths in the HTML
43    let html = if state.path_prefix.is_empty() {
44        html.into_owned()
45    } else {
46        let pfx = &state.path_prefix;
47        // JSON-encode the prefix to safely embed in a <script> block
48        let json_pfx = serde_json::to_string(pfx).unwrap_or_else(|_| "\"\"".to_string());
49        let script = format!("<script>window.__CONSTRUCT_BASE__={json_pfx};</script>");
50        // Rewrite absolute /_app/ references so the browser requests {prefix}/_app/...
51        html.replace("/_app/", &format!("{pfx}/_app/"))
52            .replace("<head>", &format!("<head>{script}"))
53    };
54
55    (
56        StatusCode::OK,
57        [
58            (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
59            (header::CACHE_CONTROL, "no-cache".to_string()),
60        ],
61        html,
62    )
63        .into_response()
64}
65
66fn serve_embedded_file(path: &str) -> Response {
67    match WebAssets::get(path) {
68        Some(content) => {
69            let mime = mime_guess::from_path(path)
70                .first_or_octet_stream()
71                .to_string();
72
73            (
74                StatusCode::OK,
75                [
76                    (header::CONTENT_TYPE, mime),
77                    (
78                        header::CACHE_CONTROL,
79                        if path.contains("assets/") {
80                            // Hashed filenames — immutable cache
81                            "public, max-age=31536000, immutable".to_string()
82                        } else {
83                            // index.html etc — no cache
84                            "no-cache".to_string()
85                        },
86                    ),
87                ],
88                content.data.to_vec(),
89            )
90                .into_response()
91        }
92        None => (StatusCode::NOT_FOUND, "Not found").into_response(),
93    }
94}