From 335ff5d7d3f61cf8aea90b9d9e4071b5c0739701 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Wed, 1 Sep 2021 18:04:39 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20added=20serving=20systems?= =?UTF-8?q?=20to=20cli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/perseus-cli/Cargo.toml | 3 +- packages/perseus-cli/src/build.rs | 15 ++-- packages/perseus-cli/src/cmd.rs | 25 ++++-- packages/perseus-cli/src/errors.rs | 15 ++++ packages/perseus-cli/src/serve.rs | 124 ++++++++++++++++++++++++++++- 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/packages/perseus-cli/Cargo.toml b/packages/perseus-cli/Cargo.toml index e2d5cb8f19..c869e64ad8 100644 --- a/packages/perseus-cli/Cargo.toml +++ b/packages/perseus-cli/Cargo.toml @@ -11,7 +11,8 @@ error-chain = "0.12" cargo_toml = "0.9" indicatif = "0.16" console = "0.14" -notify = "4.0" +serde = "1" +serde_json = "1" [lib] name = "lib" diff --git a/packages/perseus-cli/src/build.rs b/packages/perseus-cli/src/build.rs index d30a1e6fbb..f166ba5ec6 100644 --- a/packages/perseus-cli/src/build.rs +++ b/packages/perseus-cli/src/build.rs @@ -11,15 +11,16 @@ static FINALIZING: Emoji<'_, '_> = Emoji("📦", ""); /// Returns the exit code if it's non-zero. macro_rules! handle_exit_code { ($code:expr) => { - let code = $code; + let (_, _, code) = $code; if code != 0 { return Ok(code); } }; } -/// Actually builds the user's code, program arguments having been interpreted. -fn build_internal(dir: PathBuf) -> Result { +/// Actually builds the user's code, program arguments having been interpreted. This needs to know how many steps there are in total +/// because the serving logic also uses it. +pub fn build_internal(dir: PathBuf, num_steps: u8) -> Result { let mut target = dir; target.extend([".perseus"]); @@ -31,7 +32,7 @@ fn build_internal(dir: PathBuf) -> Result { &target, format!( "{} {} Generating your app", - style("[1/3]").bold().dim(), + style(format!("[1/{}]", num_steps)).bold().dim(), GENERATING ) )?); @@ -46,7 +47,7 @@ fn build_internal(dir: PathBuf) -> Result { &target, format!( "{} {} Building your app to WASM", - style("[2/3]").bold().dim(), + style(format!("[2/{}]", num_steps)).bold().dim(), BUILDING ) )?); @@ -58,7 +59,7 @@ fn build_internal(dir: PathBuf) -> Result { &target, format!( "{} {} Finalizing bundle", - style("[3/3]").bold().dim(), + style(format!("[3/{}]", num_steps)).bold().dim(), FINALIZING ) )?); @@ -76,7 +77,7 @@ pub fn build(dir: PathBuf, prog_args: &[String]) -> Result { if should_watch == Some(&"-w".to_string()) || should_watch == Some(&"--watch".to_string()) { todo!("watching not yet supported, try a tool like 'entr'"); } - let exit_code = build_internal(dir.clone())?; + let exit_code = build_internal(dir.clone(), 3)?; Ok(exit_code) } diff --git a/packages/perseus-cli/src/cmd.rs b/packages/perseus-cli/src/cmd.rs index 4732142f04..54ebce2a68 100644 --- a/packages/perseus-cli/src/cmd.rs +++ b/packages/perseus-cli/src/cmd.rs @@ -6,11 +6,12 @@ use console::Emoji; use crate::errors::*; // Some useful emojis -static SUCCESS: Emoji<'_, '_> = Emoji("✅", "success!"); -static FAILURE: Emoji<'_, '_> = Emoji("❌", "failed!"); +pub static SUCCESS: Emoji<'_, '_> = Emoji("✅", "success!"); +pub static FAILURE: Emoji<'_, '_> = Emoji("❌", "failed!"); /// Runs the given command conveniently, returning the exit code. Notably, this parses the given command by separating it on spaces. -pub fn run_cmd(raw_cmd: String, dir: &Path, pre_dump: impl Fn()) -> Result { +/// Returns the command's output and the exit code. +pub fn run_cmd(raw_cmd: String, dir: &Path, pre_dump: impl Fn()) -> Result<(String, String, i32)> { let mut cmd_args: Vec<&str> = raw_cmd.split(' ').collect(); let cmd = cmd_args.remove(0); @@ -33,10 +34,16 @@ pub fn run_cmd(raw_cmd: String, dir: &Path, pre_dump: impl Fn()) -> Result std::io::stderr().write_all(&output.stderr).unwrap(); } - Ok(exit_code) + Ok(( + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + exit_code + )) } -pub fn run_stage(cmds: Vec<&str>, target: &Path, message: String) -> Result { +/// Runs a series of commands and provides a nice spinner with a custom message. Returns the last command's output and an appropriate exit +/// code (0 if everything worked, otherwise the exit code of the one that failed). +pub fn run_stage(cmds: Vec<&str>, target: &Path, message: String) -> Result<(String, String, i32)> { // Tell the user about the stage with a nice progress bar let spinner = ProgressBar::new_spinner(); spinner.set_style( @@ -47,10 +54,11 @@ pub fn run_stage(cmds: Vec<&str>, target: &Path, message: String) -> Result // Tick the spinner every 50 milliseconds spinner.enable_steady_tick(50); + let mut last_output = (String::new(), String::new()); // Run the commands for cmd in cmds { // We make sure all commands run in the target directory ('.perseus/' itself) - let exit_code = run_cmd(cmd.to_string(), target, || { + let (stdout, stderr, exit_code) = run_cmd(cmd.to_string(), target, || { // We're done, we'll write a more permanent version of the message spinner.finish_with_message(format!( "{}...{}", @@ -58,9 +66,10 @@ pub fn run_stage(cmds: Vec<&str>, target: &Path, message: String) -> Result FAILURE )) })?; + last_output = (stdout, stderr); // If we have a non-zero exit code, we should NOT continue (stderr has been written to the console already) if exit_code != 0 { - return Ok(1); + return Ok((last_output.0, last_output.1, 1)); } } @@ -71,5 +80,5 @@ pub fn run_stage(cmds: Vec<&str>, target: &Path, message: String) -> Result SUCCESS )); - Ok(0) + Ok((last_output.0, last_output.1, 0)) } diff --git a/packages/perseus-cli/src/errors.rs b/packages/perseus-cli/src/errors.rs index f5766e1fd7..fb283d5d13 100644 --- a/packages/perseus-cli/src/errors.rs +++ b/packages/perseus-cli/src/errors.rs @@ -54,6 +54,21 @@ error_chain! { description("watching files failed") display("Couldn't watch '{}' for changes. Error was: '{}'.", path, err) } + /// For when the next line of the stdout of a command is `None` when it shouldn't have been. + NextStdoutLineNone { + description("next stdout line was None, expected Some(_)") + display("Executing a command failed because it seemed to stop reporting prmeaturely. If this error persists, you should file a bug report (particularly if you've just upgraded Rust).") + } + /// For when getting the path to the built executable for the server from the JSON build output failed. + GetServerExecutableFailed(err: String) { + description("getting server executable path failed") + display("Couldn't get the path to the server executable from `cargo build`. If this problem persists, please report it as a bug (especially if you just updated cargo). Error was: '{}'.", err) + } + /// For when getting the path to the built executable for the server from the JSON build output failed. + PortNotNumber(err: String) { + description("port in PORT environment variable couldn't be parsed as number") + display("Couldn't parse 'PORT' environment variable as a number, please check that you've provided the correct value. Error was: '{}'.", err) + } } } diff --git a/packages/perseus-cli/src/serve.rs b/packages/perseus-cli/src/serve.rs index 65c95abc95..729b738549 100644 --- a/packages/perseus-cli/src/serve.rs +++ b/packages/perseus-cli/src/serve.rs @@ -1,9 +1,125 @@ use std::path::PathBuf; +use console::{style, Emoji}; +use std::env; +use std::io::Write; +use std::process::{Command, Stdio}; +use crate::build::build_internal; +use crate::cmd::run_stage; use crate::errors::*; -/// Serves the user's app. If no arguments are provided, this will build in watch mode and serve. If `-p/--prod` is specified, we'll -/// build for development, and if `--no-build` is specified, we won't build at all (useful for pseudo-production serving). -/// General message though: do NOT use the CLI for production serving! +// Emojis for stages +static BUILDING_SERVER: Emoji<'_, '_> = Emoji("📡", ""); +static SERVING: Emoji<'_, '_> = Emoji("🛰️ ", ""); + +/// Returns the exit code if it's non-zero. +macro_rules! handle_exit_code { + ($code:expr) => { + { + let (stdout, stderr, code) = $code; + if code != 0 { + return Ok(code); + } + (stdout, stderr) + } + }; +} + +/// Actually serves the user's app, program arguments having been interpreted. This needs to know if we've built as part of this process +/// so it can show an accurate progress count. +fn serve_internal(dir: PathBuf, did_build: bool) -> Result { + let num_steps = match did_build { + true => 5, + false => 2 + }; + let mut target = dir; + // All the serving work can be done in the `server` subcrate after building is finished + target.extend([".perseus", "server"]); + + // Build the server runner + // We use the JSON message format so we can get extra info about the generated executable + let (stdout, _stderr) = handle_exit_code!(run_stage( + vec![ + "cargo build --message-format json" + ], + &target, + format!( + "{} {} Building server", + style(format!("[{}/{}]", num_steps - 1, num_steps)).bold().dim(), + BUILDING_SERVER + ) + )?); + let msgs: Vec<&str> = stdout.trim().split('\n').collect(); + // If we got to here, the exit code was 0 and everything should've worked + // The last message will just tell us that the build finished, the second-last one will tell us the executable path + let msg = msgs.get(msgs.len() - 2); + let msg = match msg { + // We'll parse it as a Serde `Value`, we don't need to know everything that's in there + Some(msg) => serde_json::from_str::(msg).map_err(|err| ErrorKind::GetServerExecutableFailed(err.to_string()))?, + None => bail!(ErrorKind::GetServerExecutableFailed("expected second-last message, none existed (too few messages)".to_string())) + }; + let server_exec_path = msg.get("executable"); + let server_exec_path = match server_exec_path { + // We'll parse it as a Serde `Value`, we don't need to know everything that's in there + Some(server_exec_path) => { + match server_exec_path.as_str() { + Some(server_exec_path) => server_exec_path, + None => bail!(ErrorKind::GetServerExecutableFailed("expected 'executable' field to be string".to_string())) + } + }, + None => bail!(ErrorKind::GetServerExecutableFailed("expected 'executable' field in JSON map in second-last message, not present".to_string())) + }; + + // Manually run the generated binary (invoking in the right directory context for good measure if it ever needs it in future) + let child = Command::new(server_exec_path) + .current_dir(target) + // We should be able to access outputs in case there's an error + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| ErrorKind::CmdExecFailed(server_exec_path.to_string(), err.to_string()))?; + // Figure out what host/port the app will be live on + let host = env::var("HOST").unwrap_or_else(|_| "localhost".to_string()); + let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string()).parse::().map_err(|err| ErrorKind::PortNotNumber(err.to_string()))?; + // Give the user a nice informational message + println!( + " {} {} Your app is now live on http://{host}:{port}! To change this, re-run this command with different settings of the HOST/PORT environment variables.", + style(format!("[{}/{}]", num_steps, num_steps)).bold().dim(), + SERVING, + host=host, + port=port + ); + + // Wait on the child process to finish (which it shouldn't unless there's an error), then perform error handling + let output = child.wait_with_output().unwrap(); + let exit_code = match output.status.code() { + Some(exit_code) => exit_code, // If we have an exit code, use it + None if output.status.success() => 0, // If we don't, but we know the command succeeded, return 0 (success code) + None => 1, // If we don't know an exit code but we know that the command failed, return 1 (general error code) + }; + // Print `stderr` only if there's something therein and the exit code is non-zero + if !output.stderr.is_empty() && exit_code != 0 { + // We don't print any failure message other than the actual error right now (see if people want something else?) + std::io::stderr().write_all(&output.stderr).unwrap(); + return Ok(1); + } + + Ok(0) +} + +/// Builds the subcrates to get a directory that we can serve. Returns an exit code. pub fn serve(dir: PathBuf, prog_args: &[String]) -> Result { - todo!("serve command") + // TODO support watching files + let mut did_build = false; + // Only build if the user hasn't set `--no-build`, handling non-zero exit codes + if !prog_args.contains(&"--no-build".to_string()) { + did_build = true; + let build_exit_code = build_internal(dir.clone(), 4)?; + if build_exit_code != 0 { + return Ok(build_exit_code); + } + } + // Now actually serve the user's data + let exit_code = serve_internal(dir.clone(), did_build)?; + + Ok(exit_code) }