use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener as StdTcpListener};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::process::{Child, Command};
use crate::checkout;
const GRACEFUL_SHUTDOWN: Duration = Duration::from_millis(1200);
pub struct DevServer {
child: Child,
port: u16,
proxy_config: PathBuf,
}
impl DevServer {
pub fn port(&self) -> u16 {
self.port
}
pub async fn start(bridge_dir: &Path, api_addr: SocketAddr) -> Result<DevServer> {
checkout::ensure_node_modules(bridge_dir).await?;
let port = free_port().context("finding a free port for the Angular dev server")?;
let proxy_config = write_proxy_config(api_addr)
.context("writing the dev server's generated proxy config")?;
println!("starting the Angular dev server (ng serve) for hot reload");
let mut command = Command::new("bash");
command
.arg("-lc")
.arg(format!(
"exec node_modules/.bin/ng serve --port {port} --proxy-config {}",
shell_quote(&proxy_config)
))
.current_dir(bridge_dir)
.kill_on_drop(true);
#[cfg(unix)]
{
command.process_group(0);
}
let child = command
.spawn()
.with_context(|| format!("spawning `ng serve` in {}", bridge_dir.display()))?;
Ok(DevServer {
child,
port,
proxy_config,
})
}
pub async fn shutdown(mut self) {
#[cfg(unix)]
if let Some(pid) = self.child.id() {
terminate_group(pid as i32);
}
#[cfg(not(unix))]
{
let _ = self.child.start_kill();
}
match tokio::time::timeout(GRACEFUL_SHUTDOWN, self.child.wait()).await {
Ok(_) => {}
Err(_elapsed) => {
tracing::warn!("ng serve did not exit within the grace period; sending SIGKILL");
let _ = self.child.start_kill();
let _ = self.child.wait().await;
}
}
let _ = std::fs::remove_file(&self.proxy_config);
}
}
#[cfg(unix)]
fn terminate_group(pid: i32) {
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
}
fn free_port() -> Result<u16> {
let listener = StdTcpListener::bind((IpAddr::V4(Ipv4Addr::LOCALHOST), 0))
.context("binding an ephemeral port")?;
let port = listener
.local_addr()
.context("reading the ephemeral port")?
.port();
drop(listener);
Ok(port)
}
fn write_proxy_config(api_addr: SocketAddr) -> Result<PathBuf> {
let host = if api_addr.ip().is_unspecified() {
IpAddr::V4(Ipv4Addr::LOCALHOST)
} else {
api_addr.ip()
};
let target = format!("http://{host}:{port}", port = api_addr.port());
let config = serde_json::json!({
"/v1": {
"target": target,
"secure": false,
"changeOrigin": true,
}
});
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"salvor-dev-proxy-{}-{unique}.json",
std::process::id()
));
std::fs::write(&path, serde_json::to_vec_pretty(&config)?)
.with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proxy_config_normalizes_unspecified_host_to_loopback() {
let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let path = write_proxy_config(addr).expect("writes the config");
let text = std::fs::read_to_string(&path).expect("reads the config back");
std::fs::remove_file(&path).ok();
assert!(
text.contains("http://127.0.0.1:8080"),
"expected the loopback target in {text}"
);
assert!(
text.contains("\"/v1\""),
"expected the /v1 context in {text}"
);
}
#[test]
fn proxy_config_preserves_a_concrete_host() {
let addr: SocketAddr = "127.0.0.1:9090".parse().unwrap();
let path = write_proxy_config(addr).expect("writes the config");
let text = std::fs::read_to_string(&path).expect("reads the config back");
std::fs::remove_file(&path).ok();
assert!(
text.contains("http://127.0.0.1:9090"),
"expected the concrete target in {text}"
);
}
#[test]
fn shell_quote_escapes_embedded_single_quotes() {
let quoted = shell_quote(Path::new("/tmp/o'brien/proxy.json"));
assert_eq!(quoted, r"'/tmp/o'\''brien/proxy.json'");
}
}