topcoat-cli 0.6.2

A modular, batteries-included Rust web framework for server-rendered apps.
Documentation
//! The `topcoat dev` command: an auto-rebuilding development server.
//!
//! Five pieces cooperate, tied together by the event loop in
//! [`DevCommand::run`]:
//!
//! - [`broadcast_server`]: a long-lived local WebSocket server that browsers connect to; it
//!   broadcasts a reload message whenever a freshly started application reports ready.
//! - [`watch`]: watches every local package -- workspace members and path dependencies alike -- and
//!   coalesces bursts of filesystem events into single change notifications.
//! - [`keyboard`]: reports the `r` keypress that triggers a manual rebuild.
//! - [`build`]: compiles the application and bundles its assets in a cancellable background task.
//! - [`app_server`]: the application process itself.
//!
//! The loop's core policy is that the running application is only ever
//! replaced by a *successful* build: while a rebuild is in flight, and after
//! a failed one, the previous process keeps serving. A successful build that
//! produced the very same binary also leaves the running process (and its
//! browsers) undisturbed, unless the rebuild was requested manually.

mod app_server;
mod broadcast_server;
mod build;
mod keyboard;
mod spinner;
mod watch;

use std::path::Path;

use app_server::AppServer;
use broadcast_server::{Event, EventBus};
use build::{BuildKind, BuildTask};
use clap::Args;
use console::style;
use keyboard::Keyboard;
use spinner::Spinner;
use watch::{Change, SourceWatcher};

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

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

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

        // 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 the running server was started from,
        // 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;
                }

                exe = async { build.as_mut().unwrap().finished().await }, if build.is_some() => {
                    build = None;
                    if let Some(exe) = exe {
                        let stamp = BuildStamp::of(&exe);
                        if !std::mem::take(&mut force_restart)
                            && server.is_some()
                            && stamp.is_some()
                            && stamp == last_build
                        {
                            // Cargo found nothing to relink: the binary that
                            // is already running. Leave the process 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(&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;
                }
            }
        }
    }
}

/// Start a rebuild, leaving the running application untouched: it keeps
/// serving until the new build is ready. A stale in-flight build compiles
/// sources that just changed again, so cancel it rather than wait for it.
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> {
    match AppServer::spawn(exe, dev_url) {
        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!();
}