1use 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#[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#[derive(Debug, Clone)]
42pub enum ServeMode {
43 Static { root_dir: PathBuf },
45 Command {
47 cmd: String,
48 args: Vec<String>,
49 cwd: Option<PathBuf>,
50 },
51}
52
53#[derive(Debug, Clone)]
55pub struct ServerConfig {
56 pub mode: ServeMode,
58 pub port: u16,
60 pub host: String,
62 pub health_check_timeout: Duration,
64 pub health_check_interval: Duration,
66}
67
68impl ServerConfig {
69 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 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 pub fn with_port(mut self, port: u16) -> Self {
99 self.port = port;
100 self
101 }
102
103 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 pub fn with_timeout(mut self, timeout: Duration) -> Self {
117 self.health_check_timeout = timeout;
118 self
119 }
120}
121
122pub 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 pub fn port(&self) -> u16 {
134 self.port
135 }
136
137 pub fn url(&self) -> &str {
139 &self.url
140 }
141
142 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
192pub 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
199pub 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
208pub 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 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(
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 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
334async 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 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 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 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 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}