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;
9
10/// Request body size limits, applied on EVERY serve path so the desktop/embedded
11/// server accepts the same payloads (e.g. an inline-image chat request) as the
12/// production server, instead of falling back to actix's ~2MB JSON / 256KB
13/// payload defaults and rejecting them with 413 (#252).
14pub(crate) const MAX_JSON_BODY_BYTES: usize = 25 * 1024 * 1024;
15pub(crate) const MAX_PAYLOAD_BYTES: usize = 30 * 1024 * 1024;
16use super::tls::build_rustls_config;
17use crate::app_state::AppState;
18use crate::config::{build_cors, build_rate_limiter, build_security_headers, is_loopback_bind};
19use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
20use actix_governor::Governor;
21use bamboo_config::TlsConfig;
22
23/// Manageable web service with start/stop lifecycle
24///
25/// Use this when you need to programmatically control the server lifecycle,
26/// such as in tests or embedded scenarios.
27pub struct WebService {
28    shutdown_tx: Option<oneshot::Sender<()>>,
29    server_handle: Option<tokio::task::JoinHandle<()>>,
30    /// Handle to the running server's [`AppState`], retained so [`WebService::stop`]
31    /// /[`Drop`] can gracefully stop AppState-owned background tasks (the #47
32    /// MCP-proxy reconnect supervisor) instead of leaking them until process exit.
33    /// #119.
34    app_state: Option<web::Data<AppState>>,
35    /// Bamboo home directory containing all application data (config, sessions, skills, etc.)
36    bamboo_home_dir: PathBuf,
37    port: u16,
38}
39
40impl WebService {
41    /// Create a new WebService instance
42    ///
43    /// # Arguments
44    /// * `bamboo_home_dir` - Bamboo home directory (e.g., `${HOME}/.bamboo` or custom path)
45    pub fn new(bamboo_home_dir: PathBuf) -> Self {
46        Self {
47            shutdown_tx: None,
48            server_handle: None,
49            app_state: None,
50            bamboo_home_dir,
51            port: 3456, // Default port
52        }
53    }
54
55    /// Start the web service on the specified port using the default localhost bind.
56    pub async fn start(&mut self, port: u16) -> Result<(), String> {
57        self.start_with_bind(port, "127.0.0.1").await
58    }
59
60    /// Start the web service on the specified port and bind address.
61    pub async fn start_with_bind(&mut self, port: u16, bind: &str) -> Result<(), String> {
62        self.start_with_bind_tls(port, bind, None).await
63    }
64
65    /// Start the web service, terminating TLS itself when `tls` is `Some` (#181).
66    ///
67    /// `None` keeps the plaintext `.bind()` path unchanged (desktop loopback).
68    pub async fn start_with_bind_tls(
69        &mut self,
70        port: u16,
71        bind: &str,
72        tls: Option<&TlsConfig>,
73    ) -> Result<(), String> {
74        info!("Starting web service...");
75        if self.server_handle.is_some() {
76            return Err("Web service is already running".to_string());
77        }
78
79        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
80        self.port = port;
81
82        let app_state = web::Data::new(
83            AppState::new(self.bamboo_home_dir.clone())
84                .await
85                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
86        );
87        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
88        self.app_state = Some(app_state.clone());
89        let bind_addr = bind.to_string();
90        let listen_addr = format!("{bind}:{port}");
91        let bind_for_log = bind_addr.clone();
92
93        let server = HttpServer::new(move || {
94            App::new()
95                .app_data(web::JsonConfig::default().limit(MAX_JSON_BODY_BYTES))
96                .app_data(web::PayloadConfig::new(MAX_PAYLOAD_BYTES))
97                .app_data(app_state.clone())
98                .wrap(build_cors(&bind_addr, port))
99                .configure(configure_routes) // No rate limiting for WebService
100        })
101        .workers(DEFAULT_WORKER_COUNT);
102
103        // Fail-fast: build the rustls config before binding; `None` → unchanged
104        // plaintext `.bind()` path. #181.
105        let server = match tls {
106            Some(tls) => server
107                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
108                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
109            None => server
110                .bind(&listen_addr)
111                .map_err(|e| format!("Failed to bind server: {e}"))?,
112        }
113        .run();
114
115        let server_handle = tokio::spawn(async move {
116            tokio::select! {
117                result = server => {
118                    if let Err(e) = result {
119                        error!("Server error: {}", e);
120                    }
121                }
122                _ = &mut shutdown_rx => {
123                    info!("Web service shutdown signal received");
124                }
125            }
126        });
127
128        self.shutdown_tx = Some(shutdown_tx);
129        self.server_handle = Some(server_handle);
130
131        let scheme = if tls.is_some() { "https" } else { "http" };
132        info!(
133            "Web service started successfully on {scheme}://{}:{}",
134            bind_for_log, port
135        );
136        Ok(())
137    }
138
139    /// Start the web service on the specified port and bind address, serving static files
140    /// alongside the API routes.
141    pub async fn start_with_bind_and_static(
142        &mut self,
143        port: u16,
144        bind: &str,
145        static_dir: PathBuf,
146    ) -> Result<(), String> {
147        self.start_with_bind_and_static_tls(port, bind, static_dir, None)
148            .await
149    }
150
151    /// Like [`WebService::start_with_bind_and_static`], terminating TLS itself
152    /// when `tls` is `Some` (#181). `None` keeps the plaintext path unchanged.
153    pub async fn start_with_bind_and_static_tls(
154        &mut self,
155        port: u16,
156        bind: &str,
157        static_dir: PathBuf,
158        tls: Option<&TlsConfig>,
159    ) -> Result<(), String> {
160        info!("Starting web service with static frontend...");
161        if self.server_handle.is_some() {
162            return Err("Web service is already running".to_string());
163        }
164
165        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
166        self.port = port;
167
168        let static_dir = static_dir
169            .canonicalize()
170            .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
171        if !static_dir.is_dir() {
172            return Err(format!(
173                "Static path is not a directory: {}",
174                static_dir.display()
175            ));
176        }
177
178        let app_state = web::Data::new(
179            AppState::new(self.bamboo_home_dir.clone())
180                .await
181                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
182        );
183        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
184        self.app_state = Some(app_state.clone());
185        // Per-IP rate limiter for the network-exposed production server (#13).
186        // SKIPPED for loopback/desktop binds: the local frontend legitimately
187        // bursts ~45 hashed `/assets/*` requests on load and would otherwise be
188        // throttled to a 429 (chunk import fails / "Too many requests").
189        let rate_limiter = build_rate_limiter();
190        let apply_rate_limit = !is_loopback_bind(bind);
191        let bind_addr = bind.to_string();
192        let listen_addr = format!("{bind}:{port}");
193        let bind_for_log = bind_addr.clone();
194
195        let server = HttpServer::new(move || {
196            App::new()
197                .app_data(web::JsonConfig::default().limit(MAX_JSON_BODY_BYTES))
198                .app_data(web::PayloadConfig::new(MAX_PAYLOAD_BYTES))
199                .app_data(app_state.clone())
200                .wrap(actix_web::middleware::Condition::new(
201                    apply_rate_limit,
202                    Governor::new(&rate_limiter),
203                ))
204                .wrap(build_cors(&bind_addr, port))
205                .wrap(build_security_headers())
206                // Immutable long-cache for hashed `/assets/*` so a proxy/CDN
207                // (e.g. Cloudflare tunnel) caches chunks at the edge instead of
208                // round-tripping each one to origin (#preload-error fix).
209                .wrap(actix_web::middleware::from_fn(
210                    crate::config::add_asset_cache_headers,
211                ))
212                .configure(configure_routes_with_rate_limiting)
213                .service(
214                    fs::Files::new("/", static_dir.clone())
215                        .index_file("index.html")
216                        .prefer_utf8(true)
217                        .disable_content_disposition()
218                        .disable_content_disposition(),
219                )
220        })
221        .workers(DEFAULT_WORKER_COUNT);
222
223        // Fail-fast: build the rustls config before binding; `None` → unchanged
224        // plaintext `.bind()` path. #181.
225        let server = match tls {
226            Some(tls) => server
227                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
228                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
229            None => server
230                .bind(&listen_addr)
231                .map_err(|e| format!("Failed to bind server: {e}"))?,
232        }
233        .run();
234
235        let server_handle = tokio::spawn(async move {
236            tokio::select! {
237                result = server => {
238                    if let Err(e) = result {
239                        error!("Server error: {}", e);
240                    }
241                }
242                _ = &mut shutdown_rx => {
243                    info!("Web service shutdown signal received");
244                }
245            }
246        });
247
248        self.shutdown_tx = Some(shutdown_tx);
249        self.server_handle = Some(server_handle);
250
251        let scheme = if tls.is_some() { "https" } else { "http" };
252        info!(
253            "Web service with static frontend started successfully on {scheme}://{}:{}",
254            bind_for_log, port
255        );
256        Ok(())
257    }
258
259    /// Stop the web service
260    pub async fn stop(&mut self) -> Result<(), String> {
261        if let Some(shutdown_tx) = self.shutdown_tx.take() {
262            if shutdown_tx.send(()).is_err() {
263                error!("Failed to send shutdown signal");
264                return Err("Error sending shutdown signal".to_string());
265            }
266
267            if let Some(handle) = self.server_handle.take() {
268                if let Err(e) = handle.await {
269                    error!("Error waiting for server shutdown: {}", e);
270                    return Err(format!("Error waiting for server shutdown: {}", e));
271                }
272            }
273
274            // Gracefully stop AppState-owned background tasks — the #47 MCP-proxy
275            // reconnect supervisor (via its cancellation token) and the MCP servers.
276            // Without this the token was wired but never cancelled, so the
277            // supervisor only died at process exit. #119.
278            if let Some(state) = self.app_state.take() {
279                state.shutdown().await;
280            }
281
282            info!("Web service stopped successfully");
283        }
284
285        Ok(())
286    }
287
288    /// Check if the web service is currently running
289    pub fn is_running(&self) -> bool {
290        self.server_handle.is_some()
291    }
292
293    /// Get the port the web service is running on
294    pub fn port(&self) -> u16 {
295        self.port
296    }
297}
298
299impl Drop for WebService {
300    fn drop(&mut self) {
301        if let Some(shutdown_tx) = self.shutdown_tx.take() {
302            let _ = shutdown_tx.send(());
303        }
304        // Drop can't run the async shutdown(), but cancelling the MCP-proxy
305        // supervisor's token is synchronous — so a WebService dropped without an
306        // explicit stop() still tears down the reconnect loop. (The async MCP
307        // server cleanup is left to process exit on this fallback path.) #119.
308        if let Some(state) = self.app_state.take() {
309            state.mcp_proxy_shutdown.cancel();
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    /// #119 e2e: WebService::stop() must cancel the AppState-owned MCP-proxy
319    /// reconnect supervisor's token, so it terminates on server stop rather than
320    /// leaking until process exit.
321    #[tokio::test]
322    async fn stop_cancels_mcp_proxy_supervisor_token() {
323        let home = tempfile::TempDir::new().expect("tempdir");
324        let mut service = WebService::new(home.path().to_path_buf());
325        // Port 0 -> OS-assigned ephemeral port (no conflict).
326        service
327            .start_with_bind(0, "127.0.0.1")
328            .await
329            .expect("web service starts");
330
331        // Capture the supervisor's cancellation token while the service runs.
332        let token = service
333            .app_state
334            .as_ref()
335            .expect("app_state retained after start")
336            .mcp_proxy_shutdown
337            .clone();
338        assert!(
339            !token.is_cancelled(),
340            "supervisor token is live while the service runs"
341        );
342
343        service.stop().await.expect("web service stops");
344
345        assert!(
346            token.is_cancelled(),
347            "stop() must cancel the MCP-proxy supervisor token so it terminates"
348        );
349    }
350}