use anyhow::Result;
use std::path::Path;
#[cfg(feature = "notebook")]
pub fn handle_serve_command(
directory: &Path,
port: u16,
host: &str,
verbose: bool,
watch: bool,
debounce: u64,
pid_file: Option<&Path>,
watch_wasm: bool,
) -> Result<()> {
use axum::{http::HeaderValue, Router};
use tower::ServiceBuilder;
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer};
if !directory.exists() {
return Err(anyhow::anyhow!(
"Directory not found: {}",
directory.display()
));
}
if !directory.is_dir() {
return Err(anyhow::anyhow!(
"Path is not a directory: {}",
directory.display()
));
}
let _pid_guard = if let Some(pid_path) = pid_file {
Some(ruchy::server::PidFile::create(pid_path)?)
} else {
None
};
print_startup_banner(host, port, directory, watch, watch_wasm);
let serve_dir = ServeDir::new(directory)
.precompressed_gzip() .precompressed_br();
let app = Router::new().fallback_service(serve_dir).layer(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
axum::http::header::HeaderName::from_static("cross-origin-opener-policy"),
HeaderValue::from_static("same-origin"),
))
.layer(SetResponseHeaderLayer::if_not_present(
axum::http::header::HeaderName::from_static("cross-origin-embedder-policy"),
HeaderValue::from_static("require-corp"),
)),
);
let num_cpus = num_cpus::get();
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(num_cpus)
.enable_all()
.build()?;
#[cfg(unix)]
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
#[cfg(unix)]
{
use signal_hook::consts::{SIGINT, SIGTERM};
use signal_hook::iterator::Signals;
let shutdown_tx_clone = shutdown_tx;
std::thread::spawn(move || {
let mut signals =
Signals::new([SIGINT, SIGTERM]).expect("Failed to register signal handlers");
if let Some(_sig) = signals.forever().next() {
let _ = shutdown_tx_clone.send(());
}
});
}
#[allow(unreachable_code)] if watch {
run_watch_mode(
&runtime,
&app,
directory,
host,
port,
verbose,
debounce,
watch_wasm,
num_cpus,
#[cfg(unix)]
&shutdown_rx,
)
} else {
run_normal_mode(
&runtime,
app,
host,
port,
verbose,
num_cpus,
#[cfg(unix)]
&shutdown_rx,
)
}
}
#[cfg(feature = "notebook")]
fn print_startup_banner(host: &str, port: u16, directory: &Path, watch: bool, watch_wasm: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
use colored::Colorize;
println!(
"\n 🚀 {} {}\n",
"Ruchy Dev Server".bright_cyan().bold(),
format!("v{}", env!("CARGO_PKG_VERSION")).dimmed()
);
println!(
" {} http://{}:{}",
"➜ Local:".green(),
host,
port.to_string().bold()
);
if let Ok(ip) = local_ip_address::local_ip() {
println!(" {} http://{}:{}", "➜ Network:".green(), ip, port);
}
println!(
" 📁 {}: {}",
"Serving".dimmed(),
directory.display().to_string().bold()
);
if watch {
println!(
" 👀 {}: {}/**/*",
"Watching".dimmed(),
directory.display().to_string().bold()
);
if watch_wasm {
println!(
" 🦀 {}: Hot reload enabled for .ruchy files",
"WASM".dimmed()
);
}
}
println!("\n {} Press Ctrl+C to stop\n", "Ready".green().bold());
}
#[cfg(target_arch = "wasm32")]
{
println!("🚀 Ruchy HTTP Server v{}", env!("CARGO_PKG_VERSION"));
println!("📁 Serving: {}", directory.display());
println!("🌐 Listening: http://{}:{}", host, port);
if watch {
println!("👀 Watching: {}/**/*", directory.display());
}
println!("Press Ctrl+C to stop\n");
}
}
#[cfg(feature = "notebook")]
#[allow(clippy::too_many_arguments)]
fn run_watch_mode(
runtime: &tokio::runtime::Runtime,
app: &axum::Router,
directory: &Path,
host: &str,
port: u16,
verbose: bool,
debounce: u64,
watch_wasm: bool,
num_cpus: usize,
#[cfg(unix)] shutdown_rx: &std::sync::mpsc::Receiver<()>,
) -> Result<()> {
loop {
let mut watcher =
ruchy::server::watcher::FileWatcher::new(vec![directory.to_path_buf()], debounce)?;
let addr = format!("{}:{}", host, port);
let app_clone = app.clone();
let server_handle = runtime.spawn(async move {
let listener = tokio::net::TcpListener::bind(&addr).await?;
if verbose {
println!("✅ Server started ({} workers)", num_cpus);
}
axum::serve(listener, app_clone).await
});
loop {
#[cfg(unix)]
if shutdown_rx.try_recv().is_ok() {
print_shutdown_message();
server_handle.abort();
return Ok(());
}
if let Some(changed_files) = watcher.check_changes() {
handle_file_changes(&changed_files, watch_wasm, verbose);
server_handle.abort();
print_restart_message();
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
}
#[cfg(feature = "notebook")]
fn handle_file_changes(changed_files: &[std::path::PathBuf], watch_wasm: bool, verbose: bool) {
use super::compile_ruchy_to_wasm;
#[cfg(not(target_arch = "wasm32"))]
{
use colored::Colorize;
if watch_wasm {
for file in changed_files {
if file.extension().and_then(|s| s.to_str()) == Some("ruchy") {
println!(" 🦀 {}: {}", "Compiling".cyan().bold(), file.display());
match compile_ruchy_to_wasm(file, verbose) {
Ok(wasm_path) => {
println!(" ✅ {}: {}", "Compiled".green(), wasm_path.display());
}
Err(e) => {
println!(" ❌ {}: {}", "Failed".red(), e);
}
}
}
}
}
if verbose {
for file in changed_files {
println!(" 📝 {}: {}", "Changed".yellow(), file.display());
}
}
}
#[cfg(target_arch = "wasm32")]
{
if verbose {
for file in changed_files {
println!(" 📝 Changed: {}", file.display());
}
}
}
}
#[cfg(feature = "notebook")]
fn print_shutdown_message() {
#[cfg(not(target_arch = "wasm32"))]
{
use colored::Colorize;
println!("\n {} Shutting down gracefully...\n", "✓".green());
}
#[cfg(target_arch = "wasm32")]
{
println!("\n ✓ Shutting down gracefully...\n");
}
}
#[cfg(feature = "notebook")]
fn print_restart_message() {
#[cfg(not(target_arch = "wasm32"))]
{
use colored::Colorize;
println!("\n {} Restarting server...\n", "↻".cyan());
}
#[cfg(target_arch = "wasm32")]
{
println!("\n ↻ Restarting server...\n");
}
}
#[cfg(feature = "notebook")]
fn run_normal_mode(
runtime: &tokio::runtime::Runtime,
app: axum::Router,
host: &str,
port: u16,
verbose: bool,
num_cpus: usize,
#[cfg(unix)] shutdown_rx: &std::sync::mpsc::Receiver<()>,
) -> Result<()> {
let addr = format!("{}:{}", host, port);
#[cfg(unix)]
{
let addr_clone = addr;
let verbose_clone = verbose;
let num_cpus_clone = num_cpus;
let server_future = async move {
let listener = tokio::net::TcpListener::bind(&addr_clone).await?;
if verbose_clone {
println!("✅ Server started ({} workers)", num_cpus_clone);
}
axum::serve(listener, app).await
};
let server_handle = runtime.spawn(server_future);
loop {
if shutdown_rx.try_recv().is_ok() {
print_shutdown_message();
server_handle.abort();
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(unix))]
runtime.block_on(async {
let listener = tokio::net::TcpListener::bind(&addr).await?;
if verbose {
println!("✅ Server started ({} workers)", num_cpus);
}
axum::serve(listener, app).await
})?;
#[allow(unreachable_code)]
Ok(())
}
#[cfg(not(feature = "notebook"))]
pub fn handle_serve_command(
_directory: &Path,
_port: u16,
_host: &str,
_verbose: bool,
_watch: bool,
_debounce: u64,
_pid_file: Option<&Path>,
_watch_wasm: bool,
) -> Result<()> {
Err(anyhow::anyhow!(
"HTTP server requires notebook feature. Rebuild with --features notebook"
))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_serve_handler_stub() {
}
#[test]
#[cfg(feature = "notebook")]
fn test_handle_serve_command_nonexistent_dir() {
let result = handle_serve_command(
Path::new("/nonexistent/dir"),
8080,
"127.0.0.1",
false,
false,
500,
None,
false,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
#[cfg(feature = "notebook")]
fn test_handle_serve_command_file_not_dir() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "test").unwrap();
let result = handle_serve_command(
&file_path,
8080,
"127.0.0.1",
false,
false,
500,
None,
false,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not a directory"));
}
#[test]
#[cfg(not(feature = "notebook"))]
fn test_handle_serve_command_no_notebook_feature() {
let result = handle_serve_command(
Path::new("."),
8080,
"127.0.0.1",
false,
false,
500,
None,
false,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("notebook feature"));
}
#[test]
fn test_serve_command_requires_valid_path() {
let _ = handle_serve_command(
Path::new("/invalid"),
8080,
"localhost",
false,
false,
100,
None,
false,
);
}
#[test]
fn test_serve_command_default_parameters() {
let _ = handle_serve_command(
Path::new("."),
3000,
"0.0.0.0",
true,
true,
1000,
Some(Path::new("/tmp/pid")),
true,
);
}
#[test]
fn test_serve_command_with_verbose() {
let temp_dir = TempDir::new().unwrap();
let _ = handle_serve_command(
temp_dir.path(),
8081,
"127.0.0.1",
true, false,
500,
None,
false,
);
}
#[test]
fn test_serve_command_various_hosts() {
let temp_dir = TempDir::new().unwrap();
let _ = handle_serve_command(
temp_dir.path(),
8082,
"localhost",
false,
false,
100,
None,
false,
);
let _ = handle_serve_command(
temp_dir.path(),
8083,
"0.0.0.0",
false,
false,
100,
None,
false,
);
}
#[test]
fn test_serve_command_various_ports() {
let temp_dir = TempDir::new().unwrap();
let _ = handle_serve_command(
temp_dir.path(),
80,
"127.0.0.1",
false,
false,
100,
None,
false,
);
let _ = handle_serve_command(
temp_dir.path(),
443,
"127.0.0.1",
false,
false,
100,
None,
false,
);
let _ = handle_serve_command(
temp_dir.path(),
65535,
"127.0.0.1",
false,
false,
100,
None,
false,
);
}
#[test]
fn test_serve_command_debounce_values() {
let temp_dir = TempDir::new().unwrap();
let _ = handle_serve_command(
temp_dir.path(),
8084,
"127.0.0.1",
false,
false,
1, None,
false,
);
let _ = handle_serve_command(
temp_dir.path(),
8085,
"127.0.0.1",
false,
false,
10000, None,
false,
);
}
#[test]
fn test_serve_command_with_wasm_watch() {
let temp_dir = TempDir::new().unwrap();
let _ = handle_serve_command(
temp_dir.path(),
8086,
"127.0.0.1",
false,
true, 500,
None,
true, );
}
#[test]
fn test_serve_command_all_flags() {
let temp_dir = TempDir::new().unwrap();
let pid_path = temp_dir.path().join("server.pid");
let _ = handle_serve_command(
temp_dir.path(),
8087,
"127.0.0.1",
true, true, 250, Some(&pid_path),
true, );
}
#[test]
fn test_serve_command_zero_debounce() {
let temp_dir = TempDir::new().unwrap();
let _ = handle_serve_command(
temp_dir.path(),
8088,
"127.0.0.1",
false,
false,
0, None,
false,
);
}
#[test]
fn test_serve_command_nested_directory() {
let temp_dir = TempDir::new().unwrap();
let nested = temp_dir.path().join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap();
let _ = handle_serve_command(&nested, 8089, "127.0.0.1", false, false, 100, None, false);
}
}