1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
//! 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!();
}