Skip to main content

mira/
ui.rs

1//! The UI, compiled into the binary.
2//!
3//! Principle 4 says a single binary with no operational overhead. A UI served
4//! from a sidecar, a CDN or an unpacked asset directory is a second thing to
5//! deploy, version and get out of sync with the API it talks to — so the built
6//! bundle is `include_bytes!`d and the binary grows by exactly the size of the
7//! files. Three of them, listed by name: Vite is configured with fixed output
8//! names and no code splitting, because content hashes in filenames would mean
9//! regenerating this table on every build.
10//!
11//! `dist/` is checked into git. That is the trade: `cargo build` never needs
12//! node, and `npm run build` is a step a developer takes before committing a UI
13//! change. The alternative — a build.rs that shells out to npm — makes every
14//! Rust build depend on a JavaScript toolchain to produce bytes that did not
15//! change.
16//!
17//! Freshness is an ETag over the bytes rather than a hash in the URL. The hash
18//! is computed once on first use, the browser sends it back on the next load,
19//! and an unchanged bundle costs three 304s — the ETag is per asset, so a page
20//! load revalidates each of the three — instead of 60 KB.
21
22use std::sync::LazyLock;
23
24use axum::Router;
25use axum::extract::Path;
26use axum::http::{HeaderMap, StatusCode, header};
27use axum::response::{IntoResponse, Response};
28use axum::routing::get;
29
30struct Asset {
31    name: &'static str,
32    mime: &'static str,
33    body: &'static [u8],
34    etag: LazyLock<String>,
35}
36
37macro_rules! asset {
38    ($name:literal, $mime:literal) => {{
39        // Bound once: naming the bytes keeps `include_bytes!` to a single
40        // expansion, so the file is not embedded twice.
41        const BODY: &[u8] = include_bytes!(concat!("../ui/dist/", $name));
42        Asset {
43            name: $name,
44            mime: $mime,
45            body: BODY,
46            etag: LazyLock::new(|| etag(BODY)),
47        }
48    }};
49}
50
51static ASSETS: [Asset; 3] = [
52    asset!("index.html", "text/html; charset=utf-8"),
53    asset!("app.js", "text/javascript; charset=utf-8"),
54    asset!("app.css", "text/css; charset=utf-8"),
55];
56
57pub fn router() -> Router {
58    Router::new()
59        .route("/", get(index))
60        .route("/{file}", get(file))
61}
62
63/// The web UI. One HTML document; every view lives under the location hash.
64async fn index(headers: HeaderMap) -> Response {
65    serve(&ASSETS[0], &headers)
66}
67
68/// One of the three assets, or nothing.
69///
70/// There is no SPA fallback here and there must not be one: every view in the
71/// app lives under the hash (`/#/logs`), so a *path* this table does not know is
72/// not a UI route that needs rescuing — it is a request for something that does
73/// not exist. Answering it with 200 and index.html made `/health`, `/metrics`
74/// and every probe path an operator might try report success in HTML, which is
75/// the one answer worse than a 404.
76async fn file(Path(file): Path<String>, headers: HeaderMap) -> Response {
77    match ASSETS.iter().find(|a| a.name == file) {
78        Some(a) => serve(a, &headers),
79        None => (StatusCode::NOT_FOUND, "not found\n").into_response(),
80    }
81}
82
83fn serve(a: &Asset, headers: &HeaderMap) -> Response {
84    let etag = a.etag.as_str();
85    // `no-cache` means revalidate, not "do not store": the browser keeps the
86    // bytes and asks whether they are still current. That is what makes the
87    // 304 path the common one after an upgrade.
88    let head = [
89        (header::CONTENT_TYPE, a.mime),
90        (header::ETAG, etag),
91        (header::CACHE_CONTROL, "no-cache"),
92    ];
93    if headers
94        .get(header::IF_NONE_MATCH)
95        .and_then(|v| v.to_str().ok())
96        .is_some_and(|v| v.split(',').any(|t| t.trim() == etag))
97    {
98        return (StatusCode::NOT_MODIFIED, head).into_response();
99    }
100    (head, a.body).into_response()
101}
102
103/// FNV-1a over the bytes, as a quoted ETag.
104///
105/// A hash of content that cannot change while the process runs needs no
106/// collision resistance — only that two different builds differ, which 64 bits
107/// of FNV gives for free and without a dependency.
108fn etag(body: &[u8]) -> String {
109    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
110    for b in body {
111        h ^= *b as u64;
112        h = h.wrapping_mul(0x0000_0100_0000_01b3);
113    }
114    format!("\"{h:016x}\"")
115}