mod backend;
mod child;
mod codegen;
mod endpoints;
mod pages;
pub(crate) mod project;
mod vite;
mod watch;
pub mod service;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::time::Duration;
use child::{ChildGuard, inherited};
use endpoints::Endpoints;
use project::Project;
use service::{BackendHandle, Supervisor};
const VITE_TIMEOUT: Duration = Duration::from_secs(30);
type Cause = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(Debug)]
pub enum DevError {
Project(Cause),
NodeMissing,
Script(std::io::Error),
Spawn {
program: String,
source: std::io::Error,
},
Wait {
source: Cause,
},
Bind {
address: SocketAddr,
source: std::io::Error,
},
Watch(notify::Error),
Cargo {
source: std::io::Error,
},
Stage {
source: std::io::Error,
},
Serve(std::io::Error),
}
impl std::fmt::Display for DevError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Project(source) => write!(formatter, "{source}"),
Self::NodeMissing => write!(
formatter,
"`node` was not found on PATH. `arc dev` runs Vite as a child \
process, so Node.js has to be installed."
),
Self::Script(source) => write!(
formatter,
"could not write the Vite entry script into .arcature: {source}"
),
Self::Spawn { program, source } => {
write!(formatter, "could not start {program}: {source}")
}
Self::Wait { source } => write!(formatter, "{source}"),
Self::Bind { address, source } => write!(
formatter,
"could not bind {address}: {source}. This is the only TCP port \
`arc dev` uses; pass --port to choose another one."
),
Self::Watch(source) => write!(
formatter,
"could not watch the project for changes: {source}"
),
Self::Cargo { source } => write!(formatter, "could not run cargo: {source}"),
Self::Stage { source } => write!(
formatter,
"could not copy the built binary aside to run it: {source}. \
The copy is what lets the next rebuild replace cargo's own \
output while the current process is still running."
),
Self::Serve(source) => write!(formatter, "the development server stopped: {source}"),
}
}
}
impl std::error::Error for DevError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Project(source) | Self::Wait { source } => Some(source.as_ref()),
Self::Script(source) | Self::Serve(source) => Some(source),
Self::Spawn { source, .. } | Self::Cargo { source } | Self::Stage { source } => {
Some(source)
}
Self::Bind { source, .. } => Some(source),
Self::Watch(source) => Some(source),
Self::NodeMissing => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Options {
pub port: u16,
pub host: IpAddr,
pub open: bool,
pub hold: Duration,
}
impl Default for Options {
fn default() -> Self {
Self {
port: 3000,
host: IpAddr::V4(Ipv4Addr::LOCALHOST),
open: false,
hold: service::DEFAULT_HOLD,
}
}
}
pub fn options(port: Option<u16>, host: Option<&str>, open: bool) -> Result<Options, DevError> {
let defaults = Options::default();
let host = match host {
Some(host) => host.parse::<IpAddr>().map_err(|error| DevError::Bind {
address: SocketAddr::new(defaults.host, port.unwrap_or(defaults.port)),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("`{host}` is not an IP address: {error}"),
),
})?,
None => defaults.host,
};
Ok(Options {
port: port.unwrap_or(defaults.port),
host,
open,
hold: defaults.hold,
})
}
pub async fn run(options: Options) -> Result<(), DevError> {
let working_directory = std::env::current_dir().map_err(|source| DevError::Spawn {
program: String::from("`arc dev` in this directory"),
source,
})?;
let project = Project::discover(&working_directory)
.map_err(|error| DevError::Project(Box::new(error)))?;
let scratch = project
.scratch()
.map_err(|error| DevError::Project(Box::new(error)))?;
let node = project::node_version().ok_or(DevError::NodeMissing)?;
let endpoints = Endpoints::mint(&scratch);
let sentinel = project.sentinel();
project::touch_sentinel(&sentinel).map_err(DevError::Script)?;
let script = vite::write_script(&scratch).map_err(DevError::Script)?;
let mut vite_child = start_vite(&project, &script, &endpoints, &sentinel)?;
let waited = endpoints::wait_until_listening(&endpoints.vite, VITE_TIMEOUT, || {
vite_child
.exited()
.map(|status| format!("vite exited with {status}"))
})
.await
.map_err(|error| DevError::Wait {
source: Box::new(error),
})?;
println!(
" vite ready in {:.2}s (node {node})",
waited.as_secs_f32()
);
let address = SocketAddr::new(options.host, options.port);
let listener = tokio::net::TcpListener::bind(address)
.await
.map_err(|source| DevError::Bind { address, source })?;
serve(project, endpoints, sentinel, listener, options, vite_child).await
}
fn start_vite(
project: &Project,
script: &std::path::Path,
endpoints: &Endpoints,
sentinel: &std::path::Path,
) -> Result<ChildGuard, DevError> {
let mut command = inherited("node");
command
.arg(script)
.current_dir(project.root())
.env(crate::config::VITE_IPC_ENV, &endpoints.vite)
.env("ARCATURE_RESTART_SENTINEL", sentinel);
ChildGuard::spawn("vite", &mut command).map_err(|source| DevError::Spawn {
program: String::from("node (for Vite)"),
source,
})
}
async fn serve(
project: Project,
endpoints: Endpoints,
sentinel: PathBuf,
listener: tokio::net::TcpListener,
options: Options,
vite_child: ChildGuard,
) -> Result<(), DevError> {
let _vite_child = vite_child;
let _published = match listener.local_addr() {
Ok(bound) => {
match project::PublishedAddress::publish(project.root(), &connectable(bound)) {
Ok(published) => Some(published),
Err(error) => {
eprintln!("warning: could not publish the dev server address: {error}");
None
}
}
}
Err(error) => {
eprintln!("warning: could not read the bound address: {error}");
None
}
};
let handle = BackendHandle::new();
let supervisor = Supervisor::new(
endpoints.vite.clone(),
endpoints.app.clone(),
handle.clone(),
options.hold,
);
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
let served = tokio::spawn(async move {
use crate::axum::ServiceExt as _;
crate::axum::serve(listener, supervisor.into_make_service())
.with_graceful_shutdown(async {
let _ = stopped.await;
})
.await
});
let mut backend =
backend::Backend::new(project.root().to_path_buf(), endpoints, sentinel, handle);
let outcome = rebuild_loop(project.root(), &mut backend, || {
let url = format!("http://{}", local_url(&options));
println!("\n Arcature dev server ready at {url}\n");
if options.open {
open_browser(&url);
}
})
.await;
backend.stop();
let _ = stop.send(());
match served.await {
Ok(Ok(())) => outcome,
Ok(Err(source)) => Err(DevError::Serve(source)),
Err(_) => outcome,
}
}
async fn rebuild_loop(
root: &std::path::Path,
backend: &mut backend::Backend,
ready: impl FnOnce(),
) -> Result<(), DevError> {
let mut watch = watch::Watch::start(root).map_err(DevError::Watch)?;
let mut ready = Some(ready);
let mut next = Some(watch::Change::Rebuild);
loop {
let change = match next.take() {
Some(change) => change,
None => tokio::select! {
biased;
signal = tokio::signal::ctrl_c() => {
signal.map_err(DevError::Serve)?;
println!("\n stopping");
return Ok(());
}
change = watch.next_change() => match change {
Some(change) => change,
None => return Ok(()),
},
},
};
match change {
watch::Change::Rebuild if ready.is_some() => println!(" building"),
watch::Change::Rebuild => println!(" rebuilding"),
watch::Change::Restart => println!(" restarting (the environment changed)"),
}
let cancel = backend::Cancel::default();
let reload = match run_change(backend, change, &cancel, &mut watch, &mut next).await? {
Some(reload) => reload,
None => {
println!("\n stopping");
return Ok(());
}
};
match reload {
backend::Reload::Done(stages) => {
println!(" app {stages}");
if let Some(ready) = ready.take() {
ready();
}
}
backend::Reload::Cancelled => println!(" superseded"),
}
}
}
async fn run_change(
backend: &mut backend::Backend,
change: watch::Change,
cancel: &backend::Cancel,
watch: &mut watch::Watch,
next: &mut Option<watch::Change>,
) -> Result<Option<backend::Reload>, DevError> {
let work = async {
match change {
watch::Change::Rebuild => backend.reload(cancel).await,
watch::Change::Restart => backend.restart_only(cancel).await,
}
};
let mut work = std::pin::pin!(work);
loop {
tokio::select! {
biased;
signal = tokio::signal::ctrl_c() => {
signal.map_err(DevError::Serve)?;
cancel.request();
drop(work.await);
return Ok(None);
}
change = watch.next_change(), if next.is_none() => {
if let Some(change) = change {
*next = Some(change);
cancel.request();
}
}
done = &mut work => return done.map(Some),
}
}
}
fn local_url(options: &Options) -> String {
let host = if options.host.is_unspecified() {
String::from("localhost")
} else if options.host.is_ipv6() {
format!("[{}]", options.host)
} else {
options.host.to_string()
};
format!("{host}:{}", options.port)
}
fn connectable(bound: SocketAddr) -> String {
if bound.ip().is_unspecified() {
let loopback = if bound.is_ipv6() {
IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
} else {
IpAddr::V4(Ipv4Addr::LOCALHOST)
};
SocketAddr::new(loopback, bound.port()).to_string()
} else {
bound.to_string()
}
}
fn open_browser(url: &str) {
let spawned = if cfg!(windows) {
std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
} else if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).spawn()
} else {
std::process::Command::new("xdg-open").arg(url).spawn()
};
if let Err(error) = spawned {
eprintln!("warning: could not open a browser: {error}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_port_is_the_one_the_printed_url_uses() {
let options = Options::default();
assert_eq!(local_url(&options), "127.0.0.1:3000");
}
#[test]
fn a_wildcard_bind_is_printed_as_something_a_browser_can_open() {
let options = Options {
host: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
..Options::default()
};
assert_eq!(local_url(&options), "localhost:3000");
}
#[test]
fn an_ipv6_host_is_bracketed_so_the_port_is_not_read_as_part_of_it() {
let options = Options {
host: IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
port: 8080,
..Options::default()
};
assert_eq!(local_url(&options), "[::1]:8080");
}
#[test]
fn the_default_bind_is_loopback_so_a_dev_server_is_not_published_by_accident() {
assert!(
Options::default().host.is_loopback(),
"a development build with debug assertions on should not be reachable from the network unless asked"
);
}
#[test]
fn a_missing_node_says_what_to_install() {
let message = DevError::NodeMissing.to_string();
assert!(message.contains("Node.js"), "got: {message}");
}
#[test]
fn a_port_clash_names_the_flag_that_fixes_it() {
let message = DevError::Bind {
address: SocketAddr::from(([127, 0, 0, 1], 3000)),
source: std::io::Error::from(std::io::ErrorKind::AddrInUse),
}
.to_string();
assert!(message.contains("--port"), "got: {message}");
assert!(
message.contains("only TCP port"),
"the message should say there is only one, got: {message}"
);
}
}
#[cfg(test)]
mod option_tests {
use super::*;
#[test]
fn an_unparseable_host_is_refused_before_anything_is_started() {
let error = options(None, Some("not-an-address"), false)
.expect_err("a hostname is not an address this can bind");
assert!(error.to_string().contains("not an IP address"));
}
#[test]
fn the_named_port_and_host_survive_resolution() {
let resolved = options(Some(5173), Some("0.0.0.0"), true).expect("resolves");
assert_eq!(resolved.port, 5173);
assert_eq!(resolved.host, IpAddr::V4(Ipv4Addr::UNSPECIFIED));
assert!(resolved.open);
}
}