From b4c93f0a8202422c2f64779d87e7bcc6bcfb217a Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Sun, 2 Jan 2022 06:34:32 +1100 Subject: [PATCH] feat(cli): added basic hot reloading This doesn't work properly yet because old builds aren't terminated. The server build never technically terminates because it then starts running the server, so this is far from production-ready! --- packages/perseus-cli/Cargo.toml | 3 +- packages/perseus-cli/src/bin/main.rs | 144 +++++++++++++++++++++++++-- packages/perseus-cli/src/deploy.rs | 2 + packages/perseus-cli/src/errors.rs | 32 ++++++ packages/perseus-cli/src/parse.rs | 10 +- 5 files changed, 179 insertions(+), 12 deletions(-) diff --git a/packages/perseus-cli/Cargo.toml b/packages/perseus-cli/Cargo.toml index 116340e661..5f260ba80f 100644 --- a/packages/perseus-cli/Cargo.toml +++ b/packages/perseus-cli/Cargo.toml @@ -29,8 +29,9 @@ serde = "1" serde_json = "1" clap = { version = "=3.0.0-beta.5", features = ["color"] } fs_extra = "1" -tokio = { version = "1", features = [ "macros", "rt-multi-thread" ] } +tokio = { version = "1", features = [ "macros", "rt-multi-thread", "sync" ] } warp = "0.3" +notify = "4" [lib] name = "perseus_cli" diff --git a/packages/perseus-cli/src/bin/main.rs b/packages/perseus-cli/src/bin/main.rs index 6a4d4509fc..256ed11506 100644 --- a/packages/perseus-cli/src/bin/main.rs +++ b/packages/perseus-cli/src/bin/main.rs @@ -1,5 +1,6 @@ use clap::Parser; use fmterr::fmt_err; +use notify::{watcher, RecursiveMode, Watcher}; use perseus_cli::parse::SnoopSubcommand; use perseus_cli::{ build, check_env, delete_artifacts, delete_bad_dir, deploy, eject, export, has_ejected, @@ -10,6 +11,8 @@ use perseus_cli::{errors::*, snoop_build, snoop_server, snoop_wasm_build}; use std::env; use std::io::Write; use std::path::PathBuf; +use std::sync::mpsc::channel; +use std::time::Duration; // All this does is run the program and terminate with the acquired exit code #[tokio::main] @@ -24,6 +27,8 @@ async fn main() { std::process::exit(exit_code) } +// IDEA Watch files at the `core()` level and then panic, catching the unwind in the watcher loop + // This manages error handling and returns a definite exit code to terminate with async fn real_main() -> i32 { // Get the working directory @@ -86,27 +91,148 @@ async fn core(dir: PathBuf) -> Result { build(dir, build_opts)? } Subcommand::Export(export_opts) => { - // Delete old build/exportation artifacts + // Delete old build/export artifacts delete_artifacts(dir.clone(), "static")?; delete_artifacts(dir.clone(), "exported")?; let exit_code = export(dir.clone(), export_opts.clone())?; if exit_code != 0 { return Ok(exit_code); } - // Start a server for those files if requested - if export_opts.serve { - serve_exported(dir, export_opts.host, export_opts.port).await; - } - 0 + if export_opts.watch { + let dir_2 = dir.clone(); + let export_opts_2 = export_opts.clone(); + if export_opts.serve { + tokio::spawn(async move { + serve_exported(dir_2, export_opts_2.host, export_opts_2.port).await + }); + } + // Now watch for changes + let (tx, rx) = channel(); + let mut watcher = watcher(tx, Duration::from_secs(2)) + .map_err(|err| WatchError::WatcherSetupFailed { source: err })?; + // Watch the current directory + for entry in std::fs::read_dir(".") + .map_err(|err| WatchError::ReadCurrentDirFailed { source: err })? + { + // We want to exclude `target/` and `.perseus/`, otherwise we should watch everything + let entry = + entry.map_err(|err| WatchError::ReadDirEntryFailed { source: err })?; + let name = entry.file_name(); + if name != "target" && name != ".perseus" { + watcher + .watch(entry.path(), RecursiveMode::Recursive) + .map_err(|err| WatchError::WatchFileFailed { + filename: entry.path().to_str().unwrap().to_string(), + source: err, + })?; + } + } + + let res: Result = loop { + match rx.recv() { + Ok(_) => { + // Delete old build/exportation artifacts + delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "exported")?; + let dir_2 = dir.clone(); + let opts = export_opts.clone(); + match export(dir_2.clone(), opts.clone()) { + // We'l let the user know if there's a non-zero exit code + Ok(exit_code) => { + if exit_code != 0 { + eprintln!("Non-zero exit code returned from exporting process: {}.", exit_code) + } + } + // Because we're watching for changes, we can manage errors here + // We won't actually terminate unless the user tells us to + Err(err) => eprintln!("{}", fmt_err(&err)), + } + // TODO Reload the browser automatically + } + Err(err) => break Err(WatchError::WatcherError { source: err }.into()), + } + }; + return res; + } else { + if export_opts.serve { + serve_exported(dir, export_opts.host, export_opts.port).await; + } + 0 + } } Subcommand::Serve(serve_opts) => { - // Delete old build artifacts if `--no-build` wasn't specified if !serve_opts.no_build { delete_artifacts(dir.clone(), "static")?; } - let (exit_code, _server_path) = serve(dir, serve_opts)?; - exit_code + if serve_opts.watch { + match serve(dir.clone(), serve_opts.clone()) { + // We'll let the user know if there's a non-zero exit code + Ok((exit_code, _server_path)) => { + if exit_code != 0 { + eprintln!( + "Non-zero exit code returned from serving process: {}.", + exit_code + ) + } + } + // Because we're watching for changes, we can manage errors here + // We won't actually terminate unless the user tells us to + Err(err) => eprintln!("{}", fmt_err(&err)), + }; + // Now watch for changes + let (tx, rx) = channel(); + let mut watcher = watcher(tx, Duration::from_secs(2)) + .map_err(|err| WatchError::WatcherSetupFailed { source: err })?; + // Watch the current directory + for entry in std::fs::read_dir(".") + .map_err(|err| WatchError::ReadCurrentDirFailed { source: err })? + { + // We want to exclude `target/` and `.perseus/`, otherwise we should watch everything + let entry = + entry.map_err(|err| WatchError::ReadDirEntryFailed { source: err })?; + let name = entry.file_name(); + if name != "target" && name != ".perseus" { + watcher + .watch(entry.path(), RecursiveMode::Recursive) + .map_err(|err| WatchError::WatchFileFailed { + filename: entry.path().to_str().unwrap().to_string(), + source: err, + })?; + } + } + + let res: Result = loop { + match rx.recv() { + Ok(_) => { + // Delete old build artifacts if `--no-build` wasn't specified + if !serve_opts.no_build { + delete_artifacts(dir.clone(), "static")?; + } + match serve(dir.clone(), serve_opts.clone()) { + // We'll let the user know if there's a non-zero exit code + Ok((exit_code, _server_path)) => { + if exit_code != 0 { + eprintln!( + "Non-zero exit code returned from serving process: {}.", + exit_code + ) + } + } + // Because we're watching for changes, we can manage errors here + // We won't actually terminate unless the user tells us to + Err(err) => eprintln!("{}", fmt_err(&err)), + }; + // TODO Reload the browser automatically + } + Err(err) => break Err(WatchError::WatcherError { source: err }.into()), + } + }; + return res; + } else { + let (exit_code, _server_path) = serve(dir, serve_opts)?; + exit_code + } } Subcommand::Test(test_opts) => { // This will be used by the subcrates diff --git a/packages/perseus-cli/src/deploy.rs b/packages/perseus-cli/src/deploy.rs index a1fad38cd0..305f76b6e1 100644 --- a/packages/perseus-cli/src/deploy.rs +++ b/packages/perseus-cli/src/deploy.rs @@ -34,6 +34,7 @@ fn deploy_full(dir: PathBuf, output: String, integration: Integration) -> Result release: true, standalone: true, integration, + watch: false, }, )?; if serve_exit_code != 0 { @@ -135,6 +136,7 @@ fn deploy_export(dir: PathBuf, output: String) -> Result { serve: false, host: String::new(), port: 0, + watch: false, }, )?; if export_exit_code != 0 { diff --git a/packages/perseus-cli/src/errors.rs b/packages/perseus-cli/src/errors.rs index 485165d136..7a12a3f3d0 100644 --- a/packages/perseus-cli/src/errors.rs +++ b/packages/perseus-cli/src/errors.rs @@ -15,6 +15,8 @@ pub enum Error { ExportError(#[from] ExportError), #[error(transparent)] DeployError(#[from] DeployError), + #[error(transparent)] + WatchError(#[from] WatchError), } /// Errors that can occur while preparing. @@ -197,3 +199,33 @@ pub enum DeployError { source: std::io::Error, }, } + +#[derive(Error, Debug)] +pub enum WatchError { + #[error("couldn't set up a file watcher, try re-running this command")] + WatcherSetupFailed { + #[source] + source: notify::Error, + }, + #[error("couldn't read your current directory to watch files, do you have the necessary permissions?")] + ReadCurrentDirFailed { + #[source] + source: std::io::Error, + }, + #[error("couldn't read entry in your current directory, try re-running this command")] + ReadDirEntryFailed { + #[source] + source: std::io::Error, + }, + #[error("couldn't watch file at '{filename}', try re-running the command")] + WatchFileFailed { + filename: String, + #[source] + source: notify::Error, + }, + #[error("an error occurred while watching files")] + WatcherError { + #[source] + source: std::sync::mpsc::RecvError, + }, +} diff --git a/packages/perseus-cli/src/parse.rs b/packages/perseus-cli/src/parse.rs index 2a85102373..a98583608a 100644 --- a/packages/perseus-cli/src/parse.rs +++ b/packages/perseus-cli/src/parse.rs @@ -14,7 +14,7 @@ pub struct Opts { pub subcmd: Subcommand, } -#[derive(Parser, PartialEq, Eq)] +#[derive(Parser, PartialEq, Eq, Clone)] pub enum Integration { ActixWeb, Warp, @@ -80,9 +80,12 @@ pub struct ExportOpts { /// The port to host your exported app on #[clap(long, default_value = "8080")] pub port: u16, + /// Whether or not to watch the files in your working directory for changes (exluding `target/` and `.perseus/`) + #[clap(short, long)] + pub watch: bool, } /// Serves your app (set the `$HOST` and `$PORT` environment variables to change the location it's served at) -#[derive(Parser)] +#[derive(Parser, Clone)] pub struct ServeOpts { /// Don't run the final binary, but print its location instead as the last line of output #[clap(long)] @@ -99,6 +102,9 @@ pub struct ServeOpts { /// The server integration to use #[clap(short, long, default_value = "warp")] pub integration: Integration, + /// Whether or not to watch the files in your working directory for changes (exluding `target/` and `.perseus/`) + #[clap(short, long)] + pub watch: bool, } /// Removes `.perseus/` entirely for updates or to fix corruptions #[derive(Parser)]