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 super::tls::build_rustls_config;
12use crate::app_state::AppState;
13use crate::config::{build_cors, build_rate_limiter, build_security_headers};
14use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
15use crate::services::frontend_package::{
16 ensure_current_frontend_dir_in, has_embedded_frontend_package, resolve_frontend_package_path,
17};
18use actix_governor::Governor;
19use bamboo_config::TlsConfig;
20
21fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
22 let canonicalized = path
23 .canonicalize()
24 .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
25 if !canonicalized.is_dir() {
26 return Err(format!(
27 "Static path is not a directory: {}",
28 canonicalized.display()
29 ));
30 }
31 Ok(canonicalized)
32}
33
34fn resolve_runtime_static_dir(
35 bamboo_home_dir: &Path,
36 configured_static_dir: Option<PathBuf>,
37) -> Result<Option<PathBuf>, String> {
38 if let Some(path) = configured_static_dir {
39 let canonicalized = canonicalize_static_dir(&path)?;
40 info!(
41 "Serving static files from configured directory: {:?}",
42 canonicalized
43 );
44 return Ok(Some(canonicalized));
45 }
46
47 if !has_embedded_frontend_package() && resolve_frontend_package_path(None).is_none() {
48 info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
49 return Ok(None);
50 }
51
52 let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
53 .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
54 let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
55
56 if status.refreshed {
57 info!(
58 "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
59 frontend_dir.display(),
60 status.bundled_manifest.frontend_version,
61 status.bundled_manifest.bundle_hash
62 );
63 } else {
64 info!(
65 "Using existing Bamboo frontend assets at {} (version {}, hash {})",
66 frontend_dir.display(),
67 status.bundled_manifest.frontend_version,
68 status.bundled_manifest.bundle_hash
69 );
70 }
71
72 Ok(Some(frontend_dir))
73}
74
75pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
87 run_with_tls(bamboo_home_dir, port, None).await
88}
89
90pub async fn run_with_tls(
94 bamboo_home_dir: PathBuf,
95 port: u16,
96 tls: Option<TlsConfig>,
97) -> Result<(), String> {
98 info!("Starting unified server in desktop mode...");
99
100 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
101
102 let app_state = web::Data::new(
103 AppState::new(bamboo_home_dir.clone())
104 .await
105 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
106 );
107 let app_state_for_shutdown = app_state.clone();
110 let workers = resolve_worker_count();
111
112 let app_factory = move || {
113 let mut app = App::new()
114 .app_data(app_state.clone())
115 .wrap(build_cors("127.0.0.1", port))
116 .wrap(actix_web::middleware::from_fn(
120 crate::config::add_asset_cache_headers,
121 ))
122 .configure(configure_routes); if let Some(static_path) = &static_dir {
125 let index_file = static_path.join("index.html");
126 info!("Serving static files from: {:?}", static_path);
127 app = app.service(
128 fs::Files::new("/", static_path)
129 .index_file("index.html")
130 .prefer_utf8(true)
131 .disable_content_disposition()
132 .default_handler(fn_service(move |req: ServiceRequest| {
133 let index_file = index_file.clone();
134 async move {
135 let path = req.path().to_string();
136 if path.starts_with("/api/")
137 || path.starts_with("/v1/")
138 || path.starts_with("/openai/")
139 || path.starts_with("/anthropic/")
140 || path.starts_with("/gemini/")
141 {
142 let response = HttpResponse::NotFound().finish();
143 return Ok(ServiceResponse::new(req.into_parts().0, response));
144 }
145
146 let (http_req, _) = req.into_parts();
147 match actix_files::NamedFile::open_async(index_file).await {
148 Ok(file) => Ok(ServiceResponse::new(
149 http_req.clone(),
150 file.into_response(&http_req),
151 )),
152 Err(_) => Ok(ServiceResponse::new(
153 http_req,
154 HttpResponse::NotFound().finish(),
155 )),
156 }
157 }
158 })),
159 );
160 }
161
162 app
163 };
164
165 let rustls_cfg = match &tls {
169 Some(tls) => Some(build_rustls_config(tls)?),
170 None => None,
171 };
172
173 let listeners = build_desktop_listeners(port)?;
174
175 let mut http = HttpServer::new(app_factory).workers(workers);
176 for (idx, listener) in listeners.into_iter().enumerate() {
177 http = match &rustls_cfg {
178 Some(cfg) => http
179 .listen_rustls_0_23(listener, cfg.clone())
180 .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
181 None => http
182 .listen(listener)
183 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
184 };
185 }
186
187 let server = http.run();
188
189 let scheme = if rustls_cfg.is_some() {
190 "https"
191 } else {
192 "http"
193 };
194 info!("Unified server running on {scheme}://127.0.0.1:{port}");
195
196 let result = server.await;
197
198 app_state_for_shutdown.shutdown().await;
203
204 if let Err(e) = result {
205 error!("Server error: {}", e);
206 return Err(format!("Server error: {e}"));
207 }
208
209 Ok(())
210}
211
212pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
226 run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
227}
228
229pub async fn run_with_bind_tls(
231 bamboo_home_dir: PathBuf,
232 port: u16,
233 bind: &str,
234 tls: Option<TlsConfig>,
235) -> Result<(), String> {
236 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, None, tls).await
237}
238
239pub async fn run_with_bind_and_static(
261 bamboo_home_dir: PathBuf,
262 port: u16,
263 bind: &str,
264 static_dir: Option<PathBuf>,
265) -> Result<(), String> {
266 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, static_dir, None).await
267}
268
269pub async fn run_with_bind_and_static_tls(
272 bamboo_home_dir: PathBuf,
273 port: u16,
274 bind: &str,
275 static_dir: Option<PathBuf>,
276 tls: Option<TlsConfig>,
277) -> Result<(), String> {
278 info!("Starting unified server on {}:{}...", bind, port);
279
280 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
281
282 let app_state = web::Data::new(
283 AppState::new(bamboo_home_dir.clone())
284 .await
285 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
286 );
287 let app_state_for_shutdown = app_state.clone();
290 let workers = resolve_worker_count();
291
292 let rate_limiter = build_rate_limiter();
296 let bind_for_cors = bind.to_string();
297 let app_factory = move || {
298 let mut app = App::new()
299 .app_data(web::JsonConfig::default().limit(25 * 1024 * 1024)) .app_data(web::PayloadConfig::new(30 * 1024 * 1024)) .app_data(app_state.clone())
304 .wrap(Governor::new(&rate_limiter))
305 .wrap(build_cors(&bind_for_cors, port))
306 .wrap(build_security_headers())
307 .wrap(actix_web::middleware::from_fn(
310 crate::config::add_asset_cache_headers,
311 ))
312 .configure(configure_routes_with_rate_limiting);
313
314 if let Some(static_path) = &static_dir {
315 let index_file = static_path.join("index.html");
316 info!("Serving static files from: {:?}", static_path);
317 app = app.service(
318 fs::Files::new("/", static_path)
319 .index_file("index.html")
320 .prefer_utf8(true)
321 .disable_content_disposition()
322 .default_handler(fn_service(move |req: ServiceRequest| {
323 let index_file = index_file.clone();
324 async move {
325 let path = req.path().to_string();
326 if path.starts_with("/api/")
327 || path.starts_with("/v1/")
328 || path.starts_with("/openai/")
329 || path.starts_with("/anthropic/")
330 || path.starts_with("/gemini/")
331 {
332 let response = HttpResponse::NotFound().finish();
333 return Ok(ServiceResponse::new(req.into_parts().0, response));
334 }
335
336 let (http_req, _) = req.into_parts();
337 match actix_files::NamedFile::open_async(index_file).await {
338 Ok(file) => Ok(ServiceResponse::new(
339 http_req.clone(),
340 file.into_response(&http_req),
341 )),
342 Err(_) => Ok(ServiceResponse::new(
343 http_req,
344 HttpResponse::NotFound().finish(),
345 )),
346 }
347 }
348 })),
349 );
350 }
351
352 app
353 };
354
355 let rustls_cfg = match &tls {
359 Some(tls) => Some(build_rustls_config(tls)?),
360 None => None,
361 };
362
363 let listeners = build_bind_listeners(bind, port)?;
364
365 let mut http = HttpServer::new(app_factory).workers(workers);
366 for (idx, listener) in listeners.into_iter().enumerate() {
367 http = match &rustls_cfg {
368 Some(cfg) => http
369 .listen_rustls_0_23(listener, cfg.clone())
370 .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
371 None => http
372 .listen(listener)
373 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
374 };
375 }
376
377 let server = http.run();
378
379 let scheme = if rustls_cfg.is_some() {
380 "https"
381 } else {
382 "http"
383 };
384 info!("Unified server running on {scheme}://{}:{}", bind, port);
385
386 let result = server.await;
387
388 app_state_for_shutdown.shutdown().await;
392
393 if let Err(e) = result {
394 error!("Server error: {}", e);
395 return Err(format!("Server error: {e}"));
396 }
397
398 Ok(())
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use tempfile::tempdir;
405
406 #[test]
407 fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
408 let bamboo_home = tempdir().unwrap();
409 let static_dir = tempdir().unwrap();
410 std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
411
412 let resolved =
413 resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
414 .expect("configured static dir should resolve")
415 .expect("configured static dir should be returned");
416
417 assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
418 }
419}