zeroclaw_gateway/
static_files.rs1use axum::{
7 Json,
8 extract::State,
9 http::{StatusCode, Uri, header},
10 response::{IntoResponse, Response},
11};
12use std::path::PathBuf;
13
14use super::AppState;
15
16#[cfg(feature = "embedded-web")]
17use include_dir::{Dir, include_dir};
18
19#[cfg(feature = "embedded-web")]
20static EMBEDDED_WEB_DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist");
21
22pub async fn handle_static(State(state): State<AppState>, uri: Uri) -> Response {
24 let path = uri
25 .path()
26 .strip_prefix("/_app/")
27 .unwrap_or(uri.path())
28 .trim_start_matches('/');
29
30 #[cfg(feature = "embedded-web")]
31 if let Some(resp) = serve_embedded_file(path) {
32 return resp;
33 }
34
35 serve_fs_file(state.web_dist_dir.as_ref(), path).await
36}
37
38pub async fn handle_spa_fallback(State(state): State<AppState>, uri: Uri) -> Response {
41 if let Some(path) = api_fallback_path(uri.path(), &state.path_prefix) {
42 let body = serde_json::json!({
43 "error": "not_found",
44 "message": "No backend route matched this path.",
45 "path": path,
46 });
47 return (StatusCode::NOT_FOUND, Json(body)).into_response();
48 }
49
50 let Some(bytes) = load_index_html_bytes(state.web_dist_dir.as_ref()).await else {
51 return (
52 StatusCode::SERVICE_UNAVAILABLE,
53 "Web dashboard not available. Build the frontend with `cargo web build` \
54 (the supported entry point — it generates the TS API client and runs \
55 the Vite production build) and point gateway.web_dist_dir at the \
56 resulting web/dist. The daemon's API endpoints remain reachable \
57 independently of the dashboard.",
58 )
59 .into_response();
60 };
61
62 let html = String::from_utf8_lossy(&bytes);
63
64 let html = if state.path_prefix.is_empty() {
66 html.into_owned()
67 } else {
68 let pfx = &state.path_prefix;
69 let json_pfx = serde_json::to_string(pfx).unwrap_or_else(|_| "\"\"".to_string());
71 let script = format!("<script>window.__ZEROCLAW_BASE__={json_pfx};</script>");
72 html.replace("/_app/", &format!("{pfx}/_app/"))
74 .replace("<head>", &format!("<head>{script}"))
75 };
76
77 (
78 StatusCode::OK,
79 [
80 (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
81 (header::CACHE_CONTROL, "no-cache".to_string()),
82 ],
83 html,
84 )
85 .into_response()
86}
87
88fn api_fallback_path<'a>(path: &'a str, path_prefix: &str) -> Option<&'a str> {
89 let path = strip_path_prefix(path, path_prefix);
90 (path == "/api" || path.strip_prefix("/api/").is_some()).then_some(path)
91}
92
93fn strip_path_prefix<'a>(path: &'a str, path_prefix: &str) -> &'a str {
94 if path_prefix.is_empty() || path_prefix == "/" {
95 return path;
96 }
97
98 if path == path_prefix {
99 return "/";
100 }
101
102 path.strip_prefix(path_prefix)
103 .filter(|rest| rest.starts_with('/'))
104 .unwrap_or(path)
105}
106
107async fn load_index_html_bytes(dist_dir: Option<&PathBuf>) -> Option<Vec<u8>> {
108 #[cfg(feature = "embedded-web")]
109 if let Some(file) = EMBEDDED_WEB_DIST.get_file("index.html") {
110 return Some(file.contents().to_vec());
111 }
112
113 let dir = dist_dir?;
114 let index_path = dir.join("index.html");
115 tokio::fs::read(&index_path).await.ok()
116}
117
118async fn serve_fs_file(dist_dir: Option<&PathBuf>, path: &str) -> Response {
119 let Some(dir) = dist_dir else {
120 return (StatusCode::NOT_FOUND, "Not found").into_response();
121 };
122
123 if path.contains("..") {
125 return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
126 }
127
128 let file_path = dir.join(path);
129
130 match tokio::fs::read(&file_path).await {
131 Ok(content) => {
132 let mime = mime_guess::from_path(path)
133 .first_or_octet_stream()
134 .to_string();
135
136 (
137 StatusCode::OK,
138 [
139 (header::CONTENT_TYPE, mime),
140 (
141 header::CACHE_CONTROL,
142 if path.contains("assets/") {
143 "public, max-age=31536000, immutable".to_string()
145 } else {
146 "no-cache".to_string()
148 },
149 ),
150 ],
151 content,
152 )
153 .into_response()
154 }
155 Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
156 }
157}
158
159#[cfg(feature = "embedded-web")]
160fn serve_embedded_file(path: &str) -> Option<Response> {
161 if path.contains("..") {
162 return Some((StatusCode::BAD_REQUEST, "Invalid path").into_response());
163 }
164
165 let file = EMBEDDED_WEB_DIST.get_file(path)?;
166 let mime = mime_guess::from_path(path)
167 .first_or_octet_stream()
168 .to_string();
169 let cache = if path.contains("assets/") {
170 "public, max-age=31536000, immutable".to_string()
171 } else {
172 "no-cache".to_string()
173 };
174
175 Some(
176 (
177 StatusCode::OK,
178 [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
179 file.contents().to_vec(),
180 )
181 .into_response(),
182 )
183}