From 1713df13524ad20c6bd0413bcf4aa57adc3f9735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Tue, 31 Oct 2023 01:25:58 +0100 Subject: feat: deno run --unstable-hmr (#20876) This commit adds `--unstable-hmr` flag, that enabled Hot Module Replacement. This flag works like `--watch` and accepts the same arguments. If HMR is not possible the process will be restarted instead. Currently HMR is only supported in `deno run` subcommand. Upon HMR a `CustomEvent("hmr")` will be dispatched that contains information which file was changed in its `details` property. --------- Co-authored-by: Valentin Anger Co-authored-by: David Sherret --- cli/args/flags.rs | 158 ++++++++++++++++---- cli/args/mod.rs | 12 ++ cli/emit.rs | 20 +++ cli/factory.rs | 14 +- cli/graph_util.rs | 4 +- cli/standalone/mod.rs | 3 + cli/tests/integration/watcher_tests.rs | 254 +++++++++++++++++++++++++++++++++ cli/tools/bench/mod.rs | 8 +- cli/tools/bundle.rs | 8 +- cli/tools/fmt.rs | 7 +- cli/tools/lint.rs | 5 +- cli/tools/run.rs | 195 ------------------------- cli/tools/run/hmr/json_types.rs | 59 ++++++++ cli/tools/run/hmr/mod.rs | 242 +++++++++++++++++++++++++++++++ cli/tools/run/mod.rs | 205 ++++++++++++++++++++++++++ cli/tools/test/mod.rs | 8 +- cli/util/file_watcher.rs | 141 ++++++++++++------ cli/worker.rs | 74 +++++++++- 18 files changed, 1121 insertions(+), 296 deletions(-) delete mode 100644 cli/tools/run.rs create mode 100644 cli/tools/run/hmr/json_types.rs create mode 100644 cli/tools/run/hmr/mod.rs create mode 100644 cli/tools/run/mod.rs diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 5b411e36b..271a56ac3 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -212,11 +212,13 @@ impl RunFlags { #[derive(Clone, Default, Debug, Eq, PartialEq)] pub struct WatchFlags { + pub hmr: bool, pub no_clear_screen: bool, } #[derive(Clone, Default, Debug, Eq, PartialEq)] pub struct WatchFlagsWithPaths { + pub hmr: bool, pub paths: Vec, pub no_clear_screen: bool, } @@ -1860,6 +1862,7 @@ fn run_subcommand() -> Command { runtime_args(Command::new("run"), true, true) .arg(check_arg(false)) .arg(watch_arg(true)) + .arg(hmr_arg(true)) .arg(no_clear_screen_arg()) .arg(executable_ext_arg()) .arg( @@ -2728,6 +2731,33 @@ fn seed_arg() -> Arg { .value_parser(value_parser!(u64)) } +fn hmr_arg(takes_files: bool) -> Arg { + let arg = Arg::new("hmr") + .long("unstable-hmr") + .help("UNSTABLE: Watch for file changes and hot replace modules") + .conflicts_with("watch"); + + if takes_files { + arg + .value_name("FILES") + .num_args(0..) + .value_parser(value_parser!(PathBuf)) + .use_value_delimiter(true) + .require_equals(true) + .long_help( + "Watch for file changes and restart process automatically. +Local files from entry point module graph are watched by default. +Additional paths might be watched by passing them as arguments to this flag.", + ) + .value_hint(ValueHint::AnyPath) + } else { + arg.action(ArgAction::SetTrue).long_help( + "Watch for file changes and restart process automatically. + Only local files from entry point module graph are watched.", + ) + } +} + fn watch_arg(takes_files: bool) -> Arg { let arg = Arg::new("watch") .long("watch") @@ -3849,6 +3879,7 @@ fn reload_arg_validate(urlstr: &str) -> Result { fn watch_arg_parse(matches: &mut ArgMatches) -> Option { if matches.get_flag("watch") { Some(WatchFlags { + hmr: false, no_clear_screen: matches.get_flag("no-clear-screen"), }) } else { @@ -3859,10 +3890,19 @@ fn watch_arg_parse(matches: &mut ArgMatches) -> Option { fn watch_arg_parse_with_paths( matches: &mut ArgMatches, ) -> Option { + if let Some(paths) = matches.remove_many::("watch") { + return Some(WatchFlagsWithPaths { + paths: paths.collect(), + hmr: false, + no_clear_screen: matches.get_flag("no-clear-screen"), + }); + } + matches - .remove_many::("watch") - .map(|f| WatchFlagsWithPaths { - paths: f.collect(), + .remove_many::("hmr") + .map(|paths| WatchFlagsWithPaths { + paths: paths.collect(), + hmr: true, no_clear_screen: matches.get_flag("no-clear-screen"), }) } @@ -3980,6 +4020,7 @@ mod tests { subcommand: DenoSubcommand::Run(RunFlags { script: "script.ts".to_string(), watch: Some(WatchFlagsWithPaths { + hmr: false, paths: vec![], no_clear_screen: false, }), @@ -3987,6 +4028,79 @@ mod tests { ..Flags::default() } ); + + let r = flags_from_vec(svec![ + "deno", + "run", + "--watch", + "--no-clear-screen", + "script.ts" + ]); + let flags = r.unwrap(); + assert_eq!( + flags, + Flags { + subcommand: DenoSubcommand::Run(RunFlags { + script: "script.ts".to_string(), + watch: Some(WatchFlagsWithPaths { + hmr: false, + paths: vec![], + no_clear_screen: true, + }), + }), + ..Flags::default() + } + ); + + let r = flags_from_vec(svec![ + "deno", + "run", + "--unstable-hmr", + "--no-clear-screen", + "script.ts" + ]); + let flags = r.unwrap(); + assert_eq!( + flags, + Flags { + subcommand: DenoSubcommand::Run(RunFlags { + script: "script.ts".to_string(), + watch: Some(WatchFlagsWithPaths { + hmr: true, + paths: vec![], + no_clear_screen: true, + }), + }), + ..Flags::default() + } + ); + + let r = flags_from_vec(svec![ + "deno", + "run", + "--unstable-hmr=foo.txt", + "--no-clear-screen", + "script.ts" + ]); + let flags = r.unwrap(); + assert_eq!( + flags, + Flags { + subcommand: DenoSubcommand::Run(RunFlags { + script: "script.ts".to_string(), + watch: Some(WatchFlagsWithPaths { + hmr: true, + paths: vec![PathBuf::from("foo.txt")], + no_clear_screen: true, + }), + }), + ..Flags::default() + } + ); + + let r = + flags_from_vec(svec!["deno", "run", "--hmr", "--watch", "script.ts"]); + assert!(r.is_err()); } #[test] @@ -4000,6 +4114,7 @@ mod tests { subcommand: DenoSubcommand::Run(RunFlags { script: "script.ts".to_string(), watch: Some(WatchFlagsWithPaths { + hmr: false, paths: vec![PathBuf::from("file1"), PathBuf::from("file2")], no_clear_screen: false, }), @@ -4026,6 +4141,7 @@ mod tests { subcommand: DenoSubcommand::Run(RunFlags { script: "script.ts".to_string(), watch: Some(WatchFlagsWithPaths { + hmr: false, paths: vec![], no_clear_screen: true, }) @@ -4347,9 +4463,7 @@ mod tests { single_quote: None, prose_wrap: None, no_semicolons: None, - watch: Some(WatchFlags { - no_clear_screen: false, - }) + watch: Some(Default::default()), }), ext: Some("ts".to_string()), ..Flags::default() @@ -4374,6 +4488,7 @@ mod tests { prose_wrap: None, no_semicolons: None, watch: Some(WatchFlags { + hmr: false, no_clear_screen: true, }) }), @@ -4405,9 +4520,7 @@ mod tests { single_quote: None, prose_wrap: None, no_semicolons: None, - watch: Some(WatchFlags { - no_clear_screen: false, - }) + watch: Some(Default::default()), }), ext: Some("ts".to_string()), ..Flags::default() @@ -4461,9 +4574,7 @@ mod tests { single_quote: None, prose_wrap: None, no_semicolons: None, - watch: Some(WatchFlags { - no_clear_screen: false, - }) + watch: Some(Default::default()), }), config_flag: ConfigFlag::Path("deno.jsonc".to_string()), ext: Some("ts".to_string()), @@ -4587,9 +4698,7 @@ mod tests { maybe_rules_exclude: None, json: false, compact: false, - watch: Some(WatchFlags { - no_clear_screen: false, - }) + watch: Some(Default::default()), }), ..Flags::default() } @@ -4621,6 +4730,7 @@ mod tests { json: false, compact: false, watch: Some(WatchFlags { + hmr: false, no_clear_screen: true, }) }), @@ -5823,9 +5933,7 @@ mod tests { subcommand: DenoSubcommand::Bundle(BundleFlags { source_file: "source.ts".to_string(), out_file: None, - watch: Some(WatchFlags { - no_clear_screen: false, - }), + watch: Some(Default::default()), }), type_check_mode: TypeCheckMode::Local, ..Flags::default() @@ -5849,6 +5957,7 @@ mod tests { source_file: "source.ts".to_string(), out_file: None, watch: Some(WatchFlags { + hmr: false, no_clear_screen: true, }), }), @@ -7017,9 +7126,7 @@ mod tests { concurrent_jobs: None, trace_ops: false, coverage_dir: None, - watch: Some(WatchFlags { - no_clear_screen: false, - }), + watch: Some(Default::default()), reporter: Default::default(), junit_path: None, }), @@ -7049,9 +7156,7 @@ mod tests { concurrent_jobs: None, trace_ops: false, coverage_dir: None, - watch: Some(WatchFlags { - no_clear_screen: false, - }), + watch: Some(Default::default()), reporter: Default::default(), junit_path: None, }), @@ -7084,6 +7189,7 @@ mod tests { trace_ops: false, coverage_dir: None, watch: Some(WatchFlags { + hmr: false, no_clear_screen: true, }), reporter: Default::default(), @@ -7851,9 +7957,7 @@ mod tests { include: vec![], ignore: vec![], }, - watch: Some(WatchFlags { - no_clear_screen: false, - }), + watch: Some(Default::default()), }), no_prompt: true, type_check_mode: TypeCheckMode::Local, diff --git a/cli/args/mod.rs b/cli/args/mod.rs index ab8d6b503..96f4e9a74 100644 --- a/cli/args/mod.rs +++ b/cli/args/mod.rs @@ -1130,6 +1130,18 @@ impl CliOptions { &self.flags.ext } + pub fn has_hmr(&self) -> bool { + if let DenoSubcommand::Run(RunFlags { + watch: Some(WatchFlagsWithPaths { hmr, .. }), + .. + }) = &self.flags.subcommand + { + *hmr + } else { + false + } + } + /// If the --inspect or --inspect-brk flags are used. pub fn is_inspecting(&self) -> bool { self.flags.inspect.is_some() diff --git a/cli/emit.rs b/cli/emit.rs index e81d2e83c..8e51c4edd 100644 --- a/cli/emit.rs +++ b/cli/emit.rs @@ -101,6 +101,26 @@ impl Emitter { } } + /// Expects a file URL, panics otherwise. + pub async fn load_and_emit_for_hmr( + &self, + specifier: &ModuleSpecifier, + ) -> Result { + let media_type = MediaType::from_specifier(specifier); + let source_code = tokio::fs::read_to_string( + ModuleSpecifier::to_file_path(specifier).unwrap(), + ) + .await?; + let source_arc: Arc = source_code.into(); + let parsed_source = self + .parsed_source_cache + .get_or_parse_module(specifier, source_arc, media_type)?; + let mut options = self.emit_options.clone(); + options.inline_source_map = false; + let transpiled_source = parsed_source.transpile(&options)?; + Ok(transpiled_source.text) + } + /// A hashing function that takes the source code and uses the global emit /// options then generates a string hash which can be stored to /// determine if the cached emit is valid or not. diff --git a/cli/factory.rs b/cli/factory.rs index 9cdd32702..389c4dbe0 100644 --- a/cli/factory.rs +++ b/cli/factory.rs @@ -66,7 +66,7 @@ use std::future::Future; use std::sync::Arc; pub struct CliFactoryBuilder { - watcher_communicator: Option, + watcher_communicator: Option>, } impl CliFactoryBuilder { @@ -86,7 +86,7 @@ impl CliFactoryBuilder { pub async fn build_from_flags_for_watcher( mut self, flags: Flags, - watcher_communicator: WatcherCommunicator, + watcher_communicator: Arc, ) -> Result { self.watcher_communicator = Some(watcher_communicator); self.build_from_flags(flags).await @@ -171,7 +171,7 @@ struct CliFactoryServices { } pub struct CliFactory { - watcher_communicator: Option, + watcher_communicator: Option>, options: Arc, services: CliFactoryServices, } @@ -620,6 +620,11 @@ impl CliFactory { let npm_resolver = self.npm_resolver().await?; let fs = self.fs(); let cli_node_resolver = self.cli_node_resolver().await?; + let maybe_file_watcher_communicator = if self.options.has_hmr() { + Some(self.watcher_communicator.clone().unwrap()) + } else { + None + }; Ok(CliMainWorkerFactory::new( StorageKeyResolver::from_options(&self.options), @@ -643,6 +648,8 @@ impl CliFactory { )), self.root_cert_store_provider().clone(), self.fs().clone(), + Some(self.emitter()?.clone()), + maybe_file_watcher_communicator, self.maybe_inspector_server().clone(), self.maybe_lockfile().clone(), self.feature_checker().clone(), @@ -659,6 +666,7 @@ impl CliFactory { coverage_dir: self.options.coverage_dir(), enable_testing_features: self.options.enable_testing_features(), has_node_modules_dir: self.options.has_node_modules_dir(), + hmr: self.options.has_hmr(), inspect_brk: self.options.inspect_brk().is_some(), inspect_wait: self.options.inspect_wait().is_some(), is_inspecting: self.options.is_inspecting(), diff --git a/cli/graph_util.rs b/cli/graph_util.rs index 2f5fd40fd..f2713a9db 100644 --- a/cli/graph_util.rs +++ b/cli/graph_util.rs @@ -681,12 +681,12 @@ impl<'a> ModuleGraphUpdatePermit<'a> { #[derive(Clone, Debug)] pub struct FileWatcherReporter { - watcher_communicator: WatcherCommunicator, + watcher_communicator: Arc, file_paths: Arc>>, } impl FileWatcherReporter { - pub fn new(watcher_communicator: WatcherCommunicator) -> Self { + pub fn new(watcher_communicator: Arc) -> Self { Self { watcher_communicator, file_paths: Default::default(), diff --git a/cli/standalone/mod.rs b/cli/standalone/mod.rs index 612ae9eed..803655b9a 100644 --- a/cli/standalone/mod.rs +++ b/cli/standalone/mod.rs @@ -446,6 +446,8 @@ pub async fn run( fs, None, None, + None, + None, feature_checker, CliMainWorkerOptions { argv: metadata.argv, @@ -453,6 +455,7 @@ pub async fn run( coverage_dir: None, enable_testing_features: false, has_node_modules_dir, + hmr: false, inspect_brk: false, inspect_wait: false, is_inspecting: false, diff --git a/cli/tests/integration/watcher_tests.rs b/cli/tests/integration/watcher_tests.rs index 1ee8a45e0..0defaa69e 100644 --- a/cli/tests/integration/watcher_tests.rs +++ b/cli/tests/integration/watcher_tests.rs @@ -1645,3 +1645,257 @@ async fn run_watch_inspect() { check_alive_then_kill(child); } + +#[tokio::test] +async fn run_hmr_server() { + let t = TempDir::new(); + let file_to_watch = t.path().join("file_to_watch.js"); + file_to_watch.write( + r#" +globalThis.state = { i: 0 }; + +function bar() { + globalThis.state.i = 0; + console.log("got request", globalThis.state.i); +} + +function handler(_req) { + bar(); + return new Response("Hello world!"); +} + +Deno.serve({ port: 11111 }, handler); +console.log("Listening...") + "#, + ); + + let mut child = util::deno_cmd() + .current_dir(util::testdata_path()) + .arg("run") + .arg("--unstable-hmr") + .arg("--allow-net") + .arg("-L") + .arg("debug") + .arg(&file_to_watch) + .env("NO_COLOR", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let (mut stdout_lines, mut stderr_lines) = child_lines(&mut child); + wait_contains("Process started", &mut stderr_lines).await; + wait_contains("No package.json file found", &mut stderr_lines).await; + + wait_for_watcher("file_to_watch.js", &mut stderr_lines).await; + wait_contains("Listening...", &mut stdout_lines).await; + + file_to_watch.write( + r#" +globalThis.state = { i: 0 }; + +function bar() { + globalThis.state.i = 0; + console.log("got request1", globalThis.state.i); +} + +function handler(_req) { + bar(); + return new Response("Hello world!"); +} + +Deno.serve({ port: 11111 }, handler); +console.log("Listening...") + "#, + ); + + wait_contains("Failed to reload module", &mut stderr_lines).await; + wait_contains("File change detected", &mut stderr_lines).await; + + check_alive_then_kill(child); +} + +#[tokio::test] +async fn run_hmr_jsx() { + let t = TempDir::new(); + let file_to_watch = t.path().join("file_to_watch.js"); + file_to_watch.write( + r#" +import { foo } from "./foo.jsx"; + +let i = 0; +setInterval(() => { + console.log(i++, foo()); +}, 100); +"#, + ); + let file_to_watch2 = t.path().join("foo.jsx"); + file_to_watch2.write( + r#" +export function foo() { + return `

Hello

`; +} +"#, + ); + + let mut child = util::deno_cmd() + .current_dir(util::testdata_path()) + .arg("run") + .arg("--unstable-hmr") + .arg("-L") + .arg("debug") + .arg(&file_to_watch) + .env("NO_COLOR", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let (mut stdout_lines, mut stderr_lines) = child_lines(&mut child); + wait_contains("Process started", &mut stderr_lines).await; + wait_contains("No package.json file found", &mut stderr_lines).await; + + wait_for_watcher("file_to_watch.js", &mut stderr_lines).await; + wait_contains("5

Hello

", &mut stdout_lines).await; + + file_to_watch2.write( + r#" +export function foo() { + return `

Hello world

`; +} + "#, + ); + + wait_contains("Replaced changed module", &mut stderr_lines).await; + wait_contains("

Hello world

", &mut stdout_lines).await; + + check_alive_then_kill(child); +} + +#[tokio::test] +async fn run_hmr_uncaught_error() { + let t = TempDir::new(); + let file_to_watch = t.path().join("file_to_watch.js"); + file_to_watch.write( + r#" +import { foo } from "./foo.jsx"; + +let i = 0; +setInterval(() => { + console.log(i++, foo()); +}, 100); +"#, + ); + let file_to_watch2 = t.path().join("foo.jsx"); + file_to_watch2.write( + r#" +export function foo() { + setTimeout(() => { + throw new Error("fail"); + }); + return `

asd1

`; +} +"#, + ); + + let mut child = util::deno_cmd() + .current_dir(util::testdata_path()) + .arg("run") + .arg("--unstable-hmr") + .arg("-L") + .arg("debug") + .arg(&file_to_watch) + .env("NO_COLOR", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let (mut stdout_lines, mut stderr_lines) = child_lines(&mut child); + wait_contains("Process started", &mut stderr_lines).await; + wait_contains("No package.json file found", &mut stderr_lines).await; + + wait_for_watcher("file_to_watch.js", &mut stderr_lines).await; + wait_contains("

asd1

", &mut stdout_lines).await; + wait_contains("fail", &mut stderr_lines).await; + + file_to_watch2.write( + r#" +export function foo() { + return `

asd2

`; +} + "#, + ); + + wait_contains("Process failed", &mut stderr_lines).await; + wait_contains("File change detected", &mut stderr_lines).await; + wait_contains("

asd2

", &mut stdout_lines).await; + + check_alive_then_kill(child); +} + +#[tokio::test] +async fn run_hmr_unhandled_rejection() { + let t = TempDir::new(); + let file_to_watch = t.path().join("file_to_watch.js"); + file_to_watch.write( + r#" +import { foo } from "./foo.jsx"; + +// deno-lint-ignore require-await +async function rejection() { + throw new Error("boom!"); +} + +let i = 0; +setInterval(() => { + if (i == 3) { + rejection(); + } + console.log(i++, foo()); +}, 100); +"#, + ); + let file_to_watch2 = t.path().join("foo.jsx"); + file_to_watch2.write( + r#" +export function foo() { + return `

asd1

`; +} +"#, + ); + + let mut child = util::deno_cmd() + .current_dir(util::testdata_path()) + .arg("run") + .arg("--unstable-hmr") + .arg("-L") + .arg("debug") + .arg(&file_to_watch) + .env("NO_COLOR", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let (mut stdout_lines, mut stderr_lines) = child_lines(&mut child); + wait_contains("Process started", &mut stderr_lines).await; + wait_contains("No package.json file found", &mut stderr_lines).await; + + wait_for_watcher("file_to_watch.js", &mut stderr_lines).await; + wait_contains("2

asd1

", &mut stdout_lines).await; + wait_contains("boom", &mut stderr_lines).await; + + file_to_watch.write( + r#" +import { foo } from "./foo.jsx"; + +let i = 0; +setInterval(() => { + console.log(i++, foo()); +}, 100); + "#, + ); + + wait_contains("Process failed", &mut stderr_lines).await; + wait_contains("File change detected", &mut stderr_lines).await; + wait_contains("

asd1

", &mut stdout_lines).await; + + check_alive_then_kill(child); +} diff --git a/cli/tools/bench/mod.rs b/cli/tools/bench/mod.rs index eb400442e..70551a767 100644 --- a/cli/tools/bench/mod.rs +++ b/cli/tools/bench/mod.rs @@ -409,14 +409,14 @@ pub async fn run_benchmarks_with_watch( ) -> Result<(), AnyError> { file_watcher::watch_func( flags, - file_watcher::PrintConfig { - job_name: "Bench".to_string(), - clear_screen: bench_flags + file_watcher::PrintConfig::new( + "Bench", + bench_flags .watch .as_ref() .map(|w| !w.no_clear_screen) .unwrap_or(true), - }, + ), move |flags, watcher_communicator, changed_paths| { let bench_flags = bench_flags.clone(); Ok(async move { diff --git a/cli/tools/bundle.rs b/cli/tools/bundle.rs index b36ff023a..0946c728b 100644 --- a/cli/tools/bundle.rs +++ b/cli/tools/bundle.rs @@ -31,10 +31,10 @@ pub async fn bundle( if let Some(watch_flags) = &bundle_flags.watch { util::file_watcher::watch_func( flags, - util::file_watcher::PrintConfig { - job_name: "Bundle".to_string(), - clear_screen: !watch_flags.no_clear_screen, - }, + util::file_watcher::PrintConfig::new( + "Bundle", + !watch_flags.no_clear_screen, + ), move |flags, watcher_communicator, _changed_paths| { let bundle_flags = bundle_flags.clone(); Ok(async move { diff --git a/cli/tools/fmt.rs b/cli/tools/fmt.rs index 92facc7ec..5c47b5497 100644 --- a/cli/tools/fmt.rs +++ b/cli/tools/fmt.rs @@ -64,10 +64,7 @@ pub async fn format(flags: Flags, fmt_flags: FmtFlags) -> Result<(), AnyError> { if let Some(watch_flags) = &fmt_flags.watch { file_watcher::watch_func( flags, - file_watcher::PrintConfig { - job_name: "Fmt".to_string(), - clear_screen: !watch_flags.no_clear_screen, - }, + file_watcher::PrintConfig::new("Fmt", !watch_flags.no_clear_screen), move |flags, watcher_communicator, changed_paths| { let fmt_flags = fmt_flags.clone(); Ok(async move { @@ -82,7 +79,7 @@ pub async fn format(flags: Flags, fmt_flags: FmtFlags) -> Result<(), AnyError> { Ok(files) } })?; - _ = watcher_communicator.watch_paths(files.clone()); + let _ = watcher_communicator.watch_paths(files.clone()); let refmt_files = if let Some(paths) = changed_paths { if fmt_options.check { // check all files on any changed (https://github.com/denoland/deno/issues/12446) diff --git a/cli/tools/lint.rs b/cli/tools/lint.rs index b7f4a3f0d..5b9387eb1 100644 --- a/cli/tools/lint.rs +++ b/cli/tools/lint.rs @@ -59,10 +59,7 @@ pub async fn lint(flags: Flags, lint_flags: LintFlags) -> Result<(), AnyError> { } file_watcher::watch_func( flags, - file_watcher::PrintConfig { - job_name: "Lint".to_string(), - clear_screen: !watch_flags.no_clear_screen, - }, + file_watcher::PrintConfig::new("Lint", !watch_flags.no_clear_screen), move |flags, watcher_communicator, changed_paths| { let lint_flags = lint_flags.clone(); Ok(async move { diff --git a/cli/tools/run.rs b/cli/tools/run.rs deleted file mode 100644 index 80e80577e..000000000 --- a/cli/tools/run.rs +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. - -use std::io::Read; - -use deno_ast::MediaType; -use deno_core::error::AnyError; -use deno_runtime::permissions::Permissions; -use deno_runtime::permissions::PermissionsContainer; - -use crate::args::EvalFlags; -use crate::args::Flags; -use crate::args::RunFlags; -use crate::args::WatchFlagsWithPaths; -use crate::factory::CliFactory; -use crate::factory::CliFactoryBuilder; -use crate::file_fetcher::File; -use crate::util; - -pub async fn run_script( - flags: Flags, - run_flags: RunFlags, -) -> Result { - if !flags.has_permission() && flags.has_permission_in_argv() { - log::warn!( - "{}", - crate::colors::yellow( - r#"Permission flags have likely been incorrectly set after the script argument. -To grant permissions, set them before the script argument. For example: - deno run --allow-read=. main.js"# - ) - ); - } - - if let Some(watch_flags) = run_flags.watch { - return run_with_watch(flags, watch_flags).await; - } - - // TODO(bartlomieju): actually I think it will also fail if there's an import - // map specified and bare specifier is used on the command line - let factory = CliFactory::from_flags(flags).await?; - let deno_dir = factory.deno_dir()?; - let http_client = factory.http_client(); - let cli_options = factory.cli_options(); - - // Run a background task that checks for available upgrades. If an earlier - // run of this background task found a new version of Deno. - super::upgrade::check_for_upgrades( - http_client.clone(), - deno_dir.upgrade_check_file_path(), - ); - - let main_module = cli_options.resolve_main_module()?; - - maybe_npm_install(&factory).await?; - - let permissions = PermissionsContainer::new(Permissions::from_options( - &cli_options.permissions_options(), - )?); - let worker_factory = factory.create_cli_main_worker_factory().await?; - let mut worker = worker_factory - .create_main_worker(main_module, permissions) - .await?; - - let exit_code = worker.run().await?; - Ok(exit_code) -} - -pub async fn run_from_stdin(flags: Flags) -> Result { - let factory = CliFactory::from_flags(flags).await?; - let cli_options = factory.cli_options(); - let main_module = cli_options.resolve_main_module()?; - - maybe_npm_install(&factory).await?; - - let file_fetcher = factory.file_fetcher()?; - let worker_factory = factory.create_cli_main_worker_factory().await?; - let permissions = PermissionsContainer::new(Permissions::from_options( - &cli_options.permissions_options(), - )?); - let mut source = Vec::new(); - std::io::stdin().read_to_end(&mut source)?; - // Create a dummy source file. - let source_file = File { - maybe_types: None, - media_type: MediaType::TypeScript, - source: String::from_utf8(source)?.into(), - specifier: main_module.clone(), - maybe_headers: None, - }; - // Save our fake file into file fetcher cache - // to allow module access by TS compiler - file_fetcher.insert_cached(source_file); - - let mut worker = worker_factory - .create_main_worker(main_module, permissions) - .await?; - let exit_code = worker.run().await?; - Ok(exit_code) -} - -// TODO(bartlomieju): this function is not handling `exit_code` set by the runtime -// code properly. -async fn run_with_watch( - flags: Flags, - watch_flags: WatchFlagsWithPaths, -) -> Result { - util::file_watcher::watch_func( - flags, - util::file_watcher::PrintConfig { - job_name: "Process".to_string(), - clear_screen: !watch_flags.no_clear_screen, - }, - move |flags, watcher_communicator, _changed_paths| { - Ok(async move { - let factory = CliFactoryBuilder::new() - .build_from_flags_for_watcher(flags, watcher_communicator.clone()) - .await?; - let cli_options = factory.cli_options(); - let main_module = cli_options.resolve_main_module()?; - - maybe_npm_install(&factory).await?; - - let _ = watcher_communicator.watch_paths(cli_options.watch_paths()); - - let permissions = PermissionsContainer::new(Permissions::from_options( - &cli_options.permissions_options(), - )?); - let worker = factory - .create_cli_main_worker_factory() - .await? - .create_main_worker(main_module, permissions) - .await?; - worker.run_for_watcher().await?; - - Ok(()) - }) - }, - ) - .await?; - - Ok(0) -} - -pub async fn eval_command( - flags: Flags, - eval_flags: EvalFlags, -) -> Result { - let factory = CliFactory::from_flags(flags).await?; - let cli_options = factory.cli_options(); - let file_fetcher = factory.file_fetcher()?; - let main_module = cli_options.resolve_main_module()?; - - maybe_npm_install(&factory).await?; - - // Create a dummy source file. - let source_code = if eval_flags.print { - format!("console.log({})", eval_flags.code) - } else { - eval_flags.code - } - .into_bytes(); - - let file = File { - maybe_types: None, - media_type: MediaType::Unknown, - source: String::from_utf8(source_code)?.into(), - specifier: main_module.clone(), - maybe_headers: None, - }; - - // Save our fake file into file fetcher cache - // to allow module access by TS compiler. - file_fetcher.insert_cached(file); - - let permissions = PermissionsContainer::new(Permissions::from_options( - &cli_options.permissions_options(), - )?); - let worker_factory = factory.create_cli_main_worker_factory().await?; - let mut worker = worker_factory - .create_main_worker(main_module, permissions) - .await?; - let exit_code = worker.run().await?; - Ok(exit_code) -} - -async fn maybe_npm_install(factory: &CliFactory) -> Result<(), AnyError> { - // ensure an "npm install" is done if the user has explicitly - // opted into using a managed node_modules directory - if factory.cli_options().node_modules_dir_enablement() == Some(true) { - if let Some(npm_resolver) = factory.npm_resolver().await?.as_managed() { - npm_resolver.ensure_top_level_package_json_install().await?; - } - } - Ok(()) -} diff --git a/cli/tools/run/hmr/json_types.rs b/cli/tools/run/hmr/json_types.rs new file mode 100644 index 000000000..3ac80344b --- /dev/null +++ b/cli/tools/run/hmr/json_types.rs @@ -0,0 +1,59 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +// TODO(bartlomieju): this code should be factored out to `cli/cdp.rs` along +// with code in `cli/tools/repl/` and `cli/tools/coverage/`. These are all +// Chrome Devtools Protocol message types. + +use deno_core::serde_json::Value; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct RpcNotification { + pub method: String, + pub params: Value, +} + +#[derive(Debug, Deserialize)] +pub struct SetScriptSourceReturnObject { + pub status: Status, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptParsed { + pub script_id: String, + pub url: String, +} + +#[derive(Debug, Deserialize)] +pub enum Status { + Ok, + CompileError, + BlockedByActiveGenerator, + BlockedByActiveFunction, + BlockedByTopLevelEsModuleChange, +} + +impl Status { + pub(crate) fn explain(&self) -> &'static str { + match self { + Status::Ok => "OK", + Status::CompileError => "compile error", + Status::BlockedByActiveGenerator => "blocked by active generator", + Status::BlockedByActiveFunction => "blocked by active function", + Status::BlockedByTopLevelEsModuleChange => { + "blocked by top-level ES module change" + } + } + } + + pub(crate) fn should_retry(&self) -> bool { + match self { + Status::Ok => false, + Status::CompileError => false, + Status::BlockedByActiveGenerator => true, + Status::BlockedByActiveFunction => true, + Status::BlockedByTopLevelEsModuleChange => false, + } + } +} diff --git a/cli/tools/run/hmr/mod.rs b/cli/tools/run/hmr/mod.rs new file mode 100644 index 000000000..1a5772307 --- /dev/null +++ b/cli/tools/run/hmr/mod.rs @@ -0,0 +1,242 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use crate::emit::Emitter; +use crate::util::file_watcher::WatcherCommunicator; +use crate::util::file_watcher::WatcherRestartMode; +use deno_core::error::generic_error; +use deno_core::error::AnyError; +use deno_core::futures::StreamExt; +use deno_core::serde_json::json; +use deno_core::serde_json::{self}; +use deno_core::url::Url; +use deno_core::LocalInspectorSession; +use deno_runtime::colors; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::select; + +mod json_types; + +use json_types::RpcNotification; +use json_types::ScriptParsed; +use json_types::SetScriptSourceReturnObject; +use json_types::Status; + +/// This structure is responsible for providing Hot Module Replacement +/// functionality. +/// +/// It communicates with V8 inspector over a local session and waits for +/// notifications about changed files from the `FileWatcher`. +/// +/// Upon receiving such notification, the runner decides if the changed +/// path should be handled the `FileWatcher` itself (as if we were running +/// in `--watch` mode), or if the path is eligible to be hot replaced in the +/// current program. +/// +/// Even if the runner decides that a path will be hot-replaced, the V8 isolate +/// can refuse to perform hot replacement, eg. a top-level variable/function +/// of an ES module cannot be hot-replaced. In such situation the runner will +/// force a full restart of a program by notifying the `FileWatcher`. +pub struct HmrRunner { + session: LocalInspectorSession, + watcher_communicator: Arc, + script_ids: HashMap, + emitter: Arc, +} + +impl HmrRunner { + pub fn new( + emitter: Arc, + session: LocalInspectorSession, + watcher_communicator: Arc, + ) -> Self { + Self { + session, + emitter, + watcher_communicator, + script_ids: HashMap::new(), + } + } + + // TODO(bartlomieju): this code is duplicated in `cli/tools/coverage/mod.rs` + pub async fn start(&mut self) -> Result<(), AnyError> { + self.enable_debugger().await + } + + // TODO(bartlomieju): this code is duplicated in `cli/tools/coverage/mod.rs` + pub async fn stop(&mut self) -> Result<(), AnyError> { + self + .watcher_communicator + .change_restart_mode(WatcherRestartMode::Automatic); + self.disable_debugger().await + } + + // TODO(bartlomieju): this code is duplicated in `cli/tools/coverage/mod.rs` + async fn enable_debugger(&mut self) -> Result<(), AnyError> { + self + .session + .post_message::<()>("Debugger.enable", None) + .await?; + self + .session + .post_message::<()>("Runtime.enable", None) + .await?; + Ok(()) + } + + // TODO(bartlomieju): this code is duplicated in `cli/tools/coverage/mod.rs` + async fn disable_debugger(&mut self) -> Result<(), AnyError> { + self + .session + .post_message::<()>("Debugger.disable", None) + .await?; + self + .session + .post_message::<()>("Runtime.disable", None) + .await?; + Ok(()) + } + + async fn set_script_source( + &mut self, + script_id: &str, + source: &str, + ) -> Result { + let result = self + .session + .post_message( + "Debugger.setScriptSource", + Some(json!({ + "scriptId": script_id, + "scriptSource": source, + "allowTopFrameEditing": true, + })), + ) + .await?; + + Ok(serde_json::from_value::( + result, + )?) + } + + async fn dispatch_hmr_event( + &mut self, + script_id: &str, + ) -> Result<(), AnyError> { + let expr = format!( + "dispatchEvent(new CustomEvent(\"hmr\", {{ detail: {{ path: \"{}\" }} }}));", + script_id + ); + + let _result = self + .session + .post_message( + "Runtime.evaluate", + Some(json!({ + "expression": expr, + "contextId": Some(1), + })), + ) + .await?; + + Ok(()) + } + + pub async fn run(&mut self) -> Result<(), AnyError> { + self + .watcher_communicator + .change_restart_mode(WatcherRestartMode::Manual); + let mut session_rx = self.session.take_notification_rx(); + loop { + select! { + biased; + Some(notification) = session_rx.next() => { + let notification = serde_json::from_value::(notification)?; + // TODO(bartlomieju): this is not great... and the code is duplicated with the REPL. + if notification.method == "Runtime.exceptionThrown" { + let params = notification.params; + let exception_details = params.get("exceptionDetails").unwrap().as_object().unwrap(); + let text = exception_details.get("text").unwrap().as_str().unwrap(); + let exception = exception_details.get("exception").unwrap().as_object().unwrap(); + let description = exception.get("description").and_then(|d| d.as_str()).unwrap_or("undefined"); + break Err(generic_error(format!("{text} {description}"))); + } else if notification.method == "Debugger.scriptParsed" { + let params = serde_json::from_value::(notification.params)?; + if params.url.starts_with("file://") { + let file_url = Url::parse(¶ms.url).unwrap(); + let file_path = file_url.to_file_path().unwrap(); + if let Ok(canonicalized_file_path) = file_path.canonicalize() { + let canonicalized_file_url = Url::from_file_path(canonicalized_file_path).unwrap(); + self.script_ids.insert(canonicalized_file_url.to_string(), params.script_id); + } + } + } + } + changed_paths = self.watcher_communicator.watch_for_changed_paths() => { + let changed_paths = changed_paths?; + + let Some(changed_paths) = changed_paths else { + let _ = self.watcher_communicator.force_restart(); + continue; + }; + + let filtered_paths: Vec = changed_paths.into_iter().filter(|p| p.extension().map_or(false, |ext| { + let ext_str = ext.to_str().unwrap(); + matches!(ext_str, "js" | "ts" | "jsx" | "tsx") + })).collect(); + + // If after filtering there are no paths it means it's either a file + // we can't HMR or an external file that was passed explicitly to + // `--unstable-hmr=` path. + if filtered_paths.is_empty() { + let _ = self.watcher_communicator.force_restart(); + continue; + } + + for path in filtered_paths { + let Some(path_str) = path.to_str() else { + let _ = self.watcher_communicator.force_restart(); + continue; + }; + let Ok(module_url) = Url::from_file_path(path_str) else { + let _ = self.watcher_communicator.force_restart(); + continue; + }; + + let Some(id) = self.script_ids.get(module_url.as_str()).cloned() else { + let _ = self.watcher_communicator.force_restart(); + continue; + }; + + let source_code = self.emitter.load_and_emit_for_hmr( + &module_url + ).await?; + + let mut tries = 1; + loop { + let result = self.set_script_source(&id, source_code.as_str()).await?; + + if matches!(result.status, Status::Ok) { + self.dispatch_hmr_event(module_url.as_str()).await?; + self.watcher_communicator.print(format!("Replaced changed module {}", module_url.as_str())); + break; + } + + self.watcher_communicator.print(format!("Failed to reload module {}: {}.", module_url, colors::gray(result.status.explain()))); + if result.status.should_retry() && tries <= 2 { + tries += 1; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + + let _ = self.watcher_communicator.force_restart(); + break; + } + } + } + _ = self.session.receive_from_v8_session() => {} + } + } + } +} diff --git a/cli/tools/run/mod.rs b/cli/tools/run/mod.rs new file mode 100644 index 000000000..119129b1b --- /dev/null +++ b/cli/tools/run/mod.rs @@ -0,0 +1,205 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use std::io::Read; + +use deno_ast::MediaType; +use deno_core::error::AnyError; +use deno_runtime::permissions::Permissions; +use deno_runtime::permissions::PermissionsContainer; + +use crate::args::EvalFlags; +use crate::args::Flags; +use crate::args::RunFlags; +use crate::args::WatchFlagsWithPaths; +use crate::factory::CliFactory; +use crate::factory::CliFactoryBuilder; +use crate::file_fetcher::File; +use crate::util; +use crate::util::file_watcher::WatcherRestartMode; + +pub mod hmr; + +pub async fn run_script( + flags: Flags, + run_flags: RunFlags, +) -> Result { + if !flags.has_permission() && flags.has_permission_in_argv() { + log::warn!( + "{}", + crate::colors::yellow( + r#"Permission flags have likely been incorrectly set after the script argument. +To grant permissions, set them before the script argument. For example: + deno run --allow-read=. main.js"# + ) + ); + } + + if let Some(watch_flags) = run_flags.watch { + return run_with_watch(flags, watch_flags).await; + } + + // TODO(bartlomieju): actually I think it will also fail if there's an import + // map specified and bare specifier is used on the command line + let factory = CliFactory::from_flags(flags).await?; + let deno_dir = factory.deno_dir()?; + let http_client = factory.http_client(); + let cli_options = factory.cli_options(); + + // Run a background task that checks for available upgrades. If an earlier + // run of this background task found a new version of Deno. + super::upgrade::check_for_upgrades( + http_client.clone(), + deno_dir.upgrade_check_file_path(), + ); + + let main_module = cli_options.resolve_main_module()?; + + maybe_npm_install(&factory).await?; + + let permissions = PermissionsContainer::new(Permissions::from_options( + &cli_options.permissions_options(), + )?); + let worker_factory = factory.create_cli_main_worker_factory().await?; + let mut worker = worker_factory + .create_main_worker(main_module, permissions) + .await?; + + let exit_code = worker.run().await?; + Ok(exit_code) +} + +pub async fn run_from_stdin(flags: Flags) -> Result { + let factory = CliFactory::from_flags(flags).await?; + let cli_options = factory.cli_options(); + let main_module = cli_options.resolve_main_module()?; + + maybe_npm_install(&factory).await?; + + let file_fetcher = factory.file_fetcher()?; + let worker_factory = factory.create_cli_main_worker_factory().await?; + let permissions = PermissionsContainer::new(Permissions::from_options( + &cli_options.permissions_options(), + )?); + let mut source = Vec::new(); + std::io::stdin().read_to_end(&mut source)?; + // Create a dummy source file. + let source_file = File { + maybe_types: None, + media_type: MediaType::TypeScript, + source: String::from_utf8(source)?.into(), + specifier: main_module.clone(), + maybe_headers: None, + }; + // Save our fake file into file fetcher cache + // to allow module access by TS compiler + file_fetcher.insert_cached(source_file); + + let mut worker = worker_factory + .create_main_worker(main_module, permissions) + .await?; + let exit_code = worker.run().await?; + Ok(exit_code) +} + +// TODO(bartlomieju): this function is not handling `exit_code` set by the runtime +// code properly. +async fn run_with_watch( + flags: Flags, + watch_flags: WatchFlagsWithPaths, +) -> Result { + util::file_watcher::watch_recv( + flags, + util::file_watcher::PrintConfig::new_with_banner( + if watch_flags.hmr { "HMR" } else { "Watcher" }, + "Process", + !watch_flags.no_clear_screen, + ), + WatcherRestartMode::Automatic, + move |flags, watcher_communicator, _changed_paths| { + Ok(async move { + let factory = CliFactoryBuilder::new() + .build_from_flags_for_watcher(flags, watcher_communicator.clone()) + .await?; + let cli_options = factory.cli_options(); + let main_module = cli_options.resolve_main_module()?; + + maybe_npm_install(&factory).await?; + + let _ = watcher_communicator.watch_paths(cli_options.watch_paths()); + + let permissions = PermissionsContainer::new(Permissions::from_options( + &cli_options.permissions_options(), + )?); + let mut worker = factory + .create_cli_main_worker_factory() + .await? + .create_main_worker(main_module, permissions) + .await?; + + if watch_flags.hmr { + worker.run().await?; + } else { + worker.run_for_watcher().await?; + } + + Ok(()) + }) + }, + ) + .await?; + + Ok(0) +} + +pub async fn eval_command( + flags: Flags, + eval_flags: EvalFlags, +) -> Result { + let factory = CliFactory::from_flags(flags).await?; + let cli_options = factory.cli_options(); + let file_fetcher = factory.file_fetcher()?; + let main_module = cli_options.resolve_main_module()?; + + maybe_npm_install(&factory).await?; + + // Create a dummy source file. + let source_code = if eval_flags.print { + format!("console.log({})", eval_flags.code) + } else { + eval_flags.code + } + .into_bytes(); + + let file = File { + maybe_types: None, + media_type: MediaType::Unknown, + source: String::from_utf8(source_code)?.into(), + specifier: main_module.clone(), + maybe_headers: None, + }; + + // Save our fake file into file fetcher cache + // to allow module access by TS compiler. + file_fetcher.insert_cached(file); + + let permissions = PermissionsContainer::new(Permissions::from_options( + &cli_options.permissions_options(), + )?); + let worker_factory = factory.create_cli_main_worker_factory().await?; + let mut worker = worker_factory + .create_main_worker(main_module, permissions) + .await?; + let exit_code = worker.run().await?; + Ok(exit_code) +} + +async fn maybe_npm_install(factory: &CliFactory) -> Result<(), AnyError> { + // ensure an "npm install" is done if the user has explicitly + // opted into using a managed node_modules directory + if factory.cli_options().node_modules_dir_enablement() == Some(true) { + if let Some(npm_resolver) = factory.npm_resolver().await?.as_managed() { + npm_resolver.ensure_top_level_package_json_install().await?; + } + } + Ok(()) +} diff --git a/cli/tools/test/mod.rs b/cli/tools/test/mod.rs index 8e29ba2cb..5e34e345f 100644 --- a/cli/tools/test/mod.rs +++ b/cli/tools/test/mod.rs @@ -1205,14 +1205,14 @@ pub async fn run_tests_with_watch( file_watcher::watch_func( flags, - file_watcher::PrintConfig { - job_name: "Test".to_string(), - clear_screen: test_flags + file_watcher::PrintConfig::new( + "Test", + test_flags .watch .as_ref() .map(|w| !w.no_clear_screen) .unwrap_or(true), - }, + ), move |flags, watcher_communicator, changed_paths| { let test_flags = test_flags.clone(); Ok(async move { diff --git a/cli/util/file_watcher.rs b/cli/util/file_watcher.rs index 8d6b4e8fb..5a316139c 100644 --- a/cli/util/file_watcher.rs +++ b/cli/util/file_watcher.rs @@ -8,6 +8,7 @@ use deno_core::error::AnyError; use deno_core::error::JsError; use deno_core::futures::Future; use deno_core::futures::FutureExt; +use deno_core::parking_lot::Mutex; use deno_runtime::fmt_errors::format_js_error; use log::info; use notify::event::Event as NotifyEvent; @@ -16,9 +17,11 @@ use notify::Error as NotifyError; use notify::RecommendedWatcher; use notify::RecursiveMode; use notify::Watcher; +use std::cell::RefCell; use std::collections::HashSet; use std::io::IsTerminal; use std::path::PathBuf; +use std::rc::Rc; use std::sync::Arc; use std::time::Duration; use tokio::select; @@ -91,20 +94,49 @@ where } pub struct PrintConfig { - /// printing watcher status to terminal. - pub job_name: String, - /// determine whether to clear the terminal screen; applicable to TTY environments only. - pub clear_screen: bool, + banner: &'static str, + /// Printing watcher status to terminal. + job_name: &'static str, + /// Determine whether to clear the terminal screen; applicable to TTY environments only. + clear_screen: bool, } -fn create_print_after_restart_fn(clear_screen: bool) -> impl Fn() { +impl PrintConfig { + /// By default `PrintConfig` uses "Watcher" as a banner name that will + /// be printed in color. If you need to customize it, use + /// `PrintConfig::new_with_banner` instead. + pub fn new(job_name: &'static str, clear_screen: bool) -> Self { + Self { + banner: "Watcher", + job_name, + clear_screen, + } + } + + pub fn new_with_banner( + banner: &'static str, + job_name: &'static str, + clear_screen: bool, + ) -> Self { + Self { + banner, + job_name, + clear_screen, + } + } +} + +fn create_print_after_restart_fn( + banner: &'static str, + clear_screen: bool, +) -> impl Fn() { move || { if clear_screen && std::io::stderr().is_terminal() { eprint!("{CLEAR_SCREEN}"); } info!( "{} File change detected! Restarting!", - colors::intense_blue("Watcher"), + colors::intense_blue(banner), ); } } @@ -120,22 +152,38 @@ pub struct WatcherCommunicator { /// Send a message to force a restart. restart_tx: tokio::sync::mpsc::UnboundedSender<()>, -} -impl Clone for WatcherCommunicator { - fn clone(&self) -> Self { - Self { - paths_to_watch_tx: self.paths_to_watch_tx.clone(), - changed_paths_rx: self.changed_paths_rx.resubscribe(), - restart_tx: self.restart_tx.clone(), - } - } + restart_mode: Mutex, + + banner: String, } impl WatcherCommunicator { pub fn watch_paths(&self, paths: Vec) -> Result<(), AnyError> { self.paths_to_watch_tx.send(paths).map_err(AnyError::from) } + + pub fn force_restart(&self) -> Result<(), AnyError> { + // Change back to automatic mode, so that HMR can set up watching + // from scratch. + *self.restart_mode.lock() = WatcherRestartMode::Automatic; + self.restart_tx.send(()).map_err(AnyError::from) + } + + pub async fn watch_for_changed_paths( + &self, + ) -> Result>, AnyError> { + let mut rx = self.changed_paths_rx.resubscribe(); + rx.recv().await.map_err(AnyError::from) + } + + pub fn change_restart_mode(&self, restart_mode: WatcherRestartMode) { + *self.restart_mode.lock() = restart_mode; + } + + pub fn print(&self, msg: String) { + log::info!("{} {}", self.banner, msg); + } } /// Creates a file watcher. @@ -151,7 +199,7 @@ pub async fn watch_func( where O: FnMut( Flags, - WatcherCommunicator, + Arc, Option>, ) -> Result, F: Future>, @@ -173,9 +221,7 @@ pub enum WatcherRestartMode { Automatic, /// When a file path changes the caller will trigger a restart, using - /// `WatcherCommunicator.restart_tx`. - // TODO(bartlomieju): this mode will be used in a follow up PR - #[allow(dead_code)] + /// `WatcherInterface.restart_tx`. Manual, } @@ -193,7 +239,7 @@ pub async fn watch_recv( where O: FnMut( Flags, - WatcherCommunicator, + Arc, Option>, ) -> Result, F: Future>, @@ -206,19 +252,42 @@ where DebouncedReceiver::new_with_sender(); let PrintConfig { + banner, job_name, clear_screen, } = print_config; - let print_after_restart = create_print_after_restart_fn(clear_screen); - let watcher_communicator = WatcherCommunicator { + let print_after_restart = create_print_after_restart_fn(banner, clear_screen); + let watcher_communicator = Arc::new(WatcherCommunicator { paths_to_watch_tx: paths_to_watch_tx.clone(), changed_paths_rx: changed_paths_rx.resubscribe(), restart_tx: restart_tx.clone(), - }; - info!("{} {} started.", colors::intense_blue("Watcher"), job_name,); + restart_mode: Mutex::new(restart_mode), + banner: colors::intense_blue(banner).to_string(), + }); + info!("{} {} started.", colors::intense_blue(banner), job_name); + + let changed_paths = Rc::new(RefCell::new(None)); + let changed_paths_ = changed_paths.clone(); + let watcher_ = watcher_communicator.clone(); + + deno_core::unsync::spawn(async move { + loop { + let received_changed_paths = watcher_receiver.recv().await; + *changed_paths_.borrow_mut() = received_changed_paths.clone(); + + match *watcher_.restart_mode.lock() { + WatcherRestartMode::Automatic => { + let _ = restart_tx.send(()); + } + WatcherRestartMode::Manual => { + // TODO(bartlomieju): should we fail on sending changed paths? + let _ = changed_paths_tx.send(received_changed_paths); + } + } + } + }); - let mut changed_paths = None; loop { // We may need to give the runtime a tick to settle, as cancellations may need to propagate // to tasks. We choose yielding 10 times to the runtime as a decent heuristic. If watch tests @@ -239,7 +308,7 @@ where let operation_future = error_handler(operation( flags.clone(), watcher_communicator.clone(), - changed_paths.take(), + changed_paths.borrow_mut().take(), )?); // don't reload dependencies after the first run @@ -251,26 +320,12 @@ where print_after_restart(); continue; }, - received_changed_paths = watcher_receiver.recv() => { - changed_paths = received_changed_paths.clone(); - - match restart_mode { - WatcherRestartMode::Automatic => { - print_after_restart(); - continue; - }, - WatcherRestartMode::Manual => { - // TODO(bartlomieju): should we fail on sending changed paths? - let _ = changed_paths_tx.send(received_changed_paths); - } - } - }, success = operation_future => { consume_paths_to_watch(&mut watcher, &mut paths_to_watch_rx); // TODO(bartlomieju): print exit code here? info!( "{} {} {}. Restarting on file change...", - colors::intense_blue("Watcher"), + colors::intense_blue(banner), job_name, if success { "finished" @@ -280,7 +335,6 @@ where ); }, }; - let receiver_future = async { loop { let maybe_paths = paths_to_watch_rx.recv().await; @@ -293,9 +347,8 @@ where // watched paths has changed. select! { _ = receiver_future => {}, - received_changed_paths = watcher_receiver.recv() => { + _ = restart_rx.recv() => { print_after_restart(); - changed_paths = received_changed_paths; continue; }, }; diff --git a/cli/worker.rs b/cli/worker.rs index d8738d492..58bd96642 100644 --- a/cli/worker.rs +++ b/cli/worker.rs @@ -43,15 +43,20 @@ use deno_runtime::BootstrapOptions; use deno_runtime::WorkerLogLevel; use deno_semver::npm::NpmPackageReqReference; use deno_semver::package::PackageReqReference; +use tokio::select; use crate::args::package_json::PackageJsonDeps; use crate::args::StorageKeyResolver; +use crate::emit::Emitter; use crate::errors; use crate::npm::CliNpmResolver; use crate::ops; use crate::tools; use crate::tools::coverage::CoverageCollector; +use crate::tools::run::hmr::HmrRunner; use crate::util::checksum; +use crate::util::file_watcher::WatcherCommunicator; +use crate::util::file_watcher::WatcherRestartMode; use crate::version; pub trait ModuleLoaderFactory: Send + Sync { @@ -83,6 +88,7 @@ pub struct CliMainWorkerOptions { pub coverage_dir: Option, pub enable_testing_features: bool, pub has_node_modules_dir: bool, + pub hmr: bool, pub inspect_brk: bool, pub inspect_wait: bool, pub is_inspecting: bool, @@ -108,6 +114,8 @@ struct SharedWorkerState { module_loader_factory: Box, root_cert_store_provider: Arc, fs: Arc, + emitter: Option>, + maybe_file_watcher_communicator: Option>, maybe_inspector_server: Option>, maybe_lockfile: Option>>, feature_checker: Arc, @@ -137,6 +145,8 @@ impl CliMainWorker { pub async fn run(&mut self) -> Result { let mut maybe_coverage_collector = self.maybe_setup_coverage_collector().await?; + let mut maybe_hmr_runner = self.maybe_setup_hmr_runner().await?; + log::debug!("main_module {}", self.main_module); if self.is_main_cjs { @@ -153,10 +163,34 @@ impl CliMainWorker { self.worker.dispatch_load_event(located_script_name!())?; loop { - self - .worker - .run_event_loop(maybe_coverage_collector.is_none()) - .await?; + if let Some(hmr_runner) = maybe_hmr_runner.as_mut() { + let watcher_communicator = + self.shared.maybe_file_watcher_communicator.clone().unwrap(); + + let hmr_future = hmr_runner.run().boxed_local(); + let event_loop_future = self.worker.run_event_loop(false).boxed_local(); + + let result; + select! { + hmr_result = hmr_future => { + result = hmr_result; + }, + event_loop_result = event_loop_future => { + result = event_loop_result; + } + } + if let Err(e) = result { + watcher_communicator + .change_restart_mode(WatcherRestartMode::Automatic); + return Err(e); + } + } else { + self + .worker + .run_event_loop(maybe_coverage_collector.is_none()) + .await?; + } + if !self .worker .dispatch_beforeunload_event(located_script_name!())? @@ -173,6 +207,12 @@ impl CliMainWorker { .with_event_loop(coverage_collector.stop_collecting().boxed_local()) .await?; } + if let Some(hmr_runner) = maybe_hmr_runner.as_mut() { + self + .worker + .with_event_loop(hmr_runner.stop().boxed_local()) + .await?; + } Ok(self.worker.exit_code()) } @@ -287,6 +327,28 @@ impl CliMainWorker { } } + pub async fn maybe_setup_hmr_runner( + &mut self, + ) -> Result, AnyError> { + if !self.shared.options.hmr { + return Ok(None); + } + + let watcher_communicator = + self.shared.maybe_file_watcher_communicator.clone().unwrap(); + let emitter = self.shared.emitter.clone().unwrap(); + + let session = self.worker.create_inspector_session().await; + let mut hmr_runner = HmrRunner::new(emitter, session, watcher_communicator); + + self + .worker + .with_event_loop(hmr_runner.start().boxed_local()) + .await?; + + Ok(Some(hmr_runner)) + } + pub fn execute_script_static( &mut self, name: &'static str, @@ -313,6 +375,8 @@ impl CliMainWorkerFactory { module_loader_factory: Box, root_cert_store_provider: Arc, fs: Arc, + emitter: Option>, + maybe_file_watcher_communicator: Option>, maybe_inspector_server: Option>, maybe_lockfile: Option>>, feature_checker: Arc, @@ -330,7 +394,9 @@ impl CliMainWorkerFactory { compiled_wasm_module_store: Default::default(), module_loader_factory, root_cert_store_provider, + emitter, fs, + maybe_file_watcher_communicator, maybe_inspector_server, maybe_lockfile, feature_checker, -- cgit v1.2.3