use std::collections::{BTreeSet, HashMap, VecDeque};
use std::env;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{self, Command as ProcessCommand};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
#[cfg(feature = "btcpay")]
mod btcpay;
#[cfg(feature = "btcpay")]
pub use self::btcpay::BtcpayDevProfile;
#[cfg(feature = "btcpay")]
use self::btcpay::{LocalBtcpayBootstrapOptions, bootstrap_local_btcpay};
use clap::{Args, Parser, Subcommand};
use color_eyre::eyre::{Context, bail, ensure};
use serde::Deserialize;
use serde_json::Value;
pub type DevResult<T = ()> = color_eyre::Result<T>;
#[derive(Parser, Debug)]
#[command(about = "Build and run the current application's backend and web frontend.")]
struct AppCli {
#[command(subcommand)]
command: AppCommand,
}
#[derive(Subcommand, Debug)]
enum AppCommand {
Watch(AppWatchCommand),
Serve(AppRunCommand),
Build(AppBuildCommand),
#[command(hide = true)]
BuildPublicAsset(AppPublicAssetBuildCommand),
}
#[derive(Args, Debug)]
struct AppRunCommand {
#[command(flatten)]
optional_assets: OptionalAssetsArgs,
#[arg(long)]
release: bool,
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
}
#[derive(Args, Debug, Default)]
struct OptionalAssetsArgs {
#[arg(long = "with-optional-assets", alias = "editor")]
include_optional_assets: bool,
}
#[derive(Args, Debug)]
struct AppWatchCommand {
#[command(flatten)]
run: AppRunCommand,
#[arg(long)]
no_backend_reload: bool,
}
#[derive(Args, Debug)]
struct AppBuildCommand {
#[command(flatten)]
optional_assets: OptionalAssetsArgs,
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
}
#[derive(Args, Debug)]
struct AppPublicAssetBuildCommand {
#[arg(long)]
id: String,
#[arg(long)]
release: bool,
#[arg(long)]
target_dir: Option<PathBuf>,
}
#[derive(Clone, Debug)]
pub struct ProjectProfile {
app_id: String,
web_dir: String,
bin_target: Option<String>,
api_addr: String,
backend_ready_timeout: Duration,
backend_ready_path: Option<String>,
backend_target_dir: Option<String>,
web_backend_origin_env: Option<String>,
#[cfg(feature = "btcpay")]
btcpay: Option<BtcpayDevProfile>,
public_assets: Vec<ProjectPublicAsset>,
optional_public_assets: Vec<ProjectPublicAsset>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ProjectPublicAsset {
WasmBindgen(WasmBindgenPublicAsset),
}
#[derive(Clone, Debug, Deserialize)]
pub struct WasmBindgenPublicAsset {
id: String,
label: String,
package: String,
wasm_file: String,
#[serde(default)]
no_default_features: bool,
#[serde(default)]
features: String,
#[serde(default)]
target_dir: Option<String>,
out_dir: Vec<String>,
out_name: String,
wasm_bindgen_version: String,
}
#[derive(Clone, Copy, Debug)]
pub enum RunMode {
Watch,
Serve,
}
#[derive(Debug)]
pub struct WorkspacePaths {
workspace_root: PathBuf,
project_root: PathBuf,
web_root: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
resolve: Option<CargoResolve>,
}
#[derive(Debug, Deserialize)]
struct CargoPackage {
id: String,
name: String,
source: Option<String>,
manifest_path: PathBuf,
targets: Vec<CargoTarget>,
}
#[derive(Debug, Deserialize)]
struct CargoTarget {
kind: Vec<String>,
name: String,
src_path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CargoResolve {
nodes: Vec<CargoResolveNode>,
}
#[derive(Debug, Deserialize)]
struct CargoResolveNode {
id: String,
deps: Vec<CargoResolveDependency>,
}
#[derive(Debug, Deserialize)]
struct CargoResolveDependency {
pkg: String,
}
#[derive(Debug, Default, Eq, PartialEq)]
struct BackendWatchPaths {
paths: Vec<PathBuf>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct BackendArtifactStamp {
modified: SystemTime,
length: u64,
}
#[derive(Debug)]
struct ManagedChild {
child: process::Child,
}
impl std::ops::Deref for ManagedChild {
type Target = process::Child;
fn deref(&self) -> &Self::Target {
&self.child
}
}
impl std::ops::DerefMut for ManagedChild {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.child
}
}
impl ManagedChild {
fn terminate(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for ManagedChild {
fn drop(&mut self) {
self.terminate();
}
}
#[derive(Debug)]
struct BackendWatchRuntime {
process: ManagedChild,
builder: Option<ManagedChild>,
artifact: Option<PathBuf>,
active_image: Option<PathBuf>,
active_stamp: Option<BackendArtifactStamp>,
generation: u64,
}
const DEFAULT_BACKEND_READY_TIMEOUT: Duration = Duration::from_secs(300);
const BACKEND_READY_POLL_INTERVAL: Duration = Duration::from_millis(250);
const BACKEND_READY_CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
const WATCH_DEBOUNCE: &str = "750ms";
impl ProjectProfile {
pub fn new(app_id: impl Into<String>, api_addr: impl Into<String>) -> Self {
Self {
app_id: app_id.into(),
web_dir: "web".to_string(),
bin_target: None,
api_addr: api_addr.into(),
backend_ready_timeout: DEFAULT_BACKEND_READY_TIMEOUT,
backend_ready_path: None,
backend_target_dir: None,
web_backend_origin_env: None,
#[cfg(feature = "btcpay")]
btcpay: None,
public_assets: Vec::new(),
optional_public_assets: Vec::new(),
}
}
pub fn with_web_dir(mut self, web_dir: impl Into<String>) -> Self {
self.web_dir = web_dir.into();
self
}
pub fn with_bin_target(mut self, bin_target: impl Into<String>) -> Self {
self.bin_target = Some(bin_target.into());
self
}
pub fn with_backend_ready_timeout(mut self, timeout: Duration) -> Self {
self.backend_ready_timeout = timeout;
self
}
pub fn with_backend_ready_path(mut self, path: impl Into<String>) -> Self {
self.backend_ready_path = Some(path.into());
self
}
pub fn with_backend_target_dir(mut self, target_dir: impl Into<String>) -> Self {
self.backend_target_dir = Some(target_dir.into());
self
}
pub fn with_web_backend_origin_env(mut self, key: impl Into<String>) -> Self {
self.web_backend_origin_env = Some(key.into());
self
}
#[cfg(feature = "btcpay")]
pub fn with_btcpay_profile(mut self, profile: BtcpayDevProfile) -> Self {
self.btcpay = Some(profile);
self
}
pub fn with_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
self.public_assets.push(asset);
self
}
pub fn with_optional_public_asset(mut self, asset: ProjectPublicAsset) -> Self {
self.optional_public_assets.push(asset);
self
}
pub fn app_id(&self) -> &str {
&self.app_id
}
pub fn bin_target(&self) -> &str {
self.bin_target.as_deref().unwrap_or(&self.app_id)
}
pub fn validate(&self, source: &str) -> DevResult {
ensure!(
!self.app_id.trim().is_empty(),
"{source} contains an empty app id"
);
ensure!(
!self.web_dir.trim().is_empty(),
"{source} project `{}` has an empty web_dir",
self.app_id
);
ensure!(
!self.bin_target().trim().is_empty(),
"{source} project `{}` has an empty bin_target",
self.app_id
);
ensure!(
!self.backend_ready_timeout.is_zero(),
"{source} project `{}` has a zero backend readiness timeout",
self.app_id
);
if let Some(path) = &self.backend_ready_path {
ensure!(
path.starts_with('/') && !path.chars().any(char::is_control),
"{source} project `{}` has an invalid backend readiness path",
self.app_id
);
}
if let Some(target_dir) = &self.backend_target_dir {
ensure!(
!target_dir.trim().is_empty(),
"{source} project `{}` has an empty backend target directory",
self.app_id
);
}
api_addr(self)?;
for asset in &self.public_assets {
asset.validate(source, &self.app_id)?;
}
for asset in &self.optional_public_assets {
asset.validate(source, &self.app_id)?;
}
#[cfg(feature = "btcpay")]
{
if let Some(profile) = &self.btcpay {
profile.validate().map_err(|error| {
color_eyre::eyre::eyre!(
"{source} project `{}` has an invalid BTCPay profile: {error:#}",
self.app_id
)
})?;
}
}
Ok(())
}
}
impl ProjectPublicAsset {
pub fn wasm_bindgen(
id: impl Into<String>,
label: impl Into<String>,
package: impl Into<String>,
wasm_file: impl Into<String>,
out_dir: impl Into<Vec<String>>,
out_name: impl Into<String>,
wasm_bindgen_version: impl Into<String>,
) -> Self {
Self::WasmBindgen(WasmBindgenPublicAsset {
id: id.into(),
label: label.into(),
package: package.into(),
wasm_file: wasm_file.into(),
no_default_features: false,
features: String::new(),
target_dir: None,
out_dir: out_dir.into(),
out_name: out_name.into(),
wasm_bindgen_version: wasm_bindgen_version.into(),
})
}
pub fn validate(&self, source: &str, project_id: &str) -> DevResult {
match self {
Self::WasmBindgen(asset) => asset.validate(source, project_id),
}
}
pub fn with_no_default_features(mut self, no_default_features: bool) -> Self {
match &mut self {
Self::WasmBindgen(asset) => {
asset.no_default_features = no_default_features;
}
}
self
}
pub fn with_features(mut self, features: impl Into<String>) -> Self {
match &mut self {
Self::WasmBindgen(asset) => {
asset.features = features.into();
}
}
self
}
pub fn with_target_dir(mut self, target_dir: impl Into<String>) -> Self {
match &mut self {
Self::WasmBindgen(asset) => {
asset.target_dir = Some(target_dir.into());
}
}
self
}
fn id(&self) -> &str {
match self {
Self::WasmBindgen(asset) => &asset.id,
}
}
fn package(&self) -> &str {
match self {
Self::WasmBindgen(asset) => &asset.package,
}
}
}
impl WasmBindgenPublicAsset {
fn validate(&self, source: &str, project_id: &str) -> DevResult {
ensure!(
!self.id.trim().is_empty(),
"{source} project `{project_id}` has a wasm-bindgen asset with an empty id"
);
ensure!(
!self.label.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty label",
self.id
);
ensure!(
!self.package.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty package",
self.id
);
ensure!(
!self.wasm_file.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty wasm_file",
self.id
);
ensure!(
!self.out_dir.is_empty(),
"{source} project `{project_id}` asset `{}` must declare out_dir",
self.id
);
ensure!(
!self.out_name.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty out_name",
self.id
);
ensure!(
!self.wasm_bindgen_version.trim().is_empty(),
"{source} project `{project_id}` asset `{}` has an empty wasm_bindgen_version",
self.id
);
if let Some(target_dir) = &self.target_dir {
let target_dir = Path::new(target_dir);
ensure!(
target_dir.is_relative()
&& target_dir
.components()
.all(|component| matches!(component, std::path::Component::Normal(_))),
"{source} project `{project_id}` asset `{}` has an invalid target_dir",
self.id
);
}
Ok(())
}
}
impl WorkspacePaths {
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn project_root(&self) -> &Path {
&self.project_root
}
pub fn web_root(&self) -> &Path {
&self.web_root
}
}
pub fn run_project(profile: ProjectProfile, manifest_dir: impl AsRef<Path>) -> DevResult {
color_eyre::install()?;
match AppCli::parse().command {
AppCommand::Watch(command) => {
let profile = activate_optional_public_assets(
profile,
command.run.optional_assets.include_optional_assets,
);
let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
run_profile_with_backend_reload(
&profile,
&paths,
command.run.release,
RunMode::Watch,
!command.no_backend_reload,
)
}
AppCommand::Serve(command) => {
let profile = activate_optional_public_assets(
profile,
command.optional_assets.include_optional_assets,
);
let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
run_profile(&profile, &paths, command.release, RunMode::Serve)
}
AppCommand::Build(command) => {
let profile = activate_optional_public_assets(
profile,
command.optional_assets.include_optional_assets,
);
let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
build_profile(&profile, &paths, true)
}
AppCommand::BuildPublicAsset(command) => {
let paths = resolve_paths_from_manifest_dir(&profile, manifest_dir.as_ref())?;
let asset = profile
.public_assets
.iter()
.chain(&profile.optional_public_assets)
.find(|asset| asset.id() == command.id)
.ok_or_else(|| {
color_eyre::eyre::eyre!(
"project `{}` has no public asset named `{}`",
profile.app_id,
command.id
)
})?;
match asset {
ProjectPublicAsset::WasmBindgen(asset) => build_wasm_bindgen_public_asset(
&profile,
&paths,
asset,
command.release,
command.target_dir.as_deref(),
),
}
}
}
}
fn activate_optional_public_assets(
mut profile: ProjectProfile,
include_optional_assets: bool,
) -> ProjectProfile {
if include_optional_assets {
profile
.public_assets
.append(&mut profile.optional_public_assets);
}
profile
}
pub fn resolve_paths_from_manifest_dir(
profile: &ProjectProfile,
manifest_dir: &Path,
) -> DevResult<WorkspacePaths> {
let project_root = find_project_root_from_manifest_dir(manifest_dir, &profile.web_dir)?;
let workspace_root = find_workspace_root(&project_root)?;
let web_root = project_root.join(&profile.web_dir);
validate_paths(&project_root, &web_root)?;
Ok(WorkspacePaths {
workspace_root,
project_root,
web_root,
})
}
pub fn resolve_paths_from_workspace_project(
profile: &ProjectProfile,
repo_dir: &str,
) -> DevResult<WorkspacePaths> {
let cwd = env::current_dir().wrap_err("failed to resolve current directory")?;
let workspace_root = find_workspace_root(&cwd)?;
let project_root = workspace_root.join(repo_dir);
let web_root = project_root.join(&profile.web_dir);
validate_paths(&project_root, &web_root)?;
Ok(WorkspacePaths {
workspace_root,
project_root,
web_root,
})
}
pub fn run_profile(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
mode: RunMode,
) -> DevResult {
run_profile_with_backend_reload(profile, paths, release, mode, true)
}
fn run_profile_with_backend_reload(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
mode: RunMode,
backend_reload: bool,
) -> DevResult {
install_node_dependencies(&paths.web_root)?;
bootstrap_project_dev_services(profile, paths, release)?;
match mode {
RunMode::Watch => {
build_project_public_assets(profile, paths, release)?;
watch_project(profile, paths, release, backend_reload)
}
RunMode::Serve => {
build_project_public_assets(profile, paths, release)?;
build_react(&paths.web_root)?;
start_backend(profile, paths, release)
}
}
}
pub fn build_profile(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
install_node_dependencies(&paths.web_root)?;
build_project_public_assets(profile, paths, release)?;
build_react(&paths.web_root)
}
fn find_project_root_from_manifest_dir(manifest_dir: &Path, web_dir: &str) -> DevResult<PathBuf> {
let start = if manifest_dir.is_absolute() {
manifest_dir.to_path_buf()
} else {
env::current_dir()
.wrap_err("failed to resolve current directory")?
.join(manifest_dir)
};
let mut current = Some(start.as_path());
while let Some(candidate) = current {
if candidate.join("Cargo.toml").is_file()
&& candidate.join(web_dir).join("package.json").is_file()
{
return Ok(candidate.to_path_buf());
}
current = candidate.parent();
}
bail!(
"failed to resolve app project root from {}",
manifest_dir.display()
);
}
fn validate_paths(project_root: &Path, web_root: &Path) -> DevResult {
if !project_root.join("Cargo.toml").is_file() {
bail!(
"project manifest is missing at {}",
project_root.join("Cargo.toml").display()
);
}
if !web_root.join("package.json").is_file() {
bail!(
"web package manifest is missing at {}",
web_root.join("package.json").display()
);
}
Ok(())
}
#[cfg(feature = "btcpay")]
fn bootstrap_project_dev_services(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
) -> DevResult {
let Some(btcpay_profile) = profile.btcpay.clone() else {
return Ok(());
};
if release {
return Ok(());
}
let options = LocalBtcpayBootstrapOptions::new(btcpay_profile, &paths.project_root);
let summary = bootstrap_local_btcpay(&options)
.map_err(|error| color_eyre::eyre::eyre!("failed to bootstrap local BTCPay: {error:#}"))?;
eprintln!(
"BTCPay local dev bootstrap ready for {}: {} (store {}, api {}, webhook {}, env {})",
summary.app_display_name,
summary.url,
summary.store_id,
summary.api_key_masked,
summary.webhook_secret_masked,
summary.env_file.display()
);
Ok(())
}
#[cfg(not(feature = "btcpay"))]
fn bootstrap_project_dev_services(
_profile: &ProjectProfile,
_paths: &WorkspacePaths,
_release: bool,
) -> DevResult {
Ok(())
}
fn find_workspace_root(start: &Path) -> DevResult<PathBuf> {
let mut nearest_package = None;
for candidate in start.ancestors() {
let manifest_path = candidate.join("Cargo.toml");
if !manifest_path.is_file() {
continue;
}
let source = fs::read_to_string(&manifest_path).wrap_err_with(|| {
format!(
"failed to read Cargo manifest at {}",
manifest_path.display()
)
})?;
let manifest = toml::from_str::<toml::Table>(&source).wrap_err_with(|| {
format!(
"failed to parse Cargo manifest at {}",
manifest_path.display()
)
})?;
if manifest.contains_key("workspace") {
return Ok(candidate.to_path_buf());
}
if nearest_package.is_none() && manifest.contains_key("package") {
nearest_package = Some(candidate.to_path_buf());
}
}
nearest_package.ok_or_else(|| {
color_eyre::eyre::eyre!(
"failed to find a Cargo workspace or package manifest above {}",
start.display()
)
})
}
fn install_node_dependencies(web_root: &Path) -> DevResult {
if web_root.join("node_modules").is_dir() {
return Ok(());
}
run_command("Node dependency install", npm(), &["ci"], web_root, &[])
}
fn build_react(web_root: &Path) -> DevResult {
let script = resolve_build_script(web_root)?;
run_command("Web build", npm(), &["run", script], web_root, &[])
}
fn build_project_public_assets(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
) -> DevResult {
for asset in &profile.public_assets {
match asset {
ProjectPublicAsset::WasmBindgen(asset) => {
build_wasm_bindgen_public_asset(profile, paths, asset, release, None)?;
}
}
}
Ok(())
}
fn resolve_public_asset_target_dir(
profile: &ProjectProfile,
paths: &WorkspacePaths,
asset: &WasmBindgenPublicAsset,
target_dir_override: Option<&Path>,
) -> DevResult<PathBuf> {
if let Some(target_dir) = target_dir_override {
ensure!(
target_dir.is_absolute()
&& target_dir.components().all(|component| matches!(
component,
std::path::Component::Prefix(_)
| std::path::Component::RootDir
| std::path::Component::Normal(_)
))
&& target_dir
.components()
.any(|component| matches!(component, std::path::Component::Normal(_))),
"public asset --target-dir must be a nonempty absolute normal path"
);
return Ok(target_dir.to_path_buf());
}
Ok(asset.target_dir.as_ref().map_or_else(
|| {
paths
.project_root
.join("target")
.join(format!("{}-dev-{}-build", profile.app_id, asset.id))
},
|target_dir| paths.project_root.join(target_dir),
))
}
fn build_wasm_bindgen_public_asset(
profile: &ProjectProfile,
paths: &WorkspacePaths,
asset: &WasmBindgenPublicAsset,
release: bool,
target_dir_override: Option<&Path>,
) -> DevResult {
let target_dir = resolve_public_asset_target_dir(profile, paths, asset, target_dir_override)?;
let target_dir_arg = path_arg(&target_dir)?;
let mut args = vec![
"build",
"--lib",
"--package",
asset.package.as_str(),
"--locked",
"--target",
"wasm32-unknown-unknown",
];
if release {
args.push("--release");
}
if asset.no_default_features {
args.push("--no-default-features");
}
if !asset.features.trim().is_empty() {
args.push("--features");
args.push(asset.features.as_str());
}
args.push("--target-dir");
args.push(target_dir_arg);
run_command(
format!("{} WASM build", asset.label).as_str(),
cargo(),
&args,
&paths.project_root,
&[],
)?;
let wasm_input = target_dir
.join("wasm32-unknown-unknown")
.join(if release { "release" } else { "debug" })
.join(&asset.wasm_file);
if !wasm_input.is_file() {
bail!(
"missing expected {} WASM at {}",
asset.label,
wasm_input.display()
);
}
let mut out_dir = paths.web_root.clone();
for segment in &asset.out_dir {
out_dir.push(segment);
}
let out_parent = out_dir.parent().ok_or_else(|| {
color_eyre::eyre::eyre!(
"{} WASM output has no parent directory: {}",
asset.label,
out_dir.display()
)
})?;
fs::create_dir_all(out_parent).wrap_err_with(|| {
format!(
"failed to create {} WASM output parent at {}",
asset.label,
out_parent.display()
)
})?;
let out_name = out_dir.file_name().ok_or_else(|| {
color_eyre::eyre::eyre!(
"{} WASM output has no directory name: {}",
asset.label,
out_dir.display()
)
})?;
let staging_dir = out_parent.join(format!(".{}.building", out_name.to_string_lossy()));
if staging_dir.exists() {
fs::remove_dir_all(&staging_dir).wrap_err_with(|| {
format!(
"failed to remove stale {} WASM staging output at {}",
asset.label,
staging_dir.display()
)
})?;
}
fs::create_dir_all(&staging_dir).wrap_err_with(|| {
format!(
"failed to create {} WASM staging output at {}",
asset.label,
staging_dir.display()
)
})?;
let wasm_input_arg = path_arg(&wasm_input)?;
let staging_dir_arg = path_arg(&staging_dir)?;
if let Err(error) = run_command(
format!("{} wasm-bindgen", asset.label).as_str(),
resolve_wasm_bindgen(&asset.wasm_bindgen_version)?,
&[
wasm_input_arg,
"--target",
"web",
"--out-dir",
staging_dir_arg,
"--out-name",
asset.out_name.as_str(),
],
&paths.project_root,
&[],
) {
let _ = fs::remove_dir_all(&staging_dir);
return Err(error);
}
if out_dir.exists() {
fs::remove_dir_all(&out_dir).wrap_err_with(|| {
format!(
"failed to replace previous {} WASM output at {}",
asset.label,
out_dir.display()
)
})?;
}
fs::rename(&staging_dir, &out_dir).wrap_err_with(|| {
format!(
"failed to activate {} WASM output from {} to {}",
asset.label,
staging_dir.display(),
out_dir.display()
)
})
}
fn resolve_build_script(web_root: &Path) -> DevResult<&'static str> {
let package_json_path = web_root.join("package.json");
let package_json = std::fs::read_to_string(&package_json_path)
.wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
let package_json: Value = serde_json::from_str(&package_json)
.wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
let scripts = package_json
.get("scripts")
.and_then(Value::as_object)
.ok_or_else(|| color_eyre::eyre::eyre!("package.json has no scripts object"))?;
if scripts.contains_key("build") {
return Ok("build");
}
if scripts.contains_key("build:web") {
return Ok("build:web");
}
bail!(
"{} must define either `build` or `build:web`",
package_json_path.display()
);
}
fn start_backend(profile: &ProjectProfile, paths: &WorkspacePaths, release: bool) -> DevResult {
let args = backend_args(profile, release);
let envs = backend_envs(profile);
run_command("Rust backend", cargo(), &args, &paths.project_root, &envs)
}
fn watch_project(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
backend_reload: bool,
) -> DevResult {
let api_addr = api_addr(profile)?;
let backend_envs = backend_envs(profile);
let mut backend = if backend_reload {
let artifact = backend_executable_path(profile, paths, release);
let args = backend_build_args(profile, release);
run_command(
"initial Rust backend build",
cargo(),
&args,
&paths.project_root,
&backend_envs,
)?;
let active_stamp = backend_artifact_stamp(&artifact)?;
let active_image = backend_runtime_image_path(profile, &artifact, 0);
copy_backend_runtime_image(&artifact, &active_image)?;
let mut process = spawn_command(
"Rust backend",
active_image.as_os_str().to_os_string(),
&[] as &[&str],
&paths.project_root,
&[],
)?;
wait_for_backend_ready(
&mut process,
api_addr,
profile.backend_ready_timeout,
profile.backend_ready_path.as_deref(),
)?;
let watchexec = resolve_watchexec()?;
let watch_paths = resolve_backend_watch_paths(profile, paths)?;
let backend_args = backend_watchexec_args(profile, paths, &watch_paths, release);
let builder = spawn_command(
"Rust backend build watcher",
watchexec,
&backend_args,
&paths.project_root,
&backend_envs,
)?;
BackendWatchRuntime {
process,
builder: Some(builder),
artifact: Some(artifact),
active_image: Some(active_image),
active_stamp: Some(active_stamp),
generation: 0,
}
} else {
let backend_args = backend_args(profile, release);
let mut process = spawn_command(
"Rust backend",
cargo(),
&backend_args,
&paths.project_root,
&backend_envs,
)?;
wait_for_backend_ready(
&mut process,
api_addr,
profile.backend_ready_timeout,
profile.backend_ready_path.as_deref(),
)?;
BackendWatchRuntime {
process,
builder: None,
artifact: None,
active_image: None,
active_stamp: None,
generation: 0,
}
};
let mut public_asset_watchers = spawn_public_asset_watchers(profile, paths, release)?;
let backend_origin = format!("http://127.0.0.1:{}", api_addr.port());
let web_envs = web_dev_envs(profile, &backend_origin);
let mut web = spawn_command(
"web dev server",
npm(),
&["run", "dev"],
&paths.web_root,
&web_envs,
)?;
wait_for_watch_processes(
profile,
paths,
api_addr,
&mut backend,
&mut web,
&mut public_asset_watchers,
)
}
fn wait_for_backend_ready(
backend: &mut ManagedChild,
api_addr: SocketAddr,
ready_timeout: Duration,
ready_path: Option<&str>,
) -> DevResult {
let ready_addr = SocketAddr::from(([127, 0, 0, 1], api_addr.port()));
let started_at = Instant::now();
loop {
if let Some(status) = backend.try_wait().wrap_err("failed to poll Rust backend")? {
if status.success() {
bail!(
"Rust backend exited before it became ready at {}",
ready_addr
);
}
bail!(
"Rust backend exited with status {:?} before it became ready at {}",
status.code(),
ready_addr
);
}
let readiness = match ready_path {
Some(path) => probe_backend_http(ready_addr, path),
None => {
TcpStream::connect_timeout(&ready_addr, BACKEND_READY_CONNECT_TIMEOUT).map(|_| ())
}
};
let last_error = match readiness {
Ok(()) => return Ok(()),
Err(error) => error,
};
if started_at.elapsed() >= ready_timeout {
backend.terminate();
bail!(
"Rust backend did not become ready at {} within {:?}: {}",
ready_addr,
ready_timeout,
last_error
);
}
thread::sleep(BACKEND_READY_POLL_INTERVAL);
}
}
fn probe_backend_http(ready_addr: SocketAddr, path: &str) -> std::io::Result<()> {
let mut stream = TcpStream::connect_timeout(&ready_addr, BACKEND_READY_CONNECT_TIMEOUT)?;
stream.set_read_timeout(Some(BACKEND_READY_CONNECT_TIMEOUT))?;
stream.set_write_timeout(Some(BACKEND_READY_CONNECT_TIMEOUT))?;
write!(
stream,
"GET {path} HTTP/1.1\r\nHost: {ready_addr}\r\nConnection: close\r\n\r\n"
)?;
let mut prefix = [0_u8; 5];
stream.read_exact(&mut prefix)?;
if prefix == *b"HTTP/" {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"backend readiness response is not HTTP",
))
}
}
fn backend_args(profile: &ProjectProfile, release: bool) -> Vec<&str> {
let mut args = vec!["run", "--bin", profile.bin_target(), "--locked"];
if release {
args.push("--release");
}
args
}
fn backend_build_args(profile: &ProjectProfile, release: bool) -> Vec<&str> {
let mut args = vec!["build", "--bin", profile.bin_target(), "--locked"];
if release {
args.push("--release");
}
args
}
fn backend_executable_path(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
) -> PathBuf {
let target_root = profile.backend_target_dir.as_ref().map_or_else(
|| paths.workspace_root.join("target"),
|target_dir| {
let target_dir = Path::new(target_dir);
if target_dir.is_absolute() {
target_dir.to_path_buf()
} else {
paths.project_root.join(target_dir)
}
},
);
target_root
.join(if release { "release" } else { "debug" })
.join(format!(
"{}{}",
profile.bin_target(),
env::consts::EXE_SUFFIX
))
}
fn backend_artifact_stamp(path: &Path) -> DevResult<BackendArtifactStamp> {
backend_artifact_stamp_if_present(path)?
.ok_or_else(|| color_eyre::eyre::eyre!("Rust backend is missing at {}", path.display()))
}
fn backend_artifact_stamp_if_present(path: &Path) -> DevResult<Option<BackendArtifactStamp>> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error)
.wrap_err_with(|| format!("failed to inspect Rust backend at {}", path.display()));
}
};
Ok(Some(BackendArtifactStamp {
modified: metadata.modified().wrap_err_with(|| {
format!(
"failed to read Rust backend timestamp at {}",
path.display()
)
})?,
length: metadata.len(),
}))
}
fn backend_runtime_image_path(
profile: &ProjectProfile,
artifact: &Path,
generation: u64,
) -> PathBuf {
artifact
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!(
".{}-ch-rds-{}-{generation}{}",
profile.bin_target(),
std::process::id(),
env::consts::EXE_SUFFIX
))
}
fn copy_backend_runtime_image(artifact: &Path, image: &Path) -> DevResult {
fs::copy(artifact, image).wrap_err_with(|| {
format!(
"failed to prepare Rust backend runtime image from {} to {}",
artifact.display(),
image.display()
)
})?;
Ok(())
}
fn backend_watchexec_args(
profile: &ProjectProfile,
paths: &WorkspacePaths,
watch_paths: &BackendWatchPaths,
release: bool,
) -> Vec<OsString> {
let mut args = vec![
OsString::from("--on-busy-update=queue"),
OsString::from(format!("--debounce={WATCH_DEBOUNCE}")),
OsString::from("--shell=none"),
OsString::from("--emit-events-to=none"),
OsString::from("--project-origin"),
paths.project_root.as_os_str().to_os_string(),
OsString::from("--exts"),
OsString::from("rs,toml,lock,sql"),
];
for path in &watch_paths.paths {
args.push(OsString::from("--watch"));
args.push(path.as_os_str().to_os_string());
}
args.push(OsString::from("--"));
args.push(cargo());
args.extend(
backend_build_args(profile, release)
.into_iter()
.map(OsString::from),
);
args
}
fn public_asset_watchexec_args(
paths: &WorkspacePaths,
asset: &ProjectPublicAsset,
watch_paths: &BackendWatchPaths,
release: bool,
) -> DevResult<Vec<OsString>> {
let mut args = vec![
OsString::from("--on-busy-update=queue"),
OsString::from(format!("--debounce={WATCH_DEBOUNCE}")),
OsString::from("--shell=none"),
OsString::from("--emit-events-to=none"),
OsString::from("--project-origin"),
paths.project_root.as_os_str().to_os_string(),
OsString::from("--exts"),
OsString::from("rs,toml,lock"),
];
for path in &watch_paths.paths {
args.push(OsString::from("--watch"));
args.push(path.as_os_str().to_os_string());
}
args.push(OsString::from("--"));
args.push(
env::current_exe()
.wrap_err("failed to resolve the project task executable")?
.into_os_string(),
);
args.push(OsString::from("build-public-asset"));
args.push(OsString::from("--id"));
args.push(OsString::from(asset.id()));
if release {
args.push(OsString::from("--release"));
}
Ok(args)
}
fn spawn_public_asset_watchers(
profile: &ProjectProfile,
paths: &WorkspacePaths,
release: bool,
) -> DevResult<Vec<ManagedChild>> {
if profile.public_assets.is_empty() {
return Ok(Vec::new());
}
let watchexec = resolve_watchexec()?;
let metadata = load_cargo_metadata(paths, "public asset reloads")?;
let mut watchers = Vec::with_capacity(profile.public_assets.len());
for asset in &profile.public_assets {
let watch_paths = package_watch_paths_from_metadata(&metadata, asset.package())?;
let args = public_asset_watchexec_args(paths, asset, &watch_paths, release)?;
watchers.push(spawn_command(
format!("{} public asset watcher", asset.id()).as_str(),
watchexec.clone(),
&args,
&paths.project_root,
&[],
)?);
}
Ok(watchers)
}
fn resolve_watchexec() -> DevResult<OsString> {
let executable = find_on_path("watchexec").ok_or_else(|| {
color_eyre::eyre::eyre!(
"Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
)
})?;
let output = ProcessCommand::new(&executable)
.arg("--version")
.output()
.wrap_err("failed to check the installed Watchexec version")?;
if output.status.success() {
return Ok(executable.into_os_string());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let details = stderr.trim();
if details.is_empty() {
bail!(
"Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`"
);
}
bail!(
"Watchexec is required for backend reloads; install it with `cargo install watchexec-cli --version 2.5.1 --locked`\n\n{details}"
);
}
fn resolve_backend_watch_paths(
profile: &ProjectProfile,
paths: &WorkspacePaths,
) -> DevResult<BackendWatchPaths> {
let metadata = load_cargo_metadata(paths, "backend reloads")?;
backend_watch_paths_from_metadata(&metadata, profile.bin_target())
}
fn load_cargo_metadata(paths: &WorkspacePaths, purpose: &str) -> DevResult<CargoMetadata> {
let output = ProcessCommand::new(cargo())
.args(["metadata", "--format-version=1", "--locked"])
.current_dir(&paths.project_root)
.output()
.wrap_err_with(|| format!("failed to read Cargo metadata for {purpose}"))?;
if !output.status.success() {
let details = String::from_utf8_lossy(&output.stderr);
bail!("Cargo metadata failed for {purpose}: {}", details.trim());
}
serde_json::from_slice::<CargoMetadata>(&output.stdout)
.wrap_err_with(|| format!("failed to parse Cargo metadata for {purpose}"))
}
fn backend_watch_paths_from_metadata(
metadata: &CargoMetadata,
bin_target: &str,
) -> DevResult<BackendWatchPaths> {
let root_ids = metadata
.packages
.iter()
.filter(|package| {
package.targets.iter().any(|target| {
target.name == bin_target && target.kind.iter().any(|kind| kind == "bin")
})
})
.map(|package| package.id.as_str())
.collect::<Vec<_>>();
ensure!(
!root_ids.is_empty(),
"Cargo metadata contains no binary target named `{bin_target}`"
);
watch_paths_from_metadata_roots(metadata, root_ids)
}
fn package_watch_paths_from_metadata(
metadata: &CargoMetadata,
package_name: &str,
) -> DevResult<BackendWatchPaths> {
let root_ids = metadata
.packages
.iter()
.filter(|package| package.name == package_name)
.map(|package| package.id.as_str())
.collect::<Vec<_>>();
ensure!(
!root_ids.is_empty(),
"Cargo metadata contains no package named `{package_name}`"
);
watch_paths_from_metadata_roots(metadata, root_ids)
}
fn watch_paths_from_metadata_roots<'a>(
metadata: &'a CargoMetadata,
root_ids: Vec<&'a str>,
) -> DevResult<BackendWatchPaths> {
let resolve = metadata
.resolve
.as_ref()
.ok_or_else(|| color_eyre::eyre::eyre!("Cargo metadata did not include a resolve graph"))?;
let nodes = resolve
.nodes
.iter()
.map(|node| (node.id.as_str(), node))
.collect::<HashMap<_, _>>();
let mut reachable = BTreeSet::new();
let mut pending = root_ids.into_iter().collect::<VecDeque<_>>();
while let Some(package_id) = pending.pop_front() {
if !reachable.insert(package_id) {
continue;
}
if let Some(node) = nodes.get(package_id) {
pending.extend(node.deps.iter().map(|dependency| dependency.pkg.as_str()));
}
}
let mut watch_paths = BTreeSet::new();
for package in metadata
.packages
.iter()
.filter(|package| package.source.is_none() && reachable.contains(package.id.as_str()))
{
let package_root = package.manifest_path.parent().ok_or_else(|| {
color_eyre::eyre::eyre!(
"Cargo package manifest has no parent: {}",
package.manifest_path.display()
)
})?;
insert_existing_watch_path(&mut watch_paths, &package.manifest_path)?;
let workspace_root = find_workspace_root(package_root)?;
insert_existing_watch_path(&mut watch_paths, &workspace_root.join("Cargo.toml"))?;
for workspace_file in ["Cargo.lock", "rust-toolchain.toml", ".rust-version"] {
let workspace_file = workspace_root.join(workspace_file);
if workspace_file.is_file() {
insert_existing_watch_path(&mut watch_paths, &workspace_file)?;
}
}
let cargo_config_dir = workspace_root.join(".cargo");
if cargo_config_dir.is_dir() {
insert_existing_watch_path(&mut watch_paths, &cargo_config_dir)?;
}
let conventional_source_root = package_root.join("src");
if conventional_source_root.is_dir() {
insert_existing_watch_path(&mut watch_paths, &conventional_source_root)?;
}
for target in &package.targets {
if !target.src_path.starts_with(&conventional_source_root) && target.src_path.is_file()
{
insert_existing_watch_path(&mut watch_paths, &target.src_path)?;
}
}
let migrations = package_root.join("migrations");
if migrations.is_dir() {
insert_existing_watch_path(&mut watch_paths, &migrations)?;
}
}
Ok(BackendWatchPaths {
paths: watch_paths.into_iter().collect(),
})
}
fn insert_existing_watch_path(paths: &mut BTreeSet<PathBuf>, path: &Path) -> DevResult {
let canonical = path
.canonicalize()
.wrap_err_with(|| format!("failed to resolve backend watch path {}", path.display()))?;
paths.insert(canonical);
Ok(())
}
fn api_addr(profile: &ProjectProfile) -> DevResult<SocketAddr> {
profile.api_addr.parse::<SocketAddr>().wrap_err_with(|| {
format!(
"invalid site address `{}` for project `{}`",
profile.api_addr, profile.app_id
)
})
}
fn web_dev_envs(profile: &ProjectProfile, backend_origin: &str) -> Vec<(String, String)> {
let mut envs = Vec::new();
if let Some(origin_env) = &profile.web_backend_origin_env {
envs.push((origin_env.clone(), backend_origin.to_string()));
}
envs
}
fn backend_envs(profile: &ProjectProfile) -> Vec<(String, String)> {
profile
.backend_target_dir
.as_ref()
.map(|target_dir| vec![("CARGO_TARGET_DIR".to_string(), target_dir.clone())])
.unwrap_or_default()
}
fn wait_for_watch_processes(
profile: &ProjectProfile,
paths: &WorkspacePaths,
api_addr: SocketAddr,
backend: &mut BackendWatchRuntime,
web: &mut ManagedChild,
public_asset_watchers: &mut [ManagedChild],
) -> DevResult {
loop {
if let Some(status) = backend
.process
.try_wait()
.wrap_err("failed to poll Rust backend")?
{
if status.success() {
bail!("Rust backend exited before the web dev server");
}
bail!("Rust backend exited with status {:?}", status.code());
}
if let Some(builder) = backend.builder.as_mut()
&& let Some(status) = builder
.try_wait()
.wrap_err("failed to poll Rust backend build watcher")?
{
bail!(
"Rust backend build watcher exited unexpectedly with status {:?}",
status.code()
);
}
refresh_backend_after_successful_build(profile, paths, api_addr, backend)?;
if let Some(status) = web.try_wait().wrap_err("failed to poll web dev server")? {
if status.success() {
return Ok(());
}
bail!("web dev server exited with status {:?}", status.code());
}
let mut watcher_exit = None;
for watcher in public_asset_watchers.iter_mut() {
if let Some(status) = watcher
.try_wait()
.wrap_err("failed to poll a public asset watcher")?
{
watcher_exit = Some(status);
break;
}
}
if let Some(status) = watcher_exit {
bail!(
"public asset watcher exited unexpectedly with status {:?}",
status.code()
);
}
thread::sleep(Duration::from_millis(250));
}
}
fn refresh_backend_after_successful_build(
profile: &ProjectProfile,
paths: &WorkspacePaths,
api_addr: SocketAddr,
backend: &mut BackendWatchRuntime,
) -> DevResult {
let Some(artifact) = backend.artifact.as_ref() else {
return Ok(());
};
let Some(candidate_stamp) = backend_artifact_stamp_if_present(artifact)? else {
return Ok(());
};
if backend.active_stamp == Some(candidate_stamp) {
return Ok(());
}
let candidate_generation = backend.generation.saturating_add(1);
let candidate_image = backend_runtime_image_path(profile, artifact, candidate_generation);
copy_backend_runtime_image(artifact, &candidate_image)?;
backend.process.terminate();
let candidate = spawn_command(
"rebuilt Rust backend",
candidate_image.as_os_str().to_os_string(),
&[] as &[&str],
&paths.project_root,
&[],
);
let candidate = candidate.and_then(|mut candidate| {
wait_for_backend_ready(
&mut candidate,
api_addr,
profile.backend_ready_timeout,
profile.backend_ready_path.as_deref(),
)
.map(|()| candidate)
});
match candidate {
Ok(candidate) => {
remove_backend_runtime_image(backend.active_image.replace(candidate_image));
backend.process = candidate;
backend.active_stamp = Some(candidate_stamp);
backend.generation = candidate_generation;
eprintln!(
"Activated rebuilt Rust backend generation {} from {}",
backend.generation,
artifact.display()
);
Ok(())
}
Err(error) => {
remove_backend_runtime_image(Some(candidate_image));
let fallback_image = backend.active_image.as_ref().ok_or_else(|| {
color_eyre::eyre::eyre!(
"rebuilt Rust backend failed and no last successful runtime image exists"
)
})?;
let mut fallback = spawn_command(
"last successful Rust backend",
fallback_image.as_os_str().to_os_string(),
&[] as &[&str],
&paths.project_root,
&[],
)?;
wait_for_backend_ready(
&mut fallback,
api_addr,
profile.backend_ready_timeout,
profile.backend_ready_path.as_deref(),
)?;
backend.process = fallback;
backend.active_stamp = Some(candidate_stamp);
eprintln!(
"Rebuilt Rust backend did not become ready: {error:#}. Continuing with the last successful backend until another build succeeds."
);
Ok(())
}
}
}
impl Drop for BackendWatchRuntime {
fn drop(&mut self) {
self.process.terminate();
if let Some(builder) = self.builder.as_mut() {
builder.terminate();
}
remove_backend_runtime_image(self.active_image.take());
}
}
fn remove_backend_runtime_image(path: Option<PathBuf>) {
if let Some(path) = path {
let _ = fs::remove_file(path);
}
}
fn run_command<S: AsRef<OsStr>>(
label: &str,
executable: OsString,
args: &[S],
cwd: &Path,
envs: &[(String, String)],
) -> DevResult {
let mut child = spawn_command(label, executable, args, cwd, envs)?;
let status = child
.wait()
.wrap_err_with(|| format!("failed to wait for {label}"))?;
if status.success() {
return Ok(());
}
bail!("{label} exited with status {:?}", status.code());
}
fn spawn_command<S: AsRef<OsStr>>(
label: &str,
executable: OsString,
args: &[S],
cwd: &Path,
envs: &[(String, String)],
) -> DevResult<ManagedChild> {
let mut command = ProcessCommand::new(executable);
command.current_dir(cwd).args(args);
for (key, value) in envs {
command.env(key, value);
}
command
.spawn()
.map(|child| ManagedChild { child })
.wrap_err_with(|| format!("failed to start {label}"))
}
fn path_arg(path: &Path) -> DevResult<&str> {
path.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("path is not valid UTF-8: {}", path.display()))
}
fn cargo() -> OsString {
env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
}
fn npm() -> OsString {
match cfg!(windows) {
true => OsString::from("npm.cmd"),
false => OsString::from("npm"),
}
}
fn resolve_wasm_bindgen(required_version: &str) -> DevResult<OsString> {
if let Some(configured) = env::var_os("WASM_BINDGEN") {
if wasm_bindgen_matches(&configured, required_version) {
return Ok(configured);
}
bail!(
"WASM_BINDGEN does not point to wasm-bindgen {}",
required_version
);
}
if let Some(executable) = find_on_path("wasm-bindgen") {
let executable = executable.into_os_string();
if wasm_bindgen_matches(&executable, required_version) {
return Ok(executable);
}
}
bail!(
"wasm-bindgen {} is required for configured WASM public assets; install it with `cargo install wasm-bindgen-cli --version {} --locked` or set WASM_BINDGEN",
required_version,
required_version
);
}
fn wasm_bindgen_matches(executable: &OsString, required_version: &str) -> bool {
let output = ProcessCommand::new(executable).arg("--version").output();
let Ok(output) = output else {
return false;
};
let text = String::from_utf8_lossy(&output.stdout);
output.status.success() && text.contains(required_version)
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let path = env::var_os("PATH")?;
for directory in env::split_paths(&path) {
let candidate = directory.join(name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
let candidate = directory.join(format!("{name}.exe"));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new(label: &str) -> Self {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = env::temp_dir().join(format!(
"cargo-workspace-{label}-{}-{sequence}",
std::process::id()
));
fs::create_dir_all(&path).expect("test directory should be created");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn project_profile_reuses_the_explicit_identity_by_default() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
assert_eq!(profile.app_id(), "example-service");
assert_eq!(profile.bin_target(), "example-service");
profile
.validate("test profile")
.expect("single-identity profile should be valid");
}
#[test]
fn project_profile_can_isolate_backend_builds() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_backend_target_dir("target/example-backend");
assert_eq!(
backend_envs(&profile),
vec![(
"CARGO_TARGET_DIR".to_string(),
"target/example-backend".to_string()
)]
);
profile
.validate("test profile")
.expect("isolated backend profile should be valid");
}
#[test]
fn wasm_asset_can_use_a_project_relative_target_directory() {
let asset = ProjectPublicAsset::wasm_bindgen(
"editor",
"Example editor",
"example-editor",
"example.wasm",
vec!["public".to_string()],
"example",
"0.2.126",
)
.with_target_dir("build/web/wasm");
asset
.validate("test profile", "example-service")
.expect("project-relative target directory should be valid");
}
#[test]
fn public_asset_command_parses_optional_absolute_target_directory() {
let default = AppCli::try_parse_from(["app", "build-public-asset", "--id", "editor"])
.expect("existing public asset invocation should parse");
let AppCommand::BuildPublicAsset(default) = default.command else {
panic!("expected public asset command");
};
assert_eq!(default.id, "editor");
assert!(!default.release);
assert!(default.target_dir.is_none());
let directory = TestDirectory::new("public-asset-cli-target");
let target = directory.path().join("wasm-cache");
let explicit = AppCli::try_parse_from(vec![
OsString::from("app"),
OsString::from("build-public-asset"),
OsString::from("--id"),
OsString::from("editor"),
OsString::from("--release"),
OsString::from("--target-dir"),
target.as_os_str().to_os_string(),
])
.expect("explicit public asset target directory should parse");
let AppCommand::BuildPublicAsset(explicit) = explicit.command else {
panic!("expected public asset command");
};
assert!(explicit.release);
assert_eq!(explicit.target_dir, Some(target));
}
#[test]
fn public_asset_target_directory_preserves_defaults_and_absolute_override() {
let directory = TestDirectory::new("public-asset-target-resolution");
let paths = WorkspacePaths {
workspace_root: directory.path().to_path_buf(),
project_root: directory.path().to_path_buf(),
web_root: directory.path().join("web"),
};
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100");
let ProjectPublicAsset::WasmBindgen(mut asset) = ProjectPublicAsset::wasm_bindgen(
"editor",
"Example editor",
"example-editor",
"example.wasm",
vec!["public".to_owned()],
"example",
"0.2.126",
);
assert_eq!(
resolve_public_asset_target_dir(&profile, &paths, &asset, None).unwrap(),
paths
.project_root
.join("target/example-service-dev-editor-build")
);
asset.target_dir = Some("build/web/wasm".to_owned());
assert_eq!(
resolve_public_asset_target_dir(&profile, &paths, &asset, None).unwrap(),
paths.project_root.join("build/web/wasm")
);
let explicit = directory.path().join("separate-ssd-cache");
assert_eq!(
resolve_public_asset_target_dir(&profile, &paths, &asset, Some(&explicit)).unwrap(),
explicit
);
assert_eq!(asset.target_dir.as_deref(), Some("build/web/wasm"));
for invalid in [
PathBuf::new(),
PathBuf::from("relative/cache"),
directory.path().join("../cache"),
] {
assert!(
resolve_public_asset_target_dir(&profile, &paths, &asset, Some(&invalid)).is_err()
);
}
}
#[test]
fn missing_backend_artifact_is_a_transient_watch_state() {
let directory = TestDirectory::new("backend-artifact-transition");
let artifact = directory.path().join("example-server");
assert_eq!(
backend_artifact_stamp_if_present(&artifact)
.expect("missing artifact should remain a valid watch state"),
None
);
fs::write(&artifact, b"new backend").expect("backend artifact should be written");
let stamp = backend_artifact_stamp_if_present(&artifact)
.expect("finished artifact should be readable")
.expect("finished artifact should be present");
assert_eq!(stamp.length, 11);
}
#[test]
fn builds_backend_artifacts_before_restarting_the_running_generation() {
let directory = TestDirectory::new("watchexec-args");
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_bin_target("example-server");
let paths = WorkspacePaths {
workspace_root: directory.path().to_path_buf(),
project_root: directory.path().to_path_buf(),
web_root: directory.path().join("web"),
};
let watch_paths = BackendWatchPaths {
paths: vec![directory.path().join("src")],
};
assert_eq!(
backend_watchexec_args(&profile, &paths, &watch_paths, false),
[
OsString::from("--on-busy-update=queue"),
OsString::from("--debounce=750ms"),
OsString::from("--shell=none"),
OsString::from("--emit-events-to=none"),
OsString::from("--project-origin"),
directory.path().as_os_str().to_os_string(),
OsString::from("--exts"),
OsString::from("rs,toml,lock,sql"),
OsString::from("--watch"),
directory.path().join("src").into_os_string(),
OsString::from("--"),
cargo(),
OsString::from("build"),
OsString::from("--bin"),
OsString::from("example-server"),
OsString::from("--locked"),
]
);
let release_args = backend_watchexec_args(&profile, &paths, &watch_paths, true);
assert_eq!(release_args.last(), Some(&OsString::from("--release")));
}
#[test]
fn public_asset_watcher_rebuilds_after_the_initial_public_asset_gate() {
let directory = TestDirectory::new("public-asset-watcher");
let paths = WorkspacePaths {
workspace_root: directory.path().to_path_buf(),
project_root: directory.path().to_path_buf(),
web_root: directory.path().join("web"),
};
let watch_paths = BackendWatchPaths {
paths: vec![directory.path().join("src")],
};
let asset = ProjectPublicAsset::wasm_bindgen(
"editor",
"Example editor",
"example-editor",
"example.wasm",
vec!["public".to_string()],
"example",
"0.2.126",
);
let args = public_asset_watchexec_args(&paths, &asset, &watch_paths, false)
.expect("public asset watcher command should resolve");
assert!(!args.contains(&OsString::from("--postpone")));
assert!(args.contains(&OsString::from("--on-busy-update=queue")));
assert!(args.contains(&OsString::from("--debounce=750ms")));
assert!(args.contains(&OsString::from("build-public-asset")));
}
#[test]
fn watch_cli_can_start_the_backend_once_without_a_reload_supervisor() {
let cli = AppCli::try_parse_from(["project-xtask", "watch", "--no-backend-reload"])
.expect("watch command should accept the backend reload opt-out");
let AppCommand::Watch(command) = cli.command else {
panic!("watch command should preserve its typed owner");
};
assert!(command.no_backend_reload);
assert!(!command.run.release);
}
#[test]
fn optional_asset_switch_is_available_for_watch_serve_and_build() {
for command_args in [
["project-xtask", "watch", "--editor"].as_slice(),
["project-xtask", "serve", "--with-optional-assets"].as_slice(),
["project-xtask", "build", "--editor"].as_slice(),
] {
let cli = AppCli::try_parse_from(command_args)
.expect("optional asset switch should be accepted by every run mode");
let include_optional_assets = match cli.command {
AppCommand::Watch(command) => command.run.optional_assets.include_optional_assets,
AppCommand::Serve(command) => command.optional_assets.include_optional_assets,
AppCommand::Build(command) => command.optional_assets.include_optional_assets,
AppCommand::BuildPublicAsset(_) => {
panic!("internal public asset command is not part of this matrix")
}
};
assert!(include_optional_assets);
}
}
#[test]
fn optional_public_assets_are_not_active_without_the_explicit_switch() {
let asset = ProjectPublicAsset::wasm_bindgen(
"editor",
"Example editor",
"example-editor",
"example.wasm",
vec!["public".to_string()],
"example",
"0.2.126",
);
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_optional_public_asset(asset);
let default_profile = activate_optional_public_assets(profile.clone(), false);
assert!(default_profile.public_assets.is_empty());
assert_eq!(default_profile.optional_public_assets.len(), 1);
let enabled_profile = activate_optional_public_assets(profile, true);
assert_eq!(enabled_profile.public_assets.len(), 1);
assert!(enabled_profile.optional_public_assets.is_empty());
}
#[test]
fn watches_only_local_packages_reachable_from_the_backend() {
let directory = TestDirectory::new("cargo-metadata");
let app_workspace = directory.path().join("app-workspace");
let app_root = app_workspace.join("app");
let unused_root = app_workspace.join("unused");
let shared_workspace = directory.path().join("shared-workspace");
let shared_root = shared_workspace.join("crates").join("shared");
for source_root in [
app_root.join("src"),
unused_root.join("src"),
shared_root.join("src"),
] {
fs::create_dir_all(source_root).expect("source directory should be created");
}
fs::write(
app_workspace.join("Cargo.toml"),
"[workspace]\nmembers = [\"app\", \"unused\"]\nresolver = \"3\"\n",
)
.expect("app workspace manifest should be written");
fs::write(
shared_workspace.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/shared\"]\nresolver = \"3\"\n",
)
.expect("shared workspace manifest should be written");
for (root, name) in [
(&app_root, "app"),
(&unused_root, "unused"),
(&shared_root, "shared"),
] {
fs::write(
root.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
)
.expect("package manifest should be written");
}
let package = |id: &str, root: &Path, target_name: &str, kind: &str| CargoPackage {
id: id.to_string(),
name: id.to_string(),
source: None,
manifest_path: root.join("Cargo.toml"),
targets: vec![CargoTarget {
kind: vec![kind.to_string()],
name: target_name.to_string(),
src_path: root
.join("src")
.join(if kind == "bin" { "main.rs" } else { "lib.rs" }),
}],
};
let metadata = CargoMetadata {
packages: vec![
package("app", &app_root, "example-server", "bin"),
package("shared", &shared_root, "shared", "lib"),
package("unused", &unused_root, "unused", "lib"),
CargoPackage {
id: "registry".to_string(),
name: "registry".to_string(),
source: Some("registry+https://example.invalid".to_string()),
manifest_path: directory.path().join("registry").join("Cargo.toml"),
targets: Vec::new(),
},
],
resolve: Some(CargoResolve {
nodes: vec![
CargoResolveNode {
id: "app".to_string(),
deps: vec![
CargoResolveDependency {
pkg: "shared".to_string(),
},
CargoResolveDependency {
pkg: "registry".to_string(),
},
],
},
CargoResolveNode {
id: "shared".to_string(),
deps: Vec::new(),
},
CargoResolveNode {
id: "unused".to_string(),
deps: Vec::new(),
},
CargoResolveNode {
id: "registry".to_string(),
deps: Vec::new(),
},
],
}),
};
let watch_paths = backend_watch_paths_from_metadata(&metadata, "example-server")
.expect("backend watch paths should resolve");
let public_asset_watch_paths = package_watch_paths_from_metadata(&metadata, "app")
.expect("public asset watch paths should resolve");
assert_eq!(watch_paths, public_asset_watch_paths);
assert!(
watch_paths
.paths
.contains(&app_root.join("src").canonicalize().unwrap())
);
assert!(
watch_paths
.paths
.contains(&shared_root.join("src").canonicalize().unwrap())
);
assert!(
!watch_paths
.paths
.contains(&unused_root.join("src").canonicalize().unwrap())
);
assert!(
watch_paths
.paths
.contains(&app_workspace.join("Cargo.toml").canonicalize().unwrap())
);
assert!(
watch_paths
.paths
.contains(&shared_workspace.join("Cargo.toml").canonicalize().unwrap())
);
assert!(
!watch_paths
.paths
.contains(&app_root.canonicalize().unwrap())
);
}
#[test]
fn project_profile_keeps_an_explicit_binary_override() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_bin_target("custom-server");
assert_eq!(profile.app_id(), "example-service");
assert_eq!(profile.bin_target(), "custom-server");
}
#[test]
fn project_profile_accepts_a_cold_backend_build_timeout() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_backend_ready_timeout(Duration::from_secs(900));
assert_eq!(profile.backend_ready_timeout, Duration::from_secs(900));
profile
.validate("test profile")
.expect("positive readiness timeout should be valid");
}
#[test]
fn project_profile_rejects_a_zero_backend_build_timeout() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_backend_ready_timeout(Duration::ZERO);
let error = profile
.validate("test profile")
.expect_err("zero readiness timeout should be rejected");
assert!(error.to_string().contains("zero backend readiness timeout"));
}
#[test]
fn project_profile_accepts_an_http_backend_readiness_path() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_backend_ready_path("/ready");
assert_eq!(profile.backend_ready_path.as_deref(), Some("/ready"));
profile
.validate("test profile")
.expect("absolute HTTP readiness path should be valid");
}
#[test]
fn project_profile_rejects_an_invalid_http_backend_readiness_path() {
let profile = ProjectProfile::new("example-service", "127.0.0.1:4100")
.with_backend_ready_path("ready\r\ninvalid");
let error = profile
.validate("test profile")
.expect_err("invalid HTTP readiness path should be rejected");
assert!(error.to_string().contains("invalid backend readiness path"));
}
#[test]
fn http_readiness_requires_an_http_response() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener address should resolve");
probe_backend_http(addr, "/ready")
.expect_err("a bound socket without a serving backend is not ready");
}
#[test]
fn http_readiness_accepts_a_serving_backend() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener
.local_addr()
.expect("listener address should resolve");
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("readiness request should connect");
let mut request = [0_u8; 256];
let read = stream
.read(&mut request)
.expect("readiness request should be readable");
assert!(read > 0);
stream
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
.expect("readiness response should be written");
});
probe_backend_http(addr, "/ready").expect("serving backend should be ready");
server.join().expect("readiness server should finish");
}
#[test]
fn finds_the_owning_cargo_workspace_above_a_member_package() {
let directory = TestDirectory::new("member");
let workspace_root = directory.path().join("workspace");
let package_root = workspace_root.join("crates").join("service");
let source_root = package_root.join("src");
fs::create_dir_all(&source_root).expect("member source directory should be created");
fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/service\"]\n",
)
.expect("workspace manifest should be written");
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
)
.expect("package manifest should be written");
assert_eq!(
find_workspace_root(&source_root).expect("workspace should resolve"),
workspace_root
);
}
#[test]
fn uses_the_nearest_standalone_package_when_no_workspace_exists() {
let directory = TestDirectory::new("standalone");
let package_root = directory.path().join("service");
let source_root = package_root.join("src");
fs::create_dir_all(&source_root).expect("package source directory should be created");
fs::write(
package_root.join("Cargo.toml"),
"[package]\nname = \"service\"\nversion = \"0.1.0\"\n",
)
.expect("package manifest should be written");
assert_eq!(
find_workspace_root(&source_root).expect("package root should resolve"),
package_root
);
}
#[test]
fn reports_an_invalid_cargo_manifest() {
let directory = TestDirectory::new("invalid");
let package_root = directory.path().join("service");
fs::create_dir_all(&package_root).expect("package directory should be created");
fs::write(package_root.join("Cargo.toml"), "[workspace\n")
.expect("invalid manifest should be written");
let error = find_workspace_root(&package_root)
.expect_err("an invalid Cargo manifest must not be ignored");
assert!(error.to_string().contains("failed to parse Cargo manifest"));
}
#[cfg(target_os = "linux")]
#[test]
fn managed_child_is_terminated_and_reaped_on_an_error_return() {
let directory = tempfile::tempdir().unwrap();
let pid_file = directory.path().join("process.pid");
let result = (|| -> DevResult<u32> {
let child = spawn_command(
"owned test child",
std::env::current_exe().unwrap().into_os_string(),
&[
"--ignored",
"--exact",
"docker::tests::process_fixture",
"--nocapture",
],
directory.path(),
&[
("RUNTIME_TEST_PROCESS_CASE".into(), "wait".into()),
(
"RUNTIME_TEST_PROCESS_PID".into(),
pid_file.to_string_lossy().into_owned(),
),
],
)?;
let pid = child.id();
let start = Instant::now();
while !pid_file.exists() {
assert!(start.elapsed() < Duration::from_secs(10));
thread::sleep(Duration::from_millis(10));
}
Err(color_eyre::eyre::eyre!(
"setup failed after starting process {pid}"
))
})();
assert!(result.is_err());
let pid = fs::read_to_string(pid_file).unwrap();
assert!(
!Path::new(&format!("/proc/{}", pid.trim())).exists(),
"an error return leaked a child process"
);
}
}