web.rs
| 1 | //! Serving the built web UI out of the binary. |
| 2 | //! |
| 3 | //! `rust-embed` compiles `web/dist` in for release builds, so a deployment is |
| 4 | //! still one file. In debug builds it reads from disk instead, so `vite build` |
| 5 | //! output is picked up without a `cargo` rebuild. |
| 6 | //! |
| 7 | //! Anything that is not an embedded asset falls back to `index.html`, because the |
| 8 | //! UI routes client-side and a deep link must not 404. |
| 9 | |
| 10 | use axum::http::{StatusCode, Uri, header}; |
| 11 | use axum::response::{IntoResponse, Response}; |
| 12 | |
| 13 | #[derive(rust_embed::Embed)] |
| 14 | #[folder = "../../web/dist"] |
| 15 | struct Assets; |
| 16 | |
| 17 | /// The message shown when the binary was built without a web UI. Silence here |
| 18 | /// would look like a broken deployment rather than a missing build step. |
| 19 | const NO_BUILD: &str = "the web UI is not built. Run `cd web && bun install && bun run build`."; |
| 20 | |
| 21 | pub async fn serve(uri: Uri) -> Response { |
| 22 | let path = uri.path().trim_start_matches('/'); |
| 23 | if let Some(response) = asset(path) { |
| 24 | return response; |
| 25 | } |
| 26 | // Never fall back for asset-shaped requests: a missing chunk answered with |
| 27 | // HTML turns a clear 404 into a confusing MIME-type error in the console. |
| 28 | if path.starts_with("assets/") { |
| 29 | return (StatusCode::NOT_FOUND, "not found").into_response(); |
| 30 | } |
| 31 | asset("index.html").unwrap_or_else(|| (StatusCode::NOT_FOUND, NO_BUILD).into_response()) |
| 32 | } |
| 33 | |
| 34 | fn asset(path: &str) -> Option<Response> { |
| 35 | let file = Assets::get(path)?; |
| 36 | let mime = mime_guess::from_path(path).first_or_octet_stream(); |
| 37 | // Vite content-hashes everything under assets/, so those are immutable. |
| 38 | // index.html must not be, or a deploy would never reach an open tab. |
| 39 | let cache = if path.starts_with("assets/") { |
| 40 | "public, max-age=31536000, immutable" |
| 41 | } else { |
| 42 | "no-cache" |
| 43 | }; |
| 44 | Some( |
| 45 | ( |
| 46 | [ |
| 47 | (header::CONTENT_TYPE, mime.as_ref()), |
| 48 | (header::CACHE_CONTROL, cache), |
| 49 | ], |
| 50 | file.data.into_owned(), |
| 51 | ) |
| 52 | .into_response(), |
| 53 | ) |
| 54 | } |
| 55 |