Skip to main content

kasl_server/
web.rs

1//! Serving the web UI.
2//!
3//! The built SPA is compiled into the binary (ADR 0012): a self-hosted install
4//! is one file, and the UI can never be from a different build than the API it
5//! calls. In development the app is served by Vite, which proxies `/api` here,
6//! so this path is what a release does rather than what a developer waits for.
7//!
8//! The fallback is the whole routing story. A single-page app owns its own
9//! paths, so anything the API has not claimed is answered with `index.html`
10//! and the browser decides what to draw. Without that, a refresh on `/privacy`
11//! would 404 - the server has no such file, and only the app knows the route.
12
13use axum::{
14    http::{StatusCode, Uri, header},
15    response::{IntoResponse, Response},
16};
17use rust_embed::Embed;
18
19/// The contents of `frontend/dist`, as of compile time.
20///
21/// `allow_missing` because the directory is a build output: it is gitignored,
22/// and `pnpm build` empties it before writing, so nothing committed can keep
23/// it in place. Without this a clean checkout would not compile at all, and a
24/// Rust-only contributor would be stopped by a missing Node toolchain.
25///
26/// A binary built that way is honest about it - every path answers "no web UI
27/// was built into this binary" rather than an empty page.
28#[derive(Embed)]
29#[folder = "frontend/dist"]
30#[allow_missing = true]
31struct Assets;
32
33/// Answers a request that no API route matched.
34///
35/// An asset if there is one at that path, `index.html` otherwise.
36pub async fn serve(uri: Uri) -> Response {
37    let path = uri.path().trim_start_matches('/');
38
39    // An empty path is the app's root, which is the document itself.
40    if path.is_empty() {
41        return index_html();
42    }
43
44    match Assets::get(path) {
45        Some(file) => {
46            // The type is decided at compile time for this exact file, by the
47            // same crate that embedded it - not guessed again here from a path
48            // that could disagree with what was stored.
49            let mime = file.metadata.mimetype().to_string();
50            (
51                StatusCode::OK,
52                [
53                    (header::CONTENT_TYPE, mime),
54                    // Vite fingerprints asset filenames, so a name that exists
55                    // never changes content and can be cached hard. The
56                    // document itself is not in this branch.
57                    (header::CACHE_CONTROL, cache_control(path).to_string()),
58                ],
59                file.data,
60            )
61                .into_response()
62        }
63        // Not an asset: a client-side route, and the app resolves it.
64        None => index_html(),
65    }
66}
67
68/// The application document.
69///
70/// Answered for every unmatched path, including ones the app will itself treat
71/// as unknown - the server cannot tell a mistyped URL from a valid route it
72/// has never heard of, and only the app can.
73fn index_html() -> Response {
74    match Assets::get("index.html") {
75        Some(file) => (
76            StatusCode::OK,
77            [
78                (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
79                // Never cached: it names the fingerprinted bundles, so a stale
80                // copy would keep pointing at a build that no longer exists.
81                (header::CACHE_CONTROL, "no-cache".to_string()),
82            ],
83            file.data,
84        )
85            .into_response(),
86        // A binary built without a frontend. Says so plainly rather than
87        // answering an empty 200, which reads as "the app is broken".
88        None => (
89            StatusCode::NOT_FOUND,
90            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
91            "no web UI was built into this binary; the API is at /api/v1",
92        )
93            .into_response(),
94    }
95}
96
97/// How long a browser may keep a file.
98///
99/// Fingerprinted names (`index-X9yN6mCH.js`) are immutable by construction;
100/// anything else keeps a short leash because its name says nothing about its
101/// contents.
102fn cache_control(path: &str) -> &'static str {
103    if path.starts_with("assets/") {
104        "public, max-age=31536000, immutable"
105    } else {
106        "public, max-age=3600"
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn fingerprinted_bundles_are_cached_forever_and_others_are_not() {
116        // The failure this guards is invisible and lasts a year: caching
117        // `index.html` immutably would pin a browser to a build that has been
118        // replaced, with no way to recover but a hard reload.
119        assert_eq!(cache_control("assets/index-X9yN6mCH.js"), "public, max-age=31536000, immutable");
120        assert_eq!(cache_control("favicon.svg"), "public, max-age=3600");
121        assert_eq!(cache_control("index.html"), "public, max-age=3600");
122    }
123
124    /// Whether this binary carries a built frontend at all.
125    ///
126    /// The suite runs both ways - a developer who has never run `pnpm build`
127    /// and CI, which builds it first - so the assertions below say which case
128    /// they are in rather than accepting either outcome for the same input.
129    fn frontend_was_embedded() -> bool {
130        Assets::get("index.html").is_some()
131    }
132
133    #[tokio::test]
134    async fn a_client_side_route_is_answered_with_the_document() {
135        // A refresh on `/privacy` must not 404: the server has no such file,
136        // and only the app knows the route.
137        let response = serve("/privacy".parse().unwrap()).await;
138        let content_type = response
139            .headers()
140            .get(header::CONTENT_TYPE)
141            .and_then(|value| value.to_str().ok())
142            .unwrap_or_default()
143            .to_string();
144
145        if frontend_was_embedded() {
146            assert_eq!(response.status(), StatusCode::OK, "a built app must answer its own routes");
147            assert_eq!(content_type, "text/html; charset=utf-8");
148        } else {
149            // Without a frontend there is nothing to answer with, and saying
150            // so beats an empty 200 that reads as a broken app.
151            assert_eq!(response.status(), StatusCode::NOT_FOUND);
152            assert_eq!(content_type, "text/plain; charset=utf-8");
153        }
154    }
155
156    #[tokio::test]
157    async fn the_root_is_the_document_too() {
158        let response = serve("/".parse().unwrap()).await;
159        let expected = if frontend_was_embedded() { StatusCode::OK } else { StatusCode::NOT_FOUND };
160        assert_eq!(response.status(), expected);
161    }
162
163    #[tokio::test]
164    async fn an_embedded_asset_is_served_with_its_own_type() {
165        // Skipped rather than faked when there is no build: asserting on an
166        // asset that does not exist would test the fallback a second time.
167        let Some(name) = Assets::iter().find(|name| name.ends_with(".js")) else {
168            eprintln!("skipped: no frontend build is embedded in this binary");
169            return;
170        };
171
172        let response = serve(format!("/{name}").parse().unwrap()).await;
173        assert_eq!(response.status(), StatusCode::OK);
174
175        let content_type = response.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
176        assert!(content_type.contains("javascript"), "a bundle served as `{content_type}` will not execute");
177
178        let cache = response.headers().get(header::CACHE_CONTROL).unwrap().to_str().unwrap();
179        assert!(cache.contains("immutable"), "a fingerprinted bundle should be cached hard, got `{cache}`");
180    }
181}