Skip to main content

bamboo_server/server/
web_service.rs

1use std::path::PathBuf;
2
3use actix_files as fs;
4use actix_web::{web, App, HttpServer};
5use tokio::sync::oneshot;
6use tracing::{error, info};
7
8use super::listeners::DEFAULT_WORKER_COUNT;
9use crate::app_state::AppState;
10use crate::config::{build_cors, build_security_headers};
11use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
12
13/// Manageable web service with start/stop lifecycle
14///
15/// Use this when you need to programmatically control the server lifecycle,
16/// such as in tests or embedded scenarios.
17pub struct WebService {
18    shutdown_tx: Option<oneshot::Sender<()>>,
19    server_handle: Option<tokio::task::JoinHandle<()>>,
20    /// Bamboo home directory containing all application data (config, sessions, skills, etc.)
21    bamboo_home_dir: PathBuf,
22    port: u16,
23}
24
25impl WebService {
26    /// Create a new WebService instance
27    ///
28    /// # Arguments
29    /// * `bamboo_home_dir` - Bamboo home directory (e.g., `${HOME}/.bamboo` or custom path)
30    pub fn new(bamboo_home_dir: PathBuf) -> Self {
31        Self {
32            shutdown_tx: None,
33            server_handle: None,
34            bamboo_home_dir,
35            port: 3456, // Default port
36        }
37    }
38
39    /// Start the web service on the specified port using the default localhost bind.
40    pub async fn start(&mut self, port: u16) -> Result<(), String> {
41        self.start_with_bind(port, "127.0.0.1").await
42    }
43
44    /// Start the web service on the specified port and bind address.
45    pub async fn start_with_bind(&mut self, port: u16, bind: &str) -> Result<(), String> {
46        info!("Starting web service...");
47        if self.server_handle.is_some() {
48            return Err("Web service is already running".to_string());
49        }
50
51        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
52        self.port = port;
53
54        let app_state = web::Data::new(
55            AppState::new(self.bamboo_home_dir.clone())
56                .await
57                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
58        );
59        let bind_addr = bind.to_string();
60        let listen_addr = format!("{bind}:{port}");
61        let bind_for_log = bind_addr.clone();
62
63        let server = HttpServer::new(move || {
64            App::new()
65                .app_data(app_state.clone())
66                .wrap(build_cors(&bind_addr, port))
67                .configure(configure_routes) // No rate limiting for WebService
68        })
69        .workers(DEFAULT_WORKER_COUNT)
70        .bind(&listen_addr)
71        .map_err(|e| format!("Failed to bind server: {e}"))?
72        .run();
73
74        let server_handle = tokio::spawn(async move {
75            tokio::select! {
76                result = server => {
77                    if let Err(e) = result {
78                        error!("Server error: {}", e);
79                    }
80                }
81                _ = &mut shutdown_rx => {
82                    info!("Web service shutdown signal received");
83                }
84            }
85        });
86
87        self.shutdown_tx = Some(shutdown_tx);
88        self.server_handle = Some(server_handle);
89
90        info!(
91            "Web service started successfully on http://{}:{}",
92            bind_for_log, port
93        );
94        Ok(())
95    }
96
97    /// Start the web service on the specified port and bind address, serving static files
98    /// alongside the API routes.
99    pub async fn start_with_bind_and_static(
100        &mut self,
101        port: u16,
102        bind: &str,
103        static_dir: PathBuf,
104    ) -> Result<(), String> {
105        info!("Starting web service with static frontend...");
106        if self.server_handle.is_some() {
107            return Err("Web service is already running".to_string());
108        }
109
110        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
111        self.port = port;
112
113        let static_dir = static_dir
114            .canonicalize()
115            .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
116        if !static_dir.is_dir() {
117            return Err(format!(
118                "Static path is not a directory: {}",
119                static_dir.display()
120            ));
121        }
122
123        let app_state = web::Data::new(
124            AppState::new(self.bamboo_home_dir.clone())
125                .await
126                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
127        );
128        let bind_addr = bind.to_string();
129        let listen_addr = format!("{bind}:{port}");
130        let bind_for_log = bind_addr.clone();
131
132        let server = HttpServer::new(move || {
133            App::new()
134                .app_data(web::JsonConfig::default().limit(25 * 1024 * 1024))
135                .app_data(web::PayloadConfig::new(30 * 1024 * 1024))
136                .app_data(app_state.clone())
137                .wrap(build_cors(&bind_addr, port))
138                .wrap(build_security_headers())
139                .configure(configure_routes_with_rate_limiting)
140                .service(
141                    fs::Files::new("/", static_dir.clone())
142                        .index_file("index.html")
143                        .prefer_utf8(true)
144                        .disable_content_disposition()
145                        .disable_content_disposition(),
146                )
147        })
148        .workers(DEFAULT_WORKER_COUNT)
149        .bind(&listen_addr)
150        .map_err(|e| format!("Failed to bind server: {e}"))?
151        .run();
152
153        let server_handle = tokio::spawn(async move {
154            tokio::select! {
155                result = server => {
156                    if let Err(e) = result {
157                        error!("Server error: {}", e);
158                    }
159                }
160                _ = &mut shutdown_rx => {
161                    info!("Web service shutdown signal received");
162                }
163            }
164        });
165
166        self.shutdown_tx = Some(shutdown_tx);
167        self.server_handle = Some(server_handle);
168
169        info!(
170            "Web service with static frontend started successfully on http://{}:{}",
171            bind_for_log, port
172        );
173        Ok(())
174    }
175
176    /// Stop the web service
177    pub async fn stop(&mut self) -> Result<(), String> {
178        if let Some(shutdown_tx) = self.shutdown_tx.take() {
179            if shutdown_tx.send(()).is_err() {
180                error!("Failed to send shutdown signal");
181                return Err("Error sending shutdown signal".to_string());
182            }
183
184            if let Some(handle) = self.server_handle.take() {
185                if let Err(e) = handle.await {
186                    error!("Error waiting for server shutdown: {}", e);
187                    return Err(format!("Error waiting for server shutdown: {}", e));
188                }
189            }
190
191            info!("Web service stopped successfully");
192        }
193
194        Ok(())
195    }
196
197    /// Check if the web service is currently running
198    pub fn is_running(&self) -> bool {
199        self.server_handle.is_some()
200    }
201
202    /// Get the port the web service is running on
203    pub fn port(&self) -> u16 {
204        self.port
205    }
206}
207
208impl Drop for WebService {
209    fn drop(&mut self) {
210        if let Some(shutdown_tx) = self.shutdown_tx.take() {
211            let _ = shutdown_tx.send(());
212        }
213    }
214}