Skip to main content

ecr_server/
web.rs

1use axum::http::StatusCode;
2use axum::response::{IntoResponse, Response};
3use axum::Router;
4use std::path::{Path, PathBuf};
5use tower_http::services::{ServeDir, ServeFile};
6
7/// Where the built web client lives.
8///
9/// Serving the UI from the same origin as the API is what makes the browser
10/// case work with no configuration: CORS never enters the picture, and the
11/// client defaults its API base to `location.origin`.
12pub fn locate(explicit: Option<PathBuf>) -> Option<PathBuf> {
13    if let Some(dir) = explicit {
14        return dir.join("index.html").is_file().then_some(dir);
15    }
16
17    if let Some(dir) = std::env::var_os("ECR_WEB_DIR").map(PathBuf::from) {
18        if dir.join("index.html").is_file() {
19            return Some(dir);
20        }
21    }
22
23    candidates()
24        .into_iter()
25        .find(|d| d.join("index.html").is_file())
26}
27
28fn candidates() -> Vec<PathBuf> {
29    let mut out = Vec::new();
30
31    if let Ok(cwd) = std::env::current_dir() {
32        out.push(cwd.join("web/dist"));
33        out.push(cwd.join("dist"));
34    }
35
36    // Beside or above the binary, which covers `cargo run` from the workspace
37    // root and an installed layout alike.
38    if let Ok(exe) = std::env::current_exe() {
39        for ancestor in exe.ancestors().skip(1).take(4) {
40            out.push(ancestor.join("web/dist"));
41            out.push(ancestor.join("share/ecr/web"));
42        }
43    }
44
45    out
46}
47
48/// Serves the client, falling back to `index.html` so client-side routes and a
49/// hard refresh both work. API routes are matched first by the caller.
50pub fn router(dir: &Path) -> Router {
51    let index = dir.join("index.html");
52
53    Router::new().fallback_service(
54        ServeDir::new(dir)
55            .append_index_html_on_directories(true)
56            .fallback(ServeFile::new(index)),
57    )
58}
59
60pub async fn missing() -> Response {
61    (
62        StatusCode::NOT_FOUND,
63        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
64        MISSING_PAGE,
65    )
66        .into_response()
67}
68
69const MISSING_PAGE: &str = r#"<!doctype html>
70<meta charset="utf-8">
71<title>ecr — client not built</title>
72<style>
73  body { background:#1a1b26; color:#c0caf5; font-family:ui-monospace,monospace;
74         display:grid; place-items:center; height:100vh; margin:0; }
75  div { max-width:34rem; padding:2rem; }
76  h1 { color:#7aa2f7; font-size:1.1rem; }
77  code { background:#1f2335; padding:.15rem .4rem; border-radius:.2rem; color:#9ece6a; }
78  p { line-height:1.7; }
79</style>
80<div>
81  <h1>The web client has not been built</h1>
82  <p>The API is running here, but there is no <code>web/dist</code> to serve.</p>
83  <p>Build it with <code>just build-web</code>, then reload. For hot reload
84     during development run <code>just dev</code> and use
85     <code>http://localhost:1420</code> instead.</p>
86</div>
87"#;
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn an_explicit_directory_without_an_index_is_rejected() {
95        let dir = tempfile::tempdir().unwrap();
96        assert_eq!(locate(Some(dir.path().to_path_buf())), None);
97    }
98
99    #[test]
100    fn an_explicit_directory_with_an_index_is_accepted() {
101        let dir = tempfile::tempdir().unwrap();
102        std::fs::write(dir.path().join("index.html"), "<html>").unwrap();
103
104        assert_eq!(
105            locate(Some(dir.path().to_path_buf())),
106            Some(dir.path().to_path_buf())
107        );
108    }
109
110    #[test]
111    fn candidates_include_the_workspace_layout() {
112        let paths = candidates();
113        assert!(paths.iter().any(|p| p.ends_with("web/dist")), "{paths:?}");
114    }
115
116    #[test]
117    fn the_missing_page_explains_how_to_fix_it() {
118        assert!(MISSING_PAGE.contains("just build-web"));
119        assert!(MISSING_PAGE.contains("just dev"));
120    }
121}