mod app_server;
mod broadcast_server;
mod build;
mod keyboard;
mod port;
mod spinner;
mod watch;
use std::path::Path;
use app_server::AppServer;
use broadcast_server::{Event, EventBus};
use build::{BuildKind, BuildTask};
use clap::Args;
use console::style;
use keyboard::Keyboard;
use spinner::Spinner;
use watch::{Change, SourceWatcher};
use crate::common::cargo::{BuildFlags, BuildOpts, BuildStamp};
#[derive(Args)]
pub struct DevCommand {
#[command(flatten)]
build: BuildFlags,
}
impl DevCommand {
pub async fn run(self) {
let opts: BuildOpts = self.build.into();
let listener = broadcast_server::bind().await;
let dev_url = format!("http://{}", listener.local_addr().unwrap());
let events = EventBus::new();
tokio::spawn(broadcast_server::run(listener, events.clone()));
eprintln!();
eprint!(
" {} {}",
style("topcoat").cyan().bold(),
style("dev server").dim()
);
if let Some(profile) = opts.profile.as_deref().filter(|&p| p != "dev") {
eprint!(" {}", style(format!("[{profile}]")).yellow());
}
eprintln!();
let mut watcher = SourceWatcher::start().await;
let mut keyboard = Keyboard::start();
eprintln!(" {}", style("watching for file changes...").dim());
if keyboard.is_listening() {
eprintln!(
" {} {} {}",
style("press").dim(),
style("r").green().bold(),
style("to reload").dim()
);
}
eprintln!();
let mut build: Option<BuildTask> = Some(BuildTask::spawn(BuildKind::Initial, opts.clone()));
let mut server: Option<AppServer> = None;
let mut last_build: Option<BuildStamp> = None;
let mut force_restart = false;
events.publish(Event::Rebuilding);
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
eprintln!();
let spinner = Spinner::new("shutting down...");
if let Some(build) = build.take() {
build.cancel().await;
}
if let Some(server) = server.take() {
server.shutdown().await;
}
drop(spinner);
eprintln!();
break;
}
exe = async { build.as_mut().unwrap().finished().await }, if build.is_some() => {
build = None;
if let Some(exe) = exe {
let stamp = BuildStamp::of(&exe);
if !std::mem::take(&mut force_restart)
&& server.is_some()
&& stamp.is_some()
&& stamp == last_build
{
events.publish(Event::UpToDate);
eprintln!(" {}", style("no changes; application up to date").dim());
eprintln!();
} else {
last_build = stamp;
if let Some(old) = server.take() {
old.shutdown().await;
}
server = start_app(&exe, &dev_url, &events);
}
} else {
events.publish(Event::BuildFailed);
report_waiting(server.is_some());
}
}
status = async { server.as_mut().unwrap().exited().await }, if server.is_some() => {
server = None;
events.publish(Event::AppExited);
let status = status.map_or_else(|error| error.to_string(), |status| status.to_string());
eprintln!(
" {}",
style(format!("application exited ({status})")).red().bold()
);
eprintln!();
report_waiting(false);
}
change = watcher.changed() => {
if change == Change::Manifest {
watcher = SourceWatcher::start().await;
}
rebuild(&mut build, &opts, &events).await;
}
() = keyboard.reload_requested() => {
force_restart = true;
rebuild(&mut build, &opts, &events).await;
}
}
}
}
}
async fn rebuild(build: &mut Option<BuildTask>, opts: &BuildOpts, events: &EventBus) {
if let Some(stale) = build.take() {
stale.cancel().await;
}
*build = Some(BuildTask::spawn(BuildKind::Rebuild, opts.clone()));
events.publish(Event::Rebuilding);
}
fn start_app(exe: &Path, dev_url: &str, events: &EventBus) -> Option<AppServer> {
let Some(address) = port::resolve() else {
events.publish(Event::AppExited);
eprintln!(
" {}",
style("failed to start application: no port available")
.red()
.bold()
);
eprintln!();
report_waiting(false);
return None;
};
match AppServer::spawn(exe, dev_url, &address) {
Ok(server) => Some(server),
Err(error) => {
events.publish(Event::AppExited);
eprintln!(
" {}",
style(format!("failed to start application: {error}"))
.red()
.bold()
);
eprintln!();
report_waiting(false);
None
}
}
}
fn report_waiting(server_running: bool) {
let message = if server_running {
"previous build still running; waiting for changes..."
} else {
"waiting for changes..."
};
eprintln!(" {}", style(message).dim());
eprintln!();
}