use clap::Parser;
use clap_verbosity_flag::{InfoLevel, Verbosity};
use colored::Colorize;
use nix::{
sys::signal::{self, Signal},
unistd::Pid,
};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use signal_hook::{consts::signal::SIGHUP, iterator::Signals};
use std::{
collections::{HashMap, HashSet},
env,
fmt::Display,
io::{self, Write},
net::{TcpListener, TcpStream, ToSocketAddrs},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc, Mutex,
mpsc::{self, RecvTimeoutError},
},
thread,
time::{Duration, Instant},
};
#[derive(Parser, Debug)]
pub struct DevServer {
#[arg(short = 'o', long, env, default_value = "localhost")]
host: String,
#[arg(short, long, env, default_value = "8080")]
port: u16,
#[arg(short, long)]
watch: Vec<PathBuf>,
#[arg(short, long)]
cwd: Option<PathBuf>,
#[arg(short, long)]
release: bool,
#[arg(short, long)]
example: Option<String>,
#[arg(long, env)]
app_port: Option<u16>,
#[arg(long, default_value = "localhost")]
app_host: String,
#[arg(long)]
no_fast: bool,
#[arg(long, env = "EDITOR")]
editor: Option<String>,
#[arg(short, long, default_value = "SIGTERM")]
signal: Signal,
#[command(flatten)]
verbose: Verbosity<InfoLevel>,
#[arg(last = true, verbatim_doc_comment)]
cargo_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum Event {
Rebuild,
Restarted,
BuildSuccess,
CompileError { diagnostics: Vec<Diagnostic> },
}
#[derive(Debug, Clone)]
struct EditorTarget {
path: PathBuf,
line: u32,
column: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
level: String,
message: String,
rendered: String,
file: Option<String>,
line: Option<u32>,
column: Option<u32>,
id: Option<usize>,
#[serde(skip)]
target: Option<EditorTarget>,
}
#[derive(Debug, Deserialize)]
struct RustcMessage {
message: String,
level: String,
#[serde(default)]
spans: Vec<RustcSpan>,
rendered: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RustcSpan {
file_name: String,
line_start: u32,
column_start: u32,
is_primary: bool,
}
impl Diagnostic {
fn from_rustc(msg: RustcMessage, cwd: &Path) -> Self {
let primary = msg
.spans
.iter()
.find(|s| s.is_primary)
.or(msg.spans.first());
let (file, line, column, target) = match primary {
Some(span) => (
Some(span.file_name.clone()),
Some(span.line_start),
Some(span.column_start),
Some(EditorTarget {
path: cwd.join(&span.file_name),
line: span.line_start,
column: span.column_start,
}),
),
None => (None, None, None, None),
};
let rendered = msg.rendered.unwrap_or_default();
let rendered = ansi_to_html::convert(&rendered).unwrap_or(rendered);
Diagnostic {
level: msg.level,
message: msg.message,
rendered,
file,
line,
column,
id: None,
target,
}
}
}
impl DevServer {
pub fn run(self) {
env_logger::Builder::new()
.filter_level(self.verbose.log_level_filter())
.format(|buf, record| {
writeln!(
buf,
"[{}] {}",
record.module_path().unwrap_or_default().dimmed(),
record.args()
)
})
.init();
let cwd = self
.cwd
.clone()
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|e| die(e)));
let cwd = cwd
.canonicalize()
.unwrap_or_else(|e| die(format!("{}: {e}", cwd.display())));
let upstream_port = self.app_port.unwrap_or_else(free_port);
let mut build = Command::new("cargo");
build
.current_dir(&cwd)
.args(["build", "--message-format=json-diagnostic-rendered-ansi"]);
if self.release {
build.arg("--release");
}
if let Some(example) = &self.example {
build.args(["--example", example]);
}
build.args(&self.cargo_args);
if !self.no_fast && !self.release {
apply_fast_build(&mut build);
}
let watches =
resolve_watch_dirs(&cwd, &self.cargo_args, self.example.is_some(), &self.watch);
print_banner(&self, upstream_port, &watches);
let (tx, rx) = mpsc::channel::<()>();
let (mut broadcast_tx, broadcast_rx) = async_broadcast::broadcast::<Event>(16);
broadcast_tx.set_overflow(true);
broadcast_tx.set_await_active(false);
let _keepalive_rx = broadcast_rx;
{
let tx = tx.clone();
thread::spawn(move || {
let mut signals = Signals::new([SIGHUP]).unwrap_or_else(|e| die(e));
for _ in signals.forever() {
if tx.send(()).is_err() {
break;
}
}
});
}
{
let tx = tx.clone();
let cwd = cwd.clone();
thread::spawn(move || watch_loop(watches, cwd, tx));
}
let open_targets = Arc::new(Mutex::new(Vec::new()));
let status = Arc::new(Mutex::new(None));
{
let broadcaster = broadcast_tx.clone();
let app_host = self.app_host.clone();
let signal = self.signal;
let open_targets = open_targets.clone();
let status = status.clone();
thread::spawn(move || {
Supervisor {
rx,
broadcaster,
build,
cwd,
signal,
app_host,
upstream_port,
exe: None,
child: None,
open_targets,
status,
}
.run()
});
}
let editor = self.editor.clone().or_else(|| env::var("VISUAL").ok());
let upstream = format!("http://{}:{}", self.app_host, upstream_port);
proxy_app::run(
self.host.clone(),
self.port,
upstream,
broadcast_tx,
editor,
open_targets,
status,
);
}
}
struct Supervisor {
rx: mpsc::Receiver<()>,
broadcaster: async_broadcast::Sender<Event>,
build: Command,
cwd: PathBuf,
signal: Signal,
app_host: String,
upstream_port: u16,
exe: Option<PathBuf>,
child: Option<Child>,
open_targets: Arc<Mutex<Vec<EditorTarget>>>,
status: Arc<Mutex<Option<Event>>>,
}
impl Supervisor {
fn broadcast(&self, event: Event) {
{
let mut status = self.status.lock().unwrap();
match &event {
Event::Rebuild | Event::CompileError { .. } => *status = Some(event.clone()),
Event::BuildSuccess => *status = None,
Event::Restarted => {}
}
}
let _ = async_io::block_on(self.broadcaster.broadcast_direct(event));
}
fn publish_targets(&self, diagnostics: &mut [Diagnostic]) {
let mut targets = Vec::new();
for diagnostic in diagnostics {
if let Some(target) = diagnostic.target.take() {
diagnostic.id = Some(targets.len());
targets.push(target);
}
}
*self.open_targets.lock().unwrap() = targets;
}
fn build(&mut self) -> bool {
log::info!("building…");
let output = match self.build.output() {
Ok(output) => output,
Err(e) => {
log::error!("failed to run `cargo build`: {e}");
return false;
}
};
let mut executables = Vec::new();
let mut diagnostics = Vec::new();
let mut terminal = String::new();
for line in output.stdout.split(|&b| b == b'\n') {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(line) else {
continue;
};
match value.get("reason").and_then(|r| r.as_str()) {
Some("compiler-artifact") => {
if let Some(exe) = value.get("executable").and_then(|e| e.as_str()) {
executables.push(PathBuf::from(exe));
}
}
Some("compiler-message") => {
let Some(message) = value.get("message").cloned() else {
continue;
};
let Ok(msg) = serde_json::from_value::<RustcMessage>(message) else {
continue;
};
if let Some(rendered) = &msg.rendered {
terminal.push_str(rendered);
}
if msg.level == "error" {
diagnostics.push(Diagnostic::from_rustc(msg, &self.cwd));
}
}
_ => {}
}
}
if !output.status.success() {
if terminal.trim().is_empty() {
terminal = String::from_utf8_lossy(&output.stderr).into_owned();
}
io::stderr().write_all(terminal.as_bytes()).ok();
if diagnostics.is_empty() {
diagnostics.push(Diagnostic {
level: "error".into(),
message: "build failed".into(),
rendered: ansi_to_html::convert(&terminal).unwrap_or(terminal),
file: None,
line: None,
column: None,
id: None,
target: None,
});
}
self.publish_targets(&mut diagnostics);
self.broadcast(Event::CompileError { diagnostics });
return false;
}
if executables.len() > 1 {
log::warn!(
"build produced {} binaries; running the last. Use `-- --bin <name>` to pick one.",
executables.len()
);
}
match executables.pop() {
Some(exe) => {
log::info!("{}", "build succeeded".green());
self.exe = Some(exe);
self.broadcast(Event::BuildSuccess);
true
}
None => {
log::error!(
"build succeeded but produced no runnable binary — is this a binary crate? \
try `--example <name>` or `-- --bin <name>`"
);
false
}
}
}
fn spawn(&mut self) {
let Some(exe) = self.exe.clone() else { return };
let mut command = Command::new(exe);
command
.current_dir(&self.cwd)
.env("PORT", self.upstream_port.to_string())
.env("HOST", &self.app_host)
.env("TRILLIUM_CLI_DEV_SERVER", "1");
match command.spawn() {
Ok(child) => {
self.child = Some(child);
wait_until_listening(&self.app_host, self.upstream_port);
}
Err(e) => log::error!("failed to start app: {e}"),
}
}
fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = signal::kill(Pid::from_raw(child.id() as i32), self.signal);
let _ = child.wait();
}
}
fn run(mut self) {
if self.build() {
self.spawn();
}
loop {
match self.rx.recv_timeout(Duration::from_millis(200)) {
Ok(()) => {
self.broadcast(Event::Rebuild);
if self.build() {
self.stop();
self.spawn();
if self.child.is_some() {
self.broadcast(Event::Restarted);
}
}
}
Err(RecvTimeoutError::Timeout) => {
if let Some(child) = self.child.as_mut()
&& matches!(child.try_wait(), Ok(Some(_)))
{
log::warn!("app exited on its own; restarting");
self.child = None;
thread::sleep(Duration::from_millis(300)); self.spawn();
if self.child.is_some() {
self.broadcast(Event::Restarted);
}
}
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
}
fn wait_until_listening(host: &str, port: u16) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Ok(addrs) = (host, port).to_socket_addrs() {
for addr in addrs {
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
log::info!("app is listening on {host}:{port}");
return;
}
}
}
if Instant::now() >= deadline {
log::warn!("app did not start listening on {host}:{port} within 10s");
return;
}
thread::sleep(Duration::from_millis(50));
}
}
fn watch_loop(watches: Vec<PathBuf>, cwd: PathBuf, tx: mpsc::Sender<()>) {
let (events_tx, events_rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(events_tx, notify::Config::default())
.unwrap_or_else(|e| die(format!("could not start file watcher: {e}")));
for watch in watches {
let path = if watch.is_relative() {
cwd.join(&watch)
} else {
watch
};
match path.canonicalize() {
Ok(path) => match watcher.watch(&path, RecursiveMode::Recursive) {
Ok(()) => log::info!("watching {}", path.display()),
Err(e) => log::warn!("could not watch {}: {e}", path.display()),
},
Err(_) => log::warn!("watch path does not exist: {}", path.display()),
}
}
loop {
if events_rx.recv().is_err() {
return;
}
while events_rx.recv_timeout(Duration::from_millis(150)).is_ok() {}
if tx.send(()).is_err() {
return;
}
}
}
fn apply_fast_build(build: &mut Command) {
build.env("CARGO_PROFILE_DEV_DEBUG", "line-tables-only");
let mut applied = String::from("reduced debuginfo");
if env::var_os("RUSTFLAGS").is_none()
&& env::var_os("CARGO_BUILD_RUSTFLAGS").is_none()
&& let Some((linker, flag)) = detect_fast_linker()
{
build.env("CARGO_BUILD_RUSTFLAGS", flag);
applied.push_str(&format!(" + {linker} linker"));
}
log::info!("dev build speedups: {applied} (pass --no-fast to disable)");
}
fn detect_fast_linker() -> Option<(&'static str, &'static str)> {
[
("mold", "mold", "-Clink-arg=-fuse-ld=mold"),
("lld", "ld.lld", "-Clink-arg=-fuse-ld=lld"),
]
.into_iter()
.find(|(_, probe, _)| {
Command::new(probe)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
.map(|(name, _, flag)| (name, flag))
}
fn resolve_watch_dirs(
cwd: &Path,
cargo_args: &[String],
example: bool,
explicit: &[PathBuf],
) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let (seeds, closure) = workspace_crates(cwd, cargo_args);
for crate_dir in &closure {
let src = crate_dir.join("src");
if src.is_dir() {
dirs.push(src);
}
}
if example {
for crate_dir in &seeds {
let examples = crate_dir.join("examples");
if examples.is_dir() {
dirs.push(examples);
}
}
}
for watch in explicit {
dirs.push(if watch.is_relative() {
cwd.join(watch)
} else {
watch.clone()
});
}
dirs.sort();
dirs.dedup();
if dirs.is_empty() {
log::warn!("couldn't tell which crate to watch — pass `-p <crate>` or `--watch <dir>`");
}
dirs
}
fn workspace_crates(cwd: &Path, cargo_args: &[String]) -> (Vec<PathBuf>, Vec<PathBuf>) {
let Some(meta) = cargo_metadata(cwd) else {
return (Vec::new(), Vec::new());
};
let mut dir_of: HashMap<&str, PathBuf> = HashMap::new();
let mut name_of: HashMap<&str, &str> = HashMap::new();
if let Some(packages) = meta.get("packages").and_then(|p| p.as_array()) {
for pkg in packages {
if let (Some(id), Some(name), Some(manifest)) = (
pkg.get("id").and_then(|v| v.as_str()),
pkg.get("name").and_then(|v| v.as_str()),
pkg.get("manifest_path").and_then(|v| v.as_str()),
) && let Some(dir) = Path::new(manifest).parent()
{
dir_of.insert(id, dir.to_path_buf());
name_of.insert(id, name);
}
}
}
let members: HashSet<&str> = meta
.get("workspace_members")
.and_then(|m| m.as_array())
.map(|ids| ids.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut deps_of: HashMap<&str, Vec<&str>> = HashMap::new();
if let Some(nodes) = meta
.get("resolve")
.and_then(|r| r.get("nodes"))
.and_then(|n| n.as_array())
{
for node in nodes {
if let Some(id) = node.get("id").and_then(|v| v.as_str()) {
let deps = node
.get("dependencies")
.and_then(|d| d.as_array())
.map(|d| d.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
deps_of.insert(id, deps);
}
}
}
let selected = packages_from_args(cargo_args);
let seed_ids: Vec<&str> = if !selected.is_empty() {
members
.iter()
.copied()
.filter(|id| {
name_of
.get(id)
.is_some_and(|n| selected.iter().any(|s| s == n))
})
.collect()
} else if members.len() == 1 {
members.iter().copied().collect()
} else {
members
.iter()
.copied()
.filter(|id| dir_of.get(id).is_some_and(|dir| cwd.starts_with(dir)))
.max_by_key(|id| dir_of.get(id).map_or(0, |dir| dir.components().count()))
.into_iter()
.collect()
};
let mut closure_ids: HashSet<&str> = HashSet::new();
let mut stack = seed_ids.clone();
while let Some(id) = stack.pop() {
if !members.contains(id) || !closure_ids.insert(id) {
continue;
}
if let Some(deps) = deps_of.get(id) {
stack.extend(deps.iter().filter(|d| members.contains(*d)));
}
}
let dirs = |ids: &[&str]| {
ids.iter()
.filter_map(|id| dir_of.get(id).cloned())
.collect()
};
let seeds = dirs(&seed_ids);
let closure = dirs(&closure_ids.into_iter().collect::<Vec<_>>());
(seeds, closure)
}
fn cargo_metadata(cwd: &Path) -> Option<serde_json::Value> {
let output = Command::new("cargo")
.current_dir(cwd)
.args(["metadata", "--format-version", "1"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
serde_json::from_slice(&output.stdout).ok()
}
fn packages_from_args(args: &[String]) -> Vec<String> {
let mut names = Vec::new();
let mut args = args.iter();
while let Some(arg) = args.next() {
if arg == "-p" || arg == "--package" {
if let Some(name) = args.next() {
names.push(name.clone());
}
} else if let Some(name) = arg.strip_prefix("--package=") {
names.push(name.to_string());
} else if let Some(name) = arg.strip_prefix("-p").filter(|n| !n.is_empty()) {
names.push(name.to_string());
}
}
names
}
fn free_port() -> u16 {
TcpListener::bind(("127.0.0.1", 0))
.and_then(|listener| Ok(listener.local_addr()?.port()))
.unwrap_or_else(|e| die(format!("could not allocate a port: {e}")))
}
fn print_banner(server: &DevServer, upstream_port: u16, watches: &[PathBuf]) {
let watches = watches
.iter()
.map(|w| w.display().to_string())
.collect::<Vec<_>>()
.join(", ");
println!();
println!(" {} {}", "â–²".green(), "trillium dev-server".bold());
println!(
" {} http://{}:{}",
"proxy".dimmed(),
server.host,
server.port
);
println!(
" {} http://{}:{}",
"app".dimmed(),
server.app_host,
upstream_port
);
println!(" {} {}", "watch".dimmed(), watches);
println!();
}
fn die(msg: impl Display) -> ! {
eprintln!("{} {msg}", "error:".red().bold());
std::process::exit(1);
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum BrowserCommand {
Open { id: usize },
}
fn open_in_editor(editor: &str, file: &str, line: u32, column: u32) {
let mut parts = editor.split_whitespace();
let Some(program) = parts.next() else {
log::warn!("$EDITOR is empty");
return;
};
let base_args: Vec<&str> = parts.collect();
let name = Path::new(program)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(program);
let mut command = Command::new(program);
command.args(&base_args);
match name {
"emacs" | "emacsclient" => {
command.arg(format!("+{line}:{column}")).arg(file);
}
"vi" | "vim" | "nvim" | "gvim" | "mvim" => {
command.arg(format!("+{line}")).arg(file);
}
"code" | "code-insiders" | "codium" | "vscodium" | "cursor" | "windsurf" => {
command.arg("--goto").arg(format!("{file}:{line}:{column}"));
}
"subl" | "sublime_text" | "zed" => {
command.arg(format!("{file}:{line}:{column}"));
}
"idea" | "pycharm" | "webstorm" | "rubymine" | "clion" | "goland" | "rustrover"
| "phpstorm" => {
command.arg("--line").arg(line.to_string()).arg(file);
}
_ => {
command.arg(file);
}
}
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match command.spawn() {
Ok(_) => log::info!("opening {file}:{line}:{column} in {program}"),
Err(e) => log::warn!("could not open editor `{program}`: {e}"),
}
}
mod proxy_app {
use super::{BrowserCommand, EditorTarget, Event, open_in_editor};
use async_broadcast::Sender;
use futures_lite::{StreamExt, future};
use std::sync::{Arc, Mutex};
use trillium::{Conn, KnownHeaderName, State};
use trillium_client::Client;
use trillium_html_rewriter::{
HtmlRewriter,
html::{Settings, element, html_content::ContentType},
};
use trillium_proxy::Proxy;
use trillium_router::Router;
use trillium_smol::ClientConfig;
use trillium_websockets::{Message, WebSocket, WebSocketConn};
#[derive(Clone)]
struct WsState {
events: Sender<Event>,
editor: Option<String>,
open_targets: Arc<Mutex<Vec<EditorTarget>>>,
status: Arc<Mutex<Option<Event>>>,
}
enum Duplex {
Outgoing(Option<Event>),
Incoming(Option<Message>),
}
pub fn run(
host: String,
port: u16,
upstream: String,
events: Sender<Event>,
editor: Option<String>,
open_targets: Arc<Mutex<Vec<EditorTarget>>>,
status: Arc<Mutex<Option<Event>>>,
) {
let client = Client::new(ClientConfig::default().with_nodelay(true));
let state = WsState {
events,
editor,
open_targets,
status,
};
trillium_smol::config()
.without_signals()
.with_port(port)
.with_host(&host)
.run((
Router::new()
.get("/_dev_server.js", |conn: Conn| async move {
conn.with_response_header(
KnownHeaderName::ContentType,
"application/javascript; charset=utf-8",
)
.ok(include_str!("./dev_server.js"))
})
.get(
"/_dev_server.ws",
(State::new(state), WebSocket::new(live_reload)),
),
Proxy::new(client, &*upstream),
HtmlRewriter::new(|| Settings {
element_content_handlers: vec![element!("body", |el| {
el.append(
r#"<script src="/_dev_server.js"></script>"#,
ContentType::Html,
);
Ok(())
})],
..Settings::new_send()
}),
));
}
async fn live_reload(mut conn: WebSocketConn) {
let Some(state) = conn.state::<WsState>().cloned() else {
return;
};
let mut rx = state.events.new_receiver();
let current = state.status.lock().unwrap().clone();
if let Some(event) = current
&& conn.send_json(&event).await.is_err()
{
return;
}
loop {
let next = {
let outgoing =
core::pin::pin!(async { Duplex::Outgoing(rx.recv_direct().await.ok()) });
let incoming = core::pin::pin!(async {
Duplex::Incoming(conn.next().await.and_then(Result::ok))
});
future::or(outgoing, incoming).await
};
match next {
Duplex::Outgoing(Some(event)) => {
if conn.send_json(&event).await.is_err() {
return;
}
}
Duplex::Incoming(Some(message)) => {
if message.is_close() {
return;
}
if let Ok(text) = message.to_text()
&& let Ok(BrowserCommand::Open { id }) = serde_json::from_str(text)
{
let target = state.open_targets.lock().unwrap().get(id).cloned();
match (&state.editor, target) {
(Some(editor), Some(t)) => open_in_editor(
editor,
&t.path.to_string_lossy(),
t.line.max(1),
t.column.max(1),
),
(None, _) => log::warn!(
"clicked a source link, but no editor is set ($EDITOR unset; pass \
--editor)"
),
(_, None) => log::warn!("ignoring open request for unknown id {id}"),
}
}
}
Duplex::Outgoing(None) | Duplex::Incoming(None) => return,
}
}
}
}