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}