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