Skip to main content

dioxuscut_renderer/
server.rs

1//! Automated Web Server Lifecycle for Dioxuscut.
2//!
3//! Provides spawning, health checking, dynamic port allocation,
4//! and clean termination for Dioxus web app rendering.
5
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use axum::{routing::get, Router};
12use tokio::net::TcpListener;
13use tokio::sync::{oneshot, Mutex};
14use tower_http::cors::CorsLayer;
15use tower_http::services::ServeDir;
16use tracing::{debug, error, info};
17
18/// Errors that can occur during web server lifecycle management.
19#[derive(Debug, thiserror::Error)]
20pub enum ServerError {
21    #[error("IO error: {0}")]
22    Io(#[from] std::io::Error),
23
24    #[error("Failed to bind server to address {0}: {1}")]
25    BindError(String, std::io::Error),
26
27    #[error("Server health check timed out for {0} after {1:?}")]
28    HealthCheckTimeout(String, Duration),
29
30    #[error("Reqwest error during health check: {0}")]
31    ReqwestError(#[from] reqwest::Error),
32
33    #[error("Child process exited prematurely with code: {0:?}")]
34    ProcessExited(Option<i32>),
35
36    #[error("Server already stopped")]
37    AlreadyStopped,
38}
39
40/// Mode of operation for the web server.
41#[derive(Debug, Clone)]
42pub enum ServeMode {
43    /// Embedded static HTTP server serving a directory.
44    Static { root_dir: PathBuf },
45    /// External child process command (e.g. `dx serve`).
46    Command {
47        cmd: String,
48        args: Vec<String>,
49        cwd: Option<PathBuf>,
50    },
51}
52
53/// Configuration for launching the web server.
54#[derive(Debug, Clone)]
55pub struct ServerConfig {
56    /// Server mode (static folder or external child process).
57    pub mode: ServeMode,
58    /// Requested port (0 for dynamic port selection).
59    pub port: u16,
60    /// Host interface to bind (default: "127.0.0.1").
61    pub host: String,
62    /// Health check timeout.
63    pub health_check_timeout: Duration,
64    /// Health check retry interval.
65    pub health_check_interval: Duration,
66}
67
68impl ServerConfig {
69    /// Create static server config for a root directory on a dynamic port (0).
70    pub fn static_dir(root_dir: impl Into<PathBuf>) -> Self {
71        Self {
72            mode: ServeMode::Static {
73                root_dir: root_dir.into(),
74            },
75            port: 0,
76            host: "127.0.0.1".to_string(),
77            health_check_timeout: Duration::from_secs(10),
78            health_check_interval: Duration::from_millis(50),
79        }
80    }
81
82    /// Create command server config for running `dx serve` or custom CLI.
83    pub fn command(cmd: impl Into<String>, args: Vec<String>) -> Self {
84        Self {
85            mode: ServeMode::Command {
86                cmd: cmd.into(),
87                args,
88                cwd: None,
89            },
90            port: 0,
91            host: "127.0.0.1".to_string(),
92            health_check_timeout: Duration::from_secs(15),
93            health_check_interval: Duration::from_millis(100),
94        }
95    }
96
97    /// Set the port.
98    pub fn with_port(mut self, port: u16) -> Self {
99        self.port = port;
100        self
101    }
102
103    /// Set the working directory for command mode.
104    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
105        if let ServeMode::Command {
106            cwd: ref mut slot, ..
107        } = self.mode
108        {
109            *slot = Some(cwd.into());
110        }
111
112        self
113    }
114
115    /// Set the health check timeout.
116    pub fn with_timeout(mut self, timeout: Duration) -> Self {
117        self.health_check_timeout = timeout;
118        self
119    }
120}
121
122/// Handle to a running server instance, ensuring clean termination on Drop or explicit `.stop()`.
123pub struct ServerHandle {
124    port: u16,
125    url: String,
126    shutdown_tx: Option<oneshot::Sender<()>>,
127    child_process: Option<Arc<Mutex<tokio::process::Child>>>,
128    stopped: bool,
129}
130
131impl ServerHandle {
132    /// Returns the port the server is listening on.
133    pub fn port(&self) -> u16 {
134        self.port
135    }
136
137    /// Returns the full URL of the server (e.g. "http://127.0.0.1:51234").
138    pub fn url(&self) -> &str {
139        &self.url
140    }
141
142    /// Explicitly stop the server and wait for clean termination.
143    pub async fn stop(mut self) -> Result<(), ServerError> {
144        self.perform_stop().await
145    }
146
147    async fn perform_stop(&mut self) -> Result<(), ServerError> {
148        if self.stopped {
149            return Ok(());
150        }
151        self.stopped = true;
152
153        info!("Stopping web server at {}", self.url);
154
155        if let Some(tx) = self.shutdown_tx.take() {
156            let _ = tx.send(());
157        }
158
159        if let Some(child_arc) = self.child_process.take() {
160            let mut child = child_arc.lock().await;
161            match child.kill().await {
162                Ok(_) => {
163                    let _ = child.wait().await;
164                    debug!("Child process killed successfully");
165                }
166                Err(e) => {
167                    debug!("Child process already exited or failed to kill: {}", e);
168                }
169            }
170        }
171
172        Ok(())
173    }
174}
175
176impl Drop for ServerHandle {
177    fn drop(&mut self) {
178        if !self.stopped {
179            if let Some(tx) = self.shutdown_tx.take() {
180                let _ = tx.send(());
181            }
182            if let Some(child_arc) = self.child_process.take() {
183                if let Ok(mut child) = child_arc.try_lock() {
184                    let _ = child.start_kill();
185                }
186            }
187            self.stopped = true;
188        }
189    }
190}
191
192/// Helper function to find an available local port.
193pub fn find_available_port() -> std::io::Result<u16> {
194    let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
195    let port = listener.local_addr()?.port();
196    Ok(port)
197}
198
199/// Spawn an embedded static web server serving `root_dir` on `port` (or dynamic port if 0).
200pub async fn spawn_server(
201    port: u16,
202    root_dir: impl AsRef<Path>,
203) -> Result<ServerHandle, ServerError> {
204    let config = ServerConfig::static_dir(root_dir.as_ref().to_path_buf()).with_port(port);
205    spawn_server_with_config(config).await
206}
207
208/// Spawn a web server with full configuration and perform health check readiness polling.
209pub async fn spawn_server_with_config(config: ServerConfig) -> Result<ServerHandle, ServerError> {
210    match config.mode {
211        ServeMode::Static { ref root_dir } => spawn_static_server(&config, root_dir).await,
212        ServeMode::Command {
213            ref cmd,
214            ref args,
215            ref cwd,
216        } => spawn_command_server(&config, cmd, args, cwd.as_deref()).await,
217    }
218}
219
220async fn spawn_static_server(
221    config: &ServerConfig,
222    root_dir: &Path,
223) -> Result<ServerHandle, ServerError> {
224    // Ensure root directory exists or create it
225    if !root_dir.exists() {
226        std::fs::create_dir_all(root_dir)?;
227    }
228
229    let bind_addr = format!("{}:{}", config.host, config.port);
230    let listener = TcpListener::bind(&bind_addr)
231        .await
232        .map_err(|e| ServerError::BindError(bind_addr.clone(), e))?;
233
234    let bound_addr: SocketAddr = listener.local_addr()?;
235    let actual_port = bound_addr.port();
236    let url = format!("http://{}:{}", config.host, actual_port);
237
238    info!(
239        "Starting static web server for {} at {}",
240        root_dir.display(),
241        url
242    );
243
244    let serve_dir = ServeDir::new(root_dir).append_index_html_on_directories(true);
245    let app = Router::new()
246        .route("/health", get(|| async { "OK" }))
247        .nest_service("/", serve_dir)
248        .layer(CorsLayer::permissive());
249
250    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
251
252    tokio::spawn(async move {
253        if let Err(err) = axum::serve(listener, app)
254            .with_graceful_shutdown(async move {
255                let _ = shutdown_rx.await;
256            })
257            .await
258        {
259            error!("Static web server error: {}", err);
260        }
261    });
262
263    // Poll health check until ready
264    poll_health_check(
265        &url,
266        config.health_check_timeout,
267        config.health_check_interval,
268    )
269    .await?;
270
271    Ok(ServerHandle {
272        port: actual_port,
273        url,
274        shutdown_tx: Some(shutdown_tx),
275        child_process: None,
276        stopped: false,
277    })
278}
279
280async fn spawn_command_server(
281    config: &ServerConfig,
282    cmd: &str,
283    args: &[String],
284    cwd: Option<&Path>,
285) -> Result<ServerHandle, ServerError> {
286    let port = if config.port == 0 {
287        find_available_port()?
288    } else {
289        config.port
290    };
291
292    let url = format!("http://{}:{}", config.host, port);
293
294    info!(
295        "Spawning child process server: {} {} at {}",
296        cmd,
297        args.join(" "),
298        url
299    );
300
301    let mut command = tokio::process::Command::new(cmd);
302    command.args(args);
303    if let Some(dir) = cwd {
304        command.current_dir(dir);
305    }
306    command.env("PORT", port.to_string());
307
308    let child = command.spawn()?;
309    let child_arc = Arc::new(Mutex::new(child));
310
311    // Poll health check
312    let health_res = poll_health_check(
313        &url,
314        config.health_check_timeout,
315        config.health_check_interval,
316    )
317    .await;
318
319    if let Err(e) = health_res {
320        let mut c = child_arc.lock().await;
321        let _ = c.kill().await;
322        return Err(e);
323    }
324
325    Ok(ServerHandle {
326        port,
327        url,
328        shutdown_tx: None,
329        child_process: Some(child_arc),
330        stopped: false,
331    })
332}
333
334/// Poll server URL until responding or timeout.
335async fn poll_health_check(
336    url: &str,
337    timeout: Duration,
338    interval: Duration,
339) -> Result<(), ServerError> {
340    let client = reqwest::Client::builder()
341        .timeout(Duration::from_secs(2))
342        .build()?;
343
344    let health_url = if url.ends_with('/') {
345        format!("{}health", url)
346    } else {
347        format!("{}/health", url)
348    };
349
350    let start = Instant::now();
351
352    while start.elapsed() < timeout {
353        if let Ok(resp) = client.get(&health_url).send().await {
354            if resp.status().is_success() || resp.status().as_u16() < 500 {
355                debug!("Health check succeeded at {}", health_url);
356                return Ok(());
357            }
358        }
359
360        if let Ok(resp) = client.get(url).send().await {
361            if resp.status().is_success() || resp.status().as_u16() < 500 {
362                debug!("Health check succeeded at {}", url);
363                return Ok(());
364            }
365        }
366
367        tokio::time::sleep(interval).await;
368    }
369
370    Err(ServerError::HealthCheckTimeout(url.to_string(), timeout))
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::fs;
377
378    fn create_temp_test_dir(name: &str) -> PathBuf {
379        let dir = std::env::temp_dir().join(format!(
380            "dioxuscut_server_test_{}_{}",
381            name,
382            std::time::SystemTime::now()
383                .duration_since(std::time::UNIX_EPOCH)
384                .unwrap()
385                .as_nanos()
386        ));
387        let _ = fs::create_dir_all(&dir);
388        dir
389    }
390
391    #[tokio::test]
392    async fn test_spawn_static_server_dynamic_port() {
393        let temp_dir = create_temp_test_dir("dyn_port");
394        let index_path = temp_dir.join("index.html");
395        fs::write(&index_path, "<h1>Test Dioxus App</h1>").unwrap();
396
397        let handle = spawn_server(0, &temp_dir)
398            .await
399            .expect("Failed to spawn server");
400
401        assert!(handle.port() > 0);
402        assert!(handle.url().starts_with("http://127.0.0.1:"));
403
404        // Health check endpoint
405        let health_res = reqwest::get(format!("{}/health", handle.url()))
406            .await
407            .unwrap();
408        assert_eq!(health_res.status(), 200);
409        let health_text = health_res.text().await.unwrap();
410        assert_eq!(health_text, "OK");
411
412        // Static file serving
413        let file_res = reqwest::get(handle.url()).await.unwrap();
414        assert_eq!(file_res.status(), 200);
415        let file_text = file_res.text().await.unwrap();
416        assert_eq!(file_text, "<h1>Test Dioxus App</h1>");
417
418        // Clean stop
419        handle.stop().await.expect("Failed to stop server");
420
421        let _ = fs::remove_dir_all(&temp_dir);
422    }
423
424    #[tokio::test]
425    async fn test_server_drop_cleanup() {
426        let temp_dir = create_temp_test_dir("drop_cleanup");
427        let url;
428        {
429            let handle = spawn_server(0, &temp_dir).await.unwrap();
430            url = handle.url().to_string();
431            let res = reqwest::get(&url).await;
432            assert!(res.is_ok());
433        }
434        // Handle dropped here
435        tokio::time::sleep(Duration::from_millis(150)).await;
436        let res = reqwest::get(&url).await;
437        assert!(res.is_err());
438
439        let _ = fs::remove_dir_all(&temp_dir);
440    }
441
442    #[tokio::test]
443    async fn test_server_explicit_port() {
444        let temp_dir = create_temp_test_dir("explicit_port");
445        let available_port = find_available_port().expect("Failed to find port");
446
447        let handle = spawn_server(available_port, &temp_dir)
448            .await
449            .expect("Failed to spawn server");
450        assert_eq!(handle.port(), available_port);
451        assert_eq!(handle.url(), format!("http://127.0.0.1:{}", available_port));
452
453        let res = reqwest::get(format!("{}/health", handle.url()))
454            .await
455            .unwrap();
456        assert_eq!(res.status(), 200);
457
458        handle.stop().await.unwrap();
459        let _ = fs::remove_dir_all(&temp_dir);
460    }
461
462    #[tokio::test]
463    async fn test_server_config_builder() {
464        let temp_dir = create_temp_test_dir("config_builder");
465        let config = ServerConfig::static_dir(&temp_dir)
466            .with_port(0)
467            .with_timeout(Duration::from_secs(5));
468
469        let handle = spawn_server_with_config(config).await.unwrap();
470        assert!(handle.port() > 0);
471        handle.stop().await.unwrap();
472        let _ = fs::remove_dir_all(&temp_dir);
473
474        let cmd_config =
475            ServerConfig::command("echo", vec!["hello".to_string()]).with_cwd(&temp_dir);
476        if let ServeMode::Command {
477            ref cmd, ref cwd, ..
478        } = cmd_config.mode
479        {
480            assert_eq!(cmd, "echo");
481            assert_eq!(cwd.as_ref().unwrap(), &temp_dir);
482        }
483    }
484}