1use std::path::{Path, PathBuf};
2
3use actix_files as fs;
4use actix_web::{
5 dev::{fn_service, ServiceRequest, ServiceResponse},
6 web, App, HttpResponse, HttpServer,
7};
8use tracing::{error, info};
9
10use super::listeners::{build_bind_listeners, build_desktop_listeners, resolve_worker_count};
11use crate::app_state::AppState;
12use crate::config::{build_cors, build_security_headers};
13use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
14use crate::services::frontend_package::{
15 ensure_current_frontend_dir_in, has_embedded_frontend_package, resolve_frontend_package_path,
16};
17
18fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
19 let canonicalized = path
20 .canonicalize()
21 .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
22 if !canonicalized.is_dir() {
23 return Err(format!(
24 "Static path is not a directory: {}",
25 canonicalized.display()
26 ));
27 }
28 Ok(canonicalized)
29}
30
31fn resolve_runtime_static_dir(
32 bamboo_home_dir: &Path,
33 configured_static_dir: Option<PathBuf>,
34) -> Result<Option<PathBuf>, String> {
35 if let Some(path) = configured_static_dir {
36 let canonicalized = canonicalize_static_dir(&path)?;
37 info!(
38 "Serving static files from configured directory: {:?}",
39 canonicalized
40 );
41 return Ok(Some(canonicalized));
42 }
43
44 if !has_embedded_frontend_package() && resolve_frontend_package_path(None).is_none() {
45 info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
46 return Ok(None);
47 }
48
49 let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
50 .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
51 let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
52
53 if status.refreshed {
54 info!(
55 "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
56 frontend_dir.display(),
57 status.bundled_manifest.frontend_version,
58 status.bundled_manifest.bundle_hash
59 );
60 } else {
61 info!(
62 "Using existing Bamboo frontend assets at {} (version {}, hash {})",
63 frontend_dir.display(),
64 status.bundled_manifest.frontend_version,
65 status.bundled_manifest.bundle_hash
66 );
67 }
68
69 Ok(Some(frontend_dir))
70}
71
72pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
84 info!("Starting unified server in desktop mode...");
85
86 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
87
88 let app_state = web::Data::new(
89 AppState::new(bamboo_home_dir.clone())
90 .await
91 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
92 );
93 let workers = resolve_worker_count();
94
95 let app_factory = move || {
96 let mut app = App::new()
97 .app_data(app_state.clone())
98 .wrap(build_cors("127.0.0.1", port))
99 .configure(configure_routes); if let Some(static_path) = &static_dir {
102 let index_file = static_path.join("index.html");
103 info!("Serving static files from: {:?}", static_path);
104 app = app.service(
105 fs::Files::new("/", static_path)
106 .index_file("index.html")
107 .prefer_utf8(true)
108 .disable_content_disposition()
109 .default_handler(fn_service(move |req: ServiceRequest| {
110 let index_file = index_file.clone();
111 async move {
112 let path = req.path().to_string();
113 if path.starts_with("/api/")
114 || path.starts_with("/v1/")
115 || path.starts_with("/openai/")
116 || path.starts_with("/anthropic/")
117 || path.starts_with("/gemini/")
118 {
119 let response = HttpResponse::NotFound().finish();
120 return Ok(ServiceResponse::new(req.into_parts().0, response));
121 }
122
123 let (http_req, _) = req.into_parts();
124 match actix_files::NamedFile::open_async(index_file).await {
125 Ok(file) => Ok(ServiceResponse::new(
126 http_req.clone(),
127 file.into_response(&http_req),
128 )),
129 Err(_) => Ok(ServiceResponse::new(
130 http_req,
131 HttpResponse::NotFound().finish(),
132 )),
133 }
134 }
135 })),
136 );
137 }
138
139 app
140 };
141
142 let listeners = build_desktop_listeners(port)?;
143
144 let mut http = HttpServer::new(app_factory).workers(workers);
145 for (idx, listener) in listeners.into_iter().enumerate() {
146 http = http
147 .listen(listener)
148 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?;
149 }
150
151 let server = http.run();
152
153 info!("Unified server running on http://127.0.0.1:{port}");
154
155 if let Err(e) = server.await {
156 error!("Server error: {}", e);
157 return Err(format!("Server error: {e}"));
158 }
159
160 Ok(())
161}
162
163pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
177 run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
178}
179
180pub async fn run_with_bind_and_static(
202 bamboo_home_dir: PathBuf,
203 port: u16,
204 bind: &str,
205 static_dir: Option<PathBuf>,
206) -> Result<(), String> {
207 info!("Starting unified server on {}:{}...", bind, port);
208
209 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
210
211 let app_state = web::Data::new(
212 AppState::new(bamboo_home_dir.clone())
213 .await
214 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
215 );
216 let workers = resolve_worker_count();
217
218 let bind_for_cors = bind.to_string();
219 let app_factory = move || {
220 let mut app = App::new()
221 .app_data(web::JsonConfig::default().limit(25 * 1024 * 1024)) .app_data(web::PayloadConfig::new(30 * 1024 * 1024)) .app_data(app_state.clone())
226 .wrap(build_cors(&bind_for_cors, port))
227 .wrap(build_security_headers())
228 .configure(configure_routes_with_rate_limiting); if let Some(static_path) = &static_dir {
231 let index_file = static_path.join("index.html");
232 info!("Serving static files from: {:?}", static_path);
233 app = app.service(
234 fs::Files::new("/", static_path)
235 .index_file("index.html")
236 .prefer_utf8(true)
237 .disable_content_disposition()
238 .default_handler(fn_service(move |req: ServiceRequest| {
239 let index_file = index_file.clone();
240 async move {
241 let path = req.path().to_string();
242 if path.starts_with("/api/")
243 || path.starts_with("/v1/")
244 || path.starts_with("/openai/")
245 || path.starts_with("/anthropic/")
246 || path.starts_with("/gemini/")
247 {
248 let response = HttpResponse::NotFound().finish();
249 return Ok(ServiceResponse::new(req.into_parts().0, response));
250 }
251
252 let (http_req, _) = req.into_parts();
253 match actix_files::NamedFile::open_async(index_file).await {
254 Ok(file) => Ok(ServiceResponse::new(
255 http_req.clone(),
256 file.into_response(&http_req),
257 )),
258 Err(_) => Ok(ServiceResponse::new(
259 http_req,
260 HttpResponse::NotFound().finish(),
261 )),
262 }
263 }
264 })),
265 );
266 }
267
268 app
269 };
270
271 let listeners = build_bind_listeners(bind, port)?;
272
273 let mut http = HttpServer::new(app_factory).workers(workers);
274 for (idx, listener) in listeners.into_iter().enumerate() {
275 http = http
276 .listen(listener)
277 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?;
278 }
279
280 let server = http.run();
281
282 info!("Unified server running on http://{}:{}", bind, port);
283
284 if let Err(e) = server.await {
285 error!("Server error: {}", e);
286 return Err(format!("Server error: {e}"));
287 }
288
289 Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use tempfile::tempdir;
296
297 #[test]
298 fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
299 let bamboo_home = tempdir().unwrap();
300 let static_dir = tempdir().unwrap();
301 std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
302
303 let resolved =
304 resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
305 .expect("configured static dir should resolve")
306 .expect("configured static dir should be returned");
307
308 assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
309 }
310}