1use std::path::{Path, PathBuf};
2
3use actix_files as fs;
4use actix_web::{
5 dev::{fn_service, ServiceRequest, ServiceResponse},
6 web, App, HttpResponse,
7};
8use tracing::{error, info};
9
10use super::h1::build_h1_server;
11use super::listeners::{build_bind_listeners, build_desktop_listeners, resolve_worker_count};
12use super::tls::build_rustls_config;
13use crate::app_state::AppState;
14use crate::config::{
15 build_cors, build_rate_limiter, build_security_headers, is_loopback_bind,
16 require_limiter_for_nonloopback, wrap_governor_and_cors,
17};
18use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
19use crate::services::frontend_package::{
20 ensure_current_frontend_dir_in, frontend_package_env_is_configured,
21 has_embedded_frontend_package, resolve_frontend_package_path,
22};
23use bamboo_config::TlsConfig;
24
25fn is_api_path(path: &str) -> bool {
34 path.starts_with("/api/")
35 || path.starts_with("/v1/")
36 || path.starts_with("/v2/")
37 || path.starts_with("/openai/")
38 || path.starts_with("/anthropic/")
39 || path.starts_with("/gemini/")
40}
41
42fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
43 let canonicalized = path
44 .canonicalize()
45 .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
46 if !canonicalized.is_dir() {
47 return Err(format!(
48 "Static path is not a directory: {}",
49 canonicalized.display()
50 ));
51 }
52 Ok(canonicalized)
53}
54
55fn resolve_runtime_static_dir(
56 bamboo_home_dir: &Path,
57 configured_static_dir: Option<PathBuf>,
58) -> Result<Option<PathBuf>, String> {
59 if let Some(path) = configured_static_dir {
60 let canonicalized = canonicalize_static_dir(&path)?;
61 info!(
62 "Serving static files from configured directory: {:?}",
63 canonicalized
64 );
65 return Ok(Some(canonicalized));
66 }
67
68 if !has_embedded_frontend_package()
69 && !frontend_package_env_is_configured()
70 && resolve_frontend_package_path(None).is_none()
71 {
72 info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
73 return Ok(None);
74 }
75
76 let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
77 .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
78 let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
79
80 if status.refreshed {
81 info!(
82 "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
83 frontend_dir.display(),
84 status.bundled_manifest.frontend_version,
85 status.bundled_manifest.bundle_hash
86 );
87 } else {
88 info!(
89 "Using existing Bamboo frontend assets at {} (version {}, hash {})",
90 frontend_dir.display(),
91 status.bundled_manifest.frontend_version,
92 status.bundled_manifest.bundle_hash
93 );
94 }
95
96 Ok(Some(frontend_dir))
97}
98
99pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
111 run_with_tls(bamboo_home_dir, port, None).await
112}
113
114pub async fn run_with_tls(
118 bamboo_home_dir: PathBuf,
119 port: u16,
120 tls: Option<TlsConfig>,
121) -> Result<(), String> {
122 info!("Starting unified server in desktop mode...");
123
124 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
125
126 let app_state = web::Data::new(
127 AppState::new(bamboo_home_dir.clone())
128 .await
129 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
130 );
131 let app_state_for_shutdown = app_state.clone();
134 let workers = resolve_worker_count();
135
136 let app_factory = move || {
137 let mut app = super::web_service::with_body_limits(App::new())
142 .app_data(app_state.clone())
143 .wrap(build_cors("127.0.0.1", port))
144 .wrap(actix_web::middleware::from_fn(
148 crate::config::add_asset_cache_headers,
149 ))
150 .configure(configure_routes); if let Some(static_path) = &static_dir {
153 let index_file = static_path.join("index.html");
154 info!("Serving static files from: {:?}", static_path);
155 app = app.service(
156 fs::Files::new("/", static_path)
157 .index_file("index.html")
158 .prefer_utf8(true)
159 .disable_content_disposition()
160 .default_handler(fn_service(move |req: ServiceRequest| {
161 let index_file = index_file.clone();
162 async move {
163 let path = req.path().to_string();
164 if is_api_path(&path) {
165 let response = HttpResponse::NotFound().finish();
166 return Ok(ServiceResponse::new(req.into_parts().0, response));
167 }
168
169 let (http_req, _) = req.into_parts();
170 match actix_files::NamedFile::open_async(index_file).await {
171 Ok(file) => Ok(ServiceResponse::new(
172 http_req.clone(),
173 file.into_response(&http_req),
174 )),
175 Err(_) => Ok(ServiceResponse::new(
176 http_req,
177 HttpResponse::NotFound().finish(),
178 )),
179 }
180 }
181 })),
182 );
183 }
184
185 app
186 };
187
188 let rustls_cfg = match &tls {
192 Some(tls) => Some(build_rustls_config(tls)?),
193 None => None,
194 };
195
196 let listeners = build_desktop_listeners(port)?;
197
198 let server = build_h1_server(app_factory, listeners, workers, rustls_cfg.clone())
199 .map_err(|e| format!("Failed to build HTTP/1.1 server: {e}"))?;
200
201 let scheme = if rustls_cfg.is_some() {
202 "https"
203 } else {
204 "http"
205 };
206 info!("Unified server running on {scheme}://127.0.0.1:{port}");
207
208 let result = server.await;
209
210 app_state_for_shutdown.shutdown().await;
215
216 if let Err(e) = result {
217 error!("Server error: {}", e);
218 return Err(format!("Server error: {e}"));
219 }
220
221 Ok(())
222}
223
224pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
238 run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
239}
240
241pub async fn run_with_bind_tls(
243 bamboo_home_dir: PathBuf,
244 port: u16,
245 bind: &str,
246 tls: Option<TlsConfig>,
247) -> Result<(), String> {
248 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, None, tls).await
249}
250
251pub async fn run_with_bind_and_static(
273 bamboo_home_dir: PathBuf,
274 port: u16,
275 bind: &str,
276 static_dir: Option<PathBuf>,
277) -> Result<(), String> {
278 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, static_dir, None).await
279}
280
281pub async fn run_with_bind_and_static_tls(
284 bamboo_home_dir: PathBuf,
285 port: u16,
286 bind: &str,
287 static_dir: Option<PathBuf>,
288 tls: Option<TlsConfig>,
289) -> Result<(), String> {
290 info!("Starting unified server on {}:{}...", bind, port);
291
292 let apply_rate_limit = !is_loopback_bind(bind);
295 require_limiter_for_nonloopback(bind, apply_rate_limit)?;
300
301 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
302
303 let app_state = web::Data::new(
304 AppState::new(bamboo_home_dir.clone())
305 .await
306 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
307 );
308 let app_state_for_shutdown = app_state.clone();
311 let workers = resolve_worker_count();
312
313 let rate_limiter = build_rate_limiter();
317 let bind_for_cors = bind.to_string();
318 let app_factory = move || {
319 let mut app = wrap_governor_and_cors(
329 super::web_service::with_body_limits(App::new()).app_data(app_state.clone()),
330 &rate_limiter,
331 apply_rate_limit,
332 &bind_for_cors,
333 port,
334 )
335 .wrap(build_security_headers())
336 .wrap(actix_web::middleware::from_fn(
339 crate::config::add_asset_cache_headers,
340 ))
341 .configure(configure_routes_with_rate_limiting);
342
343 if let Some(static_path) = &static_dir {
344 let index_file = static_path.join("index.html");
345 info!("Serving static files from: {:?}", static_path);
346 app = app.service(
347 fs::Files::new("/", static_path)
348 .index_file("index.html")
349 .prefer_utf8(true)
350 .disable_content_disposition()
351 .default_handler(fn_service(move |req: ServiceRequest| {
352 let index_file = index_file.clone();
353 async move {
354 let path = req.path().to_string();
355 if is_api_path(&path) {
356 let response = HttpResponse::NotFound().finish();
357 return Ok(ServiceResponse::new(req.into_parts().0, response));
358 }
359
360 let (http_req, _) = req.into_parts();
361 match actix_files::NamedFile::open_async(index_file).await {
362 Ok(file) => Ok(ServiceResponse::new(
363 http_req.clone(),
364 file.into_response(&http_req),
365 )),
366 Err(_) => Ok(ServiceResponse::new(
367 http_req,
368 HttpResponse::NotFound().finish(),
369 )),
370 }
371 }
372 })),
373 );
374 }
375
376 app
377 };
378
379 let rustls_cfg = match &tls {
383 Some(tls) => Some(build_rustls_config(tls)?),
384 None => None,
385 };
386
387 let listeners = build_bind_listeners(bind, port)?;
388
389 let server = build_h1_server(app_factory, listeners, workers, rustls_cfg.clone())
390 .map_err(|e| format!("Failed to build HTTP/1.1 server: {e}"))?;
391
392 let scheme = if rustls_cfg.is_some() {
393 "https"
394 } else {
395 "http"
396 };
397 info!("Unified server running on {scheme}://{}:{}", bind, port);
398
399 let result = server.await;
400
401 app_state_for_shutdown.shutdown().await;
405
406 if let Err(e) = result {
407 error!("Server error: {}", e);
408 return Err(format!("Server error: {e}"));
409 }
410
411 Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use tempfile::tempdir;
418
419 #[test]
420 fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
421 let bamboo_home = tempdir().unwrap();
422 let static_dir = tempdir().unwrap();
423 std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
424
425 let resolved =
426 resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
427 .expect("configured static dir should resolve")
428 .expect("configured static dir should be returned");
429
430 assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
431 }
432
433 #[test]
434 fn is_api_path_covers_every_registered_version_prefix() {
435 for api_path in [
439 "/api/v1/sessions",
440 "/v1/bamboo/workflows",
441 "/v2/unknown",
442 "/openai/v1/models",
443 "/anthropic/v1/messages",
444 "/gemini/v1beta/models",
445 ] {
446 assert!(is_api_path(api_path), "{api_path} must be an API path");
447 }
448
449 for frontend_path in ["/", "/index.html", "/assets/app.js", "/settings"] {
450 assert!(
451 !is_api_path(frontend_path),
452 "{frontend_path} must NOT be treated as an API path"
453 );
454 }
455 }
456
457 #[actix_web::test]
472 async fn full_app_assembly_forwards_every_api_prefix_and_still_serves_spa_fallback() {
473 use actix_web::dev::{fn_service, ServiceRequest, ServiceResponse};
474 use actix_web::http::StatusCode;
475 use actix_web::{test, App};
476
477 let static_dir = tempdir().unwrap();
478 let index_file = static_dir.path().join("index.html");
479 std::fs::write(&index_file, "<html>spa-fallback-marker</html>").unwrap();
480
481 let app = test::init_service(
482 App::new()
483 .configure(crate::routes::configure_routes)
484 .service(
485 fs::Files::new("/", static_dir.path())
486 .index_file("index.html")
487 .default_handler(fn_service(move |req: ServiceRequest| {
488 let index_file = index_file.clone();
489 async move {
490 let path = req.path().to_string();
491 if is_api_path(&path) {
492 let response = HttpResponse::NotFound().finish();
493 return Ok(ServiceResponse::new(req.into_parts().0, response));
494 }
495 let (http_req, _) = req.into_parts();
496 match actix_files::NamedFile::open_async(index_file).await {
497 Ok(file) => Ok(ServiceResponse::new(
498 http_req.clone(),
499 file.into_response(&http_req),
500 )),
501 Err(_) => Ok(ServiceResponse::new(
502 http_req,
503 HttpResponse::NotFound().finish(),
504 )),
505 }
506 }
507 })),
508 ),
509 )
510 .await;
511
512 for (method, uri) in [
517 ("GET", "/v1/bamboo/workflows"),
518 ("GET", "/api/v1/bamboo/workflows"),
519 ("GET", "/api/v1/sessions"),
520 ("GET", "/api/v1/sessions/does-not-exist/history"),
521 ("GET", "/api/v1/history/does-not-exist"),
522 ("GET", "/v2/stream"),
523 ("GET", "/openai/v1/models"),
524 ("GET", "/anthropic/v1/models"),
525 ("GET", "/gemini/v1beta/models"),
526 ] {
527 let req = test::TestRequest::with_uri(uri)
528 .method(method.parse().unwrap())
529 .to_request();
530 let resp = test::call_service(&app, req).await;
531 assert_ne!(
532 resp.status(),
533 StatusCode::NOT_FOUND,
534 "{method} {uri} must be routed to its real handler, not 404 via the SPA fallback"
535 );
536 }
537
538 let flat = test::TestRequest::get()
541 .uri("/api/v1/history/does-not-exist")
542 .to_request();
543 let flat_status = test::call_service(&app, flat).await.status();
544 let nested = test::TestRequest::get()
545 .uri("/api/v1/sessions/does-not-exist/history")
546 .to_request();
547 let nested_status = test::call_service(&app, nested).await.status();
548 assert_eq!(
549 flat_status, nested_status,
550 "flat and nested history aliases must behave identically"
551 );
552
553 let bogus_api_req = test::TestRequest::get()
556 .uri("/api/v1/totally-not-a-real-route")
557 .to_request();
558 let bogus_api_resp = test::call_service(&app, bogus_api_req).await;
559 assert_eq!(
560 bogus_api_resp.status(),
561 StatusCode::NOT_FOUND,
562 "an unmatched /api/v1/* path must 404, not silently serve the SPA"
563 );
564
565 let spa_req = test::TestRequest::get()
569 .uri("/chat/some-deep-route")
570 .to_request();
571 let spa_resp = test::call_service(&app, spa_req).await;
572 assert_eq!(spa_resp.status(), StatusCode::OK);
573 let body = actix_web::body::to_bytes(spa_resp.into_body())
574 .await
575 .unwrap();
576 assert!(
577 String::from_utf8_lossy(&body).contains("spa-fallback-marker"),
578 "non-API deep link must serve the SPA index.html"
579 );
580 }
581}