topcoat-cli 0.11.0

A modular, batteries-included Rust web framework for server-rendered apps.
Documentation
//! Runs an application and rebuilds it when source files change.
//!
//! The running application keeps serving while a rebuild is pending or has
//! failed. A successful build replaces it only if the executable or asset bundle
//! changed, or the user requested a manual rebuild.

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, BuildStamp, BuildTask};
use clap::Args;
use console::style;
use keyboard::Keyboard;
use spinner::Spinner;
use watch::{Change, SourceWatcher};

use crate::common::cargo::{BuildFlags, BuildOpts, Metadata};

#[derive(Args)]
pub struct DevCommand {
    #[command(flatten)]
    build: BuildFlags,
}

impl DevCommand {
    pub async fn run(self) {
        let mut opts: BuildOpts = self.build.into();

        // A package with several bin targets (an extra `src/bin/*.rs`, say) makes
        // `cargo build` link all of them, and the build cannot tell which one to
        // serve. `[package] default-run` already names it, so honour it here the way
        // a bare `cargo run` would. An explicit `--bin` still wins.
        if opts.bin.is_none()
            && let Some(metadata) = Metadata::workspace().await
            && let Some(bin) = metadata.default_run_for_build(opts.package.as_deref())
        {
            opts.bin = Some(bin);
        }

        // The broadcast server outlives the individual application
        // processes: browsers stay connected to it across rebuilds.
        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()
        );
        // Flag a non-default profile; `None` is cargo's default `dev` profile,
        // which is the norm and needs no mention.
        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;
        // The stamp of the executable and assets the running server loaded,
        // used to skip the restart when a rebuild changed nothing.
        let mut last_build: Option<BuildStamp> = None;
        // Set when the pending rebuild was requested with the `r` key: a
        // manual reload restarts the application even when the binary comes
        // out unchanged.
        let mut force_restart = false;
        events.publish(Event::Rebuilding);

        loop {
            // `select!` evaluates every branch expression even when its `if`
            // guard is false (it just never polls the future), so the
            // `unwrap`s must sit inside lazy `async` blocks.
            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;
                }

                output = async { build.as_mut().unwrap().finished().await }, if build.is_some() => {
                    build = None;
                    if let Some(output) = output {
                        let stamp = output.stamp;
                        if !std::mem::take(&mut force_restart)
                            && server.is_some()
                            && stamp.is_some()
                            && stamp == last_build
                        {
                            // Both the executable and asset manifest match
                            // what the running process loaded. Leave it and
                            // its browsers undisturbed.
                            events.publish(Event::UpToDate);
                            eprintln!("  {}", style("no changes; application up to date").dim());
                            eprintln!();
                        } else {
                            last_build = stamp;
                            // The new process binds the same address as the
                            // old one, so the old one must be stopped first.
                            if let Some(old) = server.take() {
                                old.shutdown().await;
                            }
                            server = start_app(&output.exe, &dev_url, &events);
                        }
                    } else {
                        // The failure is already on the terminal; keep the
                        // previous process serving while the user fixes it.
                        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() => {
                    // A manifest change can add or remove local packages, so
                    // the watched directories are re-derived before building.
                    if change == Change::Manifest {
                        watcher = SourceWatcher::start().await;
                    }
                    rebuild(&mut build, &opts, &events).await;
                }

                () = keyboard.reload_requested() => {
                    // A manual reload is handled like a file change (the
                    // running application keeps serving until the fresh
                    // build is ready), but always restarts the application:
                    // the point of pressing `r` is a fresh process.
                    force_restart = true;
                    rebuild(&mut build, &opts, &events).await;
                }
            }
        }
    }
}

/// Starts a rebuild while the current app keeps serving. Cancels any pending build so
/// it cannot replace the app with stale output.
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);
}

/// Start the built executable, reporting a failure to the terminal and to
/// connected browsers.
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
        }
    }
}

/// Print the idle status line shown after a failure.
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!();
}