use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant};
use clap::{Parser, Subcommand};
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::Value;
const TEMPLATE_FILES: &[(&str, &str)] = &[
(
".gitignore",
include_str!("../../template_assets/starter/dot-gitignore"),
),
(
"Cargo.toml",
include_str!("../../template_assets/starter/Cargo.toml.tmpl"),
),
(
"README.md",
include_str!("../../template_assets/starter/README.md"),
),
(
"package.json",
include_str!("../../template_assets/starter/package.json"),
),
(
"src/main.rs",
include_str!("../../template_assets/starter/src/main.rs"),
),
(
"app/entry-client.tsx",
include_str!("../../template_assets/starter/app/entry-client.tsx"),
),
(
"app/entry-server.tsx",
include_str!("../../template_assets/starter/app/entry-server.tsx"),
),
(
"app/renderer.tsx",
include_str!("../../template_assets/starter/app/renderer.tsx"),
),
(
"app/styles.css",
include_str!("../../template_assets/starter/app/styles.css"),
),
(
"app/pages/index.tsx",
include_str!("../../template_assets/starter/app/pages/index.tsx"),
),
(
"app/pages/about.tsx",
include_str!("../../template_assets/starter/app/pages/about.tsx"),
),
(
"vite.client.config.mjs",
include_str!("../../template_assets/starter/vite.client.config.mjs"),
),
(
"vite.dev.config.mjs",
include_str!("../../template_assets/starter/vite.dev.config.mjs"),
),
(
"vite.server.config.mjs",
include_str!("../../template_assets/starter/vite.server.config.mjs"),
),
];
#[derive(Parser)]
#[command(name = "haven", version, about = "Generate and run Haven applications")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
New(NewArgs),
Dev(DevArgs),
}
#[derive(clap::Args)]
struct NewArgs {
path: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long)]
force: bool,
#[arg(long, value_name = "PATH")]
haven_path: Option<PathBuf>,
}
#[derive(clap::Args, Debug, Clone)]
struct DevArgs {
#[arg(value_name = "PATH", default_value = ".")]
path: PathBuf,
#[arg(long, default_value_t = 5174)]
vite_port: u16,
#[arg(long)]
app_port: Option<u16>,
#[arg(long)]
open: bool,
#[arg(long)]
no_install_check: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SourceKind {
Haven,
Vite,
App,
}
impl SourceKind {
fn prefix(self) -> &'static str {
match self {
Self::Haven => "haven",
Self::Vite => "vite",
Self::App => "app",
}
}
}
#[derive(Debug)]
enum Event {
Line { source: SourceKind, line: String },
WatchPaths(Vec<PathBuf>),
}
#[derive(Debug)]
struct AppContext {
root: PathBuf,
app_port: u16,
vite_port: u16,
}
struct ManagedChild {
child: Child,
source: SourceKind,
}
impl ManagedChild {
fn spawn(mut command: Command, source: SourceKind, tx: Sender<Event>) -> Result<Self, String> {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|err| format!("failed to spawn {} process: {err}", source.prefix()))?;
if let Some(stdout) = child.stdout.take() {
spawn_output_thread(stdout, source, tx.clone());
}
if let Some(stderr) = child.stderr.take() {
spawn_output_thread(stderr, source, tx);
}
Ok(Self { child, source })
}
fn try_wait(&mut self) -> Result<Option<std::process::ExitStatus>, String> {
self.child
.try_wait()
.map_err(|err| format!("failed to poll {} process: {err}", self.source.prefix()))
}
fn terminate(&mut self) -> Result<(), String> {
match self.child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => {}
Err(err) => {
return Err(format!(
"failed to poll {} process: {err}",
self.source.prefix()
));
}
}
self.child
.kill()
.map_err(|err| format!("failed to kill {} process: {err}", self.source.prefix()))?;
self.child
.wait()
.map_err(|err| format!("failed to wait for {} process: {err}", self.source.prefix()))?;
Ok(())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::New(args) => create_starter(args)?,
Commands::Dev(args) => run_dev(args)?,
}
Ok(())
}
fn create_starter(args: NewArgs) -> Result<(), Box<dyn std::error::Error>> {
let output_dir = args.path;
let project_name = args.name.unwrap_or_else(|| {
output_dir
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("haven-app")
.to_owned()
});
let package_name = sanitize_package_name(&project_name);
let app_title = humanize_title(&project_name);
prepare_output_dir(&output_dir, args.force)?;
let haven_root = args
.haven_path
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")))
.canonicalize()?;
let haven_rel = relative_path(&output_dir.canonicalize()?, &haven_root);
let cargo_haven_path = haven_rel.to_string_lossy().replace('\\', "/");
for (relative_path, source) in TEMPLATE_FILES {
let destination = output_dir.join(relative_path);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let rendered = render_template(
relative_path,
source,
&package_name,
&app_title,
&cargo_haven_path,
)?;
fs::write(destination, rendered)?;
}
println!(
"Created Haven starter at {}",
output_dir.canonicalize()?.display()
);
println!("Next steps:");
println!(" cd {}", output_dir.display());
println!(" npm install");
println!(" haven dev");
Ok(())
}
fn run_dev(args: DevArgs) -> Result<(), Box<dyn std::error::Error>> {
let root = args.path.canonicalize()?;
validate_app_root(&root, args.no_install_check)?;
let app_port = args
.app_port
.or_else(|| detect_app_port(&root).ok().flatten())
.unwrap_or(4000);
let context = AppContext {
root,
app_port,
vite_port: args.vite_port,
};
let (event_tx, event_rx) = mpsc::channel();
let shutdown = Arc::new(AtomicBool::new(false));
{
let shutdown = Arc::clone(&shutdown);
ctrlc::set_handler(move || {
shutdown.store(true, Ordering::SeqCst);
})?;
}
let mut watcher = start_rust_watcher(&context.root, event_tx.clone())?;
let mut vite = spawn_vite(&context, event_tx.clone())?;
print_line(
SourceKind::Haven,
&format!(
"starting vite dev runtime on http://127.0.0.1:{}",
context.vite_port
),
);
wait_for_vite_ready(&context, &mut vite, &event_rx, &shutdown)?;
print_line(SourceKind::Haven, "starting app server");
let mut app = Some(spawn_app(&context, event_tx.clone())?);
let mut app_restart_deadline: Option<Instant> = None;
let mut summary_printed = false;
let mut browser_opened = false;
let mut rust_compile_failed = false;
let result = loop {
if shutdown.load(Ordering::SeqCst) {
break Ok(());
}
if let Some(status) = vite.try_wait()? {
break Err(format!("vite dev runtime exited with {status}").into());
}
if let Some(child) = app.as_mut() {
if let Some(status) = child.try_wait()? {
app = None;
summary_printed = false;
if status.success() {
print_line(SourceKind::Haven, "app server exited");
} else {
rust_compile_failed = true;
print_line(
SourceKind::Haven,
&format!(
"app server exited with {status}; waiting for another Rust change"
),
);
}
}
}
while let Ok(event) = event_rx.try_recv() {
match event {
Event::Line { source, line } => {
print_line(source, &line);
if source == SourceKind::App {
if let Some(url) = extract_url(&line) {
rust_compile_failed = false;
if !summary_printed {
print_line(SourceKind::Haven, &format!("app ready on {url}"));
summary_printed = true;
}
if args.open && !browser_opened {
let _ = open_browser(&url);
browser_opened = true;
}
}
}
}
Event::WatchPaths(paths) => {
if paths.iter().any(is_rust_restart_path) {
app_restart_deadline = Some(Instant::now() + Duration::from_millis(250));
}
}
}
}
if !summary_printed && app_ready(context.app_port)? {
let url = format!("http://127.0.0.1:{}", context.app_port);
print_line(SourceKind::Haven, &format!("app ready on {url}"));
summary_printed = true;
rust_compile_failed = false;
if args.open && !browser_opened {
let _ = open_browser(&url);
browser_opened = true;
}
}
if let Some(deadline) = app_restart_deadline {
if Instant::now() >= deadline {
print_line(
SourceKind::Haven,
"rust change detected, restarting app server",
);
if let Some(mut child) = app.take() {
child.terminate()?;
}
summary_printed = false;
rust_compile_failed = false;
app = Some(spawn_app(&context, event_tx.clone())?);
app_restart_deadline = None;
}
} else if app.is_none() && rust_compile_failed {
}
thread::sleep(Duration::from_millis(100));
};
let _ = watcher.unwatch(&context.root.join("src"));
if let Some(mut child) = app {
let _ = child.terminate();
}
let _ = vite.terminate();
result
}
fn validate_app_root(
root: &Path,
no_install_check: bool,
) -> Result<(), Box<dyn std::error::Error>> {
for file in ["Cargo.toml", "package.json", "vite.dev.config.mjs"] {
if !root.join(file).exists() {
return Err(format!(
"{} is not a Haven app root; missing {}",
root.display(),
file
)
.into());
}
}
if !no_install_check && !root.join("node_modules").exists() {
return Err(format!(
"{} is missing node_modules; run `npm install` first or pass --no-install-check",
root.display()
)
.into());
}
let script = root.join("node_modules/@ovior/haven/scripts/vite-dev-runtime.mjs");
if !script.exists() {
return Err(format!(
"missing {}; make sure Haven is installed in package.json and run `npm install`",
script.display()
)
.into());
}
Ok(())
}
fn start_rust_watcher(
root: &Path,
tx: Sender<Event>,
) -> Result<RecommendedWatcher, Box<dyn std::error::Error>> {
let mut watcher = RecommendedWatcher::new(
move |result: notify::Result<notify::Event>| {
if let Ok(event) = result {
if should_restart_on_event(&event.kind) {
let _ = tx.send(Event::WatchPaths(event.paths));
}
}
},
notify::Config::default(),
)?;
let src_dir = root.join("src");
if src_dir.exists() {
watcher.watch(&src_dir, RecursiveMode::Recursive)?;
}
for file in ["Cargo.toml", "Cargo.lock", "build.rs"] {
let path = root.join(file);
if path.exists() {
watcher.watch(&path, RecursiveMode::NonRecursive)?;
}
}
Ok(watcher)
}
fn should_restart_on_event(kind: &EventKind) -> bool {
matches!(
kind,
EventKind::Create(_)
| EventKind::Modify(_)
| EventKind::Remove(_)
| EventKind::Any
| EventKind::Other
)
}
fn spawn_vite(
context: &AppContext,
tx: Sender<Event>,
) -> Result<ManagedChild, Box<dyn std::error::Error>> {
let mut command = Command::new("node");
command
.arg("./node_modules/@ovior/haven/scripts/vite-dev-runtime.mjs")
.arg("--config")
.arg("vite.dev.config.mjs")
.arg("--host")
.arg("127.0.0.1")
.arg("--port")
.arg(context.vite_port.to_string())
.arg("--strictPort")
.current_dir(&context.root);
ManagedChild::spawn(command, SourceKind::Vite, tx).map_err(Into::into)
}
fn spawn_app(
context: &AppContext,
tx: Sender<Event>,
) -> Result<ManagedChild, Box<dyn std::error::Error>> {
let mut command = Command::new("cargo");
command.arg("run").current_dir(&context.root).env(
"HAVEN_VITE_DEV_SERVER_ORIGIN",
format!("http://127.0.0.1:{}", context.vite_port),
);
command.env("HAVEN_APP_PORT", context.app_port.to_string());
ManagedChild::spawn(command, SourceKind::App, tx).map_err(Into::into)
}
fn wait_for_vite_ready(
context: &AppContext,
vite: &mut ManagedChild,
event_rx: &Receiver<Event>,
shutdown: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
if shutdown.load(Ordering::SeqCst) {
return Err("shutdown requested".into());
}
if let Some(status) = vite.try_wait()? {
return Err(format!("vite dev runtime exited with {status}").into());
}
while let Ok(event) = event_rx.try_recv() {
if let Event::Line { source, line } = event {
print_line(source, &line);
}
}
if vite_health_ready(context.vite_port)? {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
Err(format!(
"vite dev runtime did not become ready on http://127.0.0.1:{}",
context.vite_port
)
.into())
}
fn vite_health_ready(port: u16) -> Result<bool, Box<dyn std::error::Error>> {
let mut stream = match std::net::TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse()?,
Duration::from_millis(300),
) {
Ok(stream) => stream,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::TimedOut
| std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::NotConnected
) =>
{
return Ok(false);
}
Err(err) => return Err(err.into()),
};
stream.set_read_timeout(Some(Duration::from_millis(300)))?;
stream.set_write_timeout(Some(Duration::from_millis(300)))?;
stream.write_all(
b"GET /__haven_internal/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
)?;
let mut response = String::new();
match stream.read_to_string(&mut response) {
Ok(_) => {}
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::TimedOut
| std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionReset
) =>
{
return Ok(false);
}
Err(err) => return Err(err.into()),
}
Ok(
(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200"))
&& response.contains("\"mode\":\"haven-vite-dev\""),
)
}
fn app_ready(port: u16) -> Result<bool, Box<dyn std::error::Error>> {
Ok(std::net::TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse()?,
Duration::from_millis(100),
)
.is_ok())
}
fn spawn_output_thread<T: Read + Send + 'static>(stream: T, source: SourceKind, tx: Sender<Event>) {
thread::spawn(move || {
let reader = BufReader::new(stream);
for line in reader.lines() {
match line {
Ok(line) => {
let _ = tx.send(Event::Line { source, line });
}
Err(_) => break,
}
}
});
}
fn print_line(source: SourceKind, line: &str) {
println!("[{}] {}", source.prefix(), line);
}
fn extract_url(line: &str) -> Option<String> {
let url_start = line.find("http://").or_else(|| line.find("https://"))?;
let tail = &line[url_start..];
let end = tail.find(char::is_whitespace).unwrap_or(tail.len());
Some(tail[..end].trim_end_matches('/').to_string())
}
fn detect_app_port(root: &Path) -> Result<Option<u16>, Box<dyn std::error::Error>> {
let main_rs = root.join("src/main.rs");
if !main_rs.exists() {
return Ok(None);
}
let source = fs::read_to_string(main_rs)?;
for marker in [".bind((\"127.0.0.1\", ", "TcpListener::bind(\"127.0.0.1:"] {
if let Some(port) = detect_port_after_marker(&source, marker) {
return Ok(Some(port));
}
}
Ok(None)
}
fn detect_port_after_marker(source: &str, marker: &str) -> Option<u16> {
let start = source.find(marker)?;
let tail = &source[start + marker.len()..];
let digits: String = tail.chars().take_while(|ch| ch.is_ascii_digit()).collect();
digits.parse::<u16>().ok()
}
fn is_rust_restart_path(path: &PathBuf) -> bool {
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
if matches!(name, "Cargo.toml" | "Cargo.lock" | "build.rs") {
return true;
}
path.components()
.any(|component| component.as_os_str() == "src")
}
fn open_browser(url: &str) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
let mut command = Command::new("open");
#[cfg(target_os = "linux")]
let mut command = Command::new("xdg-open");
#[cfg(target_os = "windows")]
let mut command = {
let mut command = Command::new("cmd");
command.arg("/C").arg("start");
command
};
command.arg(url).spawn()?;
Ok(())
}
fn prepare_output_dir(path: &Path, force: bool) -> Result<(), Box<dyn std::error::Error>> {
if path.exists() {
let mut entries = fs::read_dir(path)?;
if entries.next().is_some() && !force {
return Err(format!(
"destination {} is not empty; rerun with --force to overwrite template files",
path.display()
)
.into());
}
} else {
fs::create_dir_all(path)?;
}
Ok(())
}
fn render_template(
relative_path: &str,
source: &str,
package_name: &str,
app_title: &str,
cargo_haven_path: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let rendered = match relative_path {
"Cargo.toml" => source
.replace(
"name = \"haven-starter\"",
&format!("name = \"{package_name}\""),
)
.replace(
"haven = { path = \"../..\" }",
&format!("haven = {{ path = \"{cargo_haven_path}\" }}"),
),
"package.json" => {
let mut value: Value = serde_json::from_str(source)?;
value["name"] = Value::String(package_name.to_owned());
serde_json::to_string_pretty(&value)?
}
"src/main.rs" => source
.replace("Starter App", app_title)
.replace(
"starter static export complete",
&format!("{package_name} static export complete"),
)
.replace(
"starter listening on",
&format!("{package_name} listening on"),
),
"README.md" => source.replace("# haven starter", &format!("# {app_title}")),
_ => source.to_owned(),
};
Ok(rendered)
}
fn sanitize_package_name(input: &str) -> String {
let mut output = String::new();
let mut last_was_dash = false;
for ch in input.chars() {
let next = if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
};
if next == '-' {
if output.is_empty() || last_was_dash {
continue;
}
last_was_dash = true;
output.push('-');
continue;
}
last_was_dash = false;
output.push(next);
}
output.trim_matches('-').to_string()
}
fn humanize_title(input: &str) -> String {
input
.split(['-', '_', ' '])
.filter(|segment| !segment.is_empty())
.map(|segment| {
let mut chars = segment.chars();
match chars.next() {
Some(first) => {
first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase()
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn relative_path(from: &Path, to: &Path) -> PathBuf {
let mut from_components = from.components().peekable();
let mut to_components = to.components().peekable();
while from_components.peek() == to_components.peek() {
from_components.next();
to_components.next();
}
let mut output = PathBuf::new();
for component in from_components {
if matches!(component, Component::Normal(_)) {
output.push("..");
}
}
for component in to_components {
if let Component::Normal(value) = component {
output.push(value);
}
}
if output.as_os_str().is_empty() {
output.push(".");
}
output
}
#[cfg(test)]
mod tests {
use super::{detect_app_port, render_template};
use serde_json::Value;
use std::fs;
use tempfile::tempdir;
#[test]
fn detects_starter_bind_tuple_port() {
let dir = tempdir().unwrap();
let src_dir = dir.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(
src_dir.join("main.rs"),
r#"
fn main() {
HttpServer::new(|| App::new())
.bind(("127.0.0.1", 3000))?
.run()
}
"#,
)
.unwrap();
assert_eq!(detect_app_port(dir.path()).unwrap(), Some(3000));
}
#[test]
fn detects_tcp_listener_string_port() {
let dir = tempdir().unwrap();
let src_dir = dir.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(
src_dir.join("main.rs"),
r#"
fn main() {
TcpListener::bind("127.0.0.1:4000").unwrap();
}
"#,
)
.unwrap();
assert_eq!(detect_app_port(dir.path()).unwrap(), Some(4000));
}
#[test]
fn package_template_keeps_published_haven_dependency() {
let rendered = render_template(
"package.json",
include_str!("../../template_assets/starter/package.json"),
"demo-app",
"Demo App",
"../..",
)
.unwrap();
let value: Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["name"], "demo-app");
assert_eq!(value["dependencies"]["@ovior/haven"], "^0.1.1");
}
}