use std::io::{BufRead, BufReader, ErrorKind, Read};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use anyhow::{Context, Result, bail};
use crate::apps::{self, Apps, Env};
use crate::cli::ServeArgs;
use crate::commands::build::{self as build_cmd, LocalApp};
use crate::{gateway, project};
const DEFAULT_HOST: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 3000;
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
struct Process {
label: String,
binary: String,
envs: Vec<(String, String)>,
}
pub fn run(args: &ServeArgs) -> Result<()> {
let root = project::find_root()?;
let env = Env::load(&root)?;
let (host, port) = resolve_address(args, &env)?;
let mcp_address = apps::connect_address(&host, port);
let routing = Apps::load(&root, &env, &mcp_address)?;
let local = match &routing {
Some(routing) => build_cmd::local_apps(&root, &routing.config)?,
None => Vec::new(),
};
let routes_anything = routing
.as_ref()
.is_some_and(|routing| routing.routes.in_matching_order().next().is_some());
let routes_apps = routing.as_ref().is_some_and(|routing| {
routing
.routes
.in_matching_order()
.any(|(_, route)| !route.is_mcp)
});
let use_gateway = routes_anything && gateway::is_installed(&root);
if routes_apps && !use_gateway {
println!(
" note: {} routes apps by domain or path, but the gateway is not installed; \
run `slave install:gateway` and `slave build`",
apps::CONFIG
);
}
let target = build_cmd::target_dir(&root)?;
let profile = if args.release { "release" } else { "debug" };
let bin_dir = target.join(profile);
let build_hint = if args.release {
"slave build --release"
} else {
"slave build"
};
let backend_env = vec![
("APP_HOST".to_owned(), host.clone()),
("APP_PORT".to_owned(), port.to_string()),
];
let mut processes = vec![Process {
label: "mcp".into(),
binary: project::package_name(&root)?,
envs: backend_env.clone(),
}];
if use_gateway {
processes.push(Process {
label: "gateway".into(),
binary: "gateway".into(),
envs: backend_env,
});
}
for app in &local {
processes.push(app_process(app, &mcp_address));
}
let missing: Vec<&str> = processes
.iter()
.filter(|p| !binary_path(&bin_dir, &p.binary).is_file())
.map(|p| {
if p.label == "mcp" {
"backend"
} else {
p.label.as_str()
}
})
.collect();
if !missing.is_empty() {
let names = missing.join(" ");
bail!(
"not built yet: {}; run `{build_hint} {names}` first",
missing.join(", ")
);
}
warn_if_stale(&root, &bin_dir, &processes, build_hint);
ensure_port_available(&host, port, "the MCP backend")?;
if let (true, Some(routing)) = (use_gateway, &routing) {
let (listen_host, listen_port) = split_address(&routing.listen)?;
ensure_port_available(&listen_host, listen_port, "the gateway")?;
}
for app in &local {
ensure_port_available(DEFAULT_HOST, app.port, &format!("app `{}`", app.name))?;
}
println!();
println!(" MCP backend http://{mcp_address}/mcp");
if let (true, Some(routing)) = (use_gateway, &routing) {
println!(" Gateway http://{}", routing.listen);
for (_, route) in routing.routes.in_matching_order() {
let target = if route.is_mcp {
"MCP backend"
} else {
&route.app
};
println!(
" {:<36} → {target}",
routing.url(route.host.as_deref(), route.path.as_deref())
);
}
}
for app in &local {
println!(
" {:<13} http://{DEFAULT_HOST}:{}{} (direct)",
app.name,
app.port,
app.base_path.as_deref().unwrap_or("")
);
}
println!("\nPress Ctrl+C to stop.\n");
supervise(&root, &bin_dir, processes)
}
fn app_process(app: &LocalApp, mcp_address: &str) -> Process {
let envs = vec![
("PORT".to_owned(), app.port.to_string()),
(
"SLAVE_MCP_URL".to_owned(),
format!("http://{mcp_address}/mcp"),
),
(
"SLAVE_BASE_PATH".to_owned(),
app.base_path.clone().unwrap_or_default(),
),
];
Process {
label: app.name.clone(),
binary: app.name.clone(),
envs,
}
}
fn binary_path(bin_dir: &Path, name: &str) -> PathBuf {
bin_dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}
fn warn_if_stale(root: &Path, bin_dir: &Path, processes: &[Process], build_hint: &str) {
let built = processes
.iter()
.filter_map(|p| modified(&binary_path(bin_dir, &p.binary)))
.min();
let mut newest = None;
for dir in ["src", "apps", "database/migrations", "Cargo.toml"] {
newest_modified(&root.join(dir), &mut newest);
}
if let (Some(built), Some(newest)) = (built, newest)
&& newest > built
{
println!(
" warning: sources changed since the last build; run `{build_hint}` to include them"
);
}
}
fn modified(path: &Path) -> Option<SystemTime> {
path.metadata().and_then(|m| m.modified()).ok()
}
fn newest_modified(path: &Path, newest: &mut Option<SystemTime>) {
let Ok(metadata) = path.metadata() else {
return;
};
if metadata.is_dir() {
if path.file_name().is_some_and(|name| name == "target") {
return;
}
if let Ok(entries) = path.read_dir() {
for entry in entries.flatten() {
newest_modified(&entry.path(), newest);
}
}
} else if let Ok(time) = metadata.modified()
&& newest.is_none_or(|current| time > current)
{
*newest = Some(time);
}
}
fn supervise(root: &Path, bin_dir: &Path, processes: Vec<Process>) -> Result<()> {
let stop = Arc::new(AtomicBool::new(false));
{
let stop = Arc::clone(&stop);
ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst))
.context("failed to install the Ctrl+C handler")?;
}
let width = processes.iter().map(|p| p.label.len()).max().unwrap_or(0);
let mut children: Vec<(String, Child)> = Vec::new();
for process in processes {
let binary = bin_dir.join(format!(
"{}{}",
process.binary,
std::env::consts::EXE_SUFFIX
));
let spawned = Command::new(&binary)
.current_dir(root)
.envs(process.envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawned {
Ok(child) => child,
Err(err) => {
stop_all(&mut children, Duration::ZERO);
return Err(err).with_context(|| format!("failed to start {}", binary.display()));
}
};
let prefix = format!("[{:<width$}]", process.label);
forward(child.stdout.take(), prefix.clone(), false);
forward(child.stderr.take(), prefix, true);
children.push((process.label, child));
}
loop {
if stop.load(Ordering::SeqCst) {
stop_all(&mut children, SHUTDOWN_GRACE);
return Ok(());
}
for (label, child) in &mut children {
if let Some(status) = child.try_wait()? {
let label = label.clone();
stop_all(&mut children, SHUTDOWN_GRACE);
bail!("`{label}` exited ({status}); stopped the other processes");
}
}
thread::sleep(Duration::from_millis(100));
}
}
fn forward(stream: Option<impl Read + Send + 'static>, prefix: String, to_stderr: bool) {
let Some(stream) = stream else { return };
thread::spawn(move || {
for line in BufReader::new(stream).lines().map_while(Result::ok) {
if to_stderr {
eprintln!("{prefix} {line}");
} else {
println!("{prefix} {line}");
}
}
});
}
fn stop_all(children: &mut [(String, Child)], grace: Duration) {
#[cfg(unix)]
for (_, child) in children.iter_mut() {
if matches!(child.try_wait(), Ok(None)) {
let _ = Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status();
}
}
let deadline = Instant::now() + grace;
for (_, child) in children.iter_mut() {
while matches!(child.try_wait(), Ok(None)) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
let _ = child.wait();
}
}
fn resolve_address(args: &ServeArgs, env: &Env) -> Result<(String, u16)> {
let host = args
.host
.clone()
.or_else(|| env.get("APP_HOST"))
.unwrap_or_else(|| DEFAULT_HOST.to_owned());
let port = match (args.port, env.get("APP_PORT")) {
(Some(port), _) => port,
(None, Some(value)) => value
.trim()
.parse()
.with_context(|| format!("APP_PORT={value:?} is not a valid port"))?,
(None, None) => DEFAULT_PORT,
};
Ok((host, port))
}
fn split_address(address: &str) -> Result<(String, u16)> {
let (host, port) = address
.rsplit_once(':')
.with_context(|| format!("`{address}` must be host:port"))?;
let port = port
.parse()
.with_context(|| format!("`{address}` has an invalid port"))?;
Ok((host.trim_matches(['[', ']']).to_owned(), port))
}
fn ensure_port_available(host: &str, port: u16, what: &str) -> Result<()> {
match TcpListener::bind((host, port)) {
Ok(_) => Ok(()),
Err(err) if err.kind() == ErrorKind::AddrInUse => {
bail!("port {port} on {host} (for {what}) is already in use")
}
Err(err) => Err(err).with_context(|| format!("cannot bind to {host}:{port} for {what}")),
}
}