diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 5b411e36b6..271a56ac36 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 ab8d6b503c..96f4e9a74a 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 e81d2e83c6..8e51c4edde 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 9cdd327026..389c4dbe07 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 2f5fd40fd8..f2713a9db2 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 612ae9eedd..803655b9a0 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 1ee8a45e08..0defaa69ea 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 eb400442e2..70551a7672 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 b36ff023ac..0946c728bc 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 92facc7ecd..5c47b5497f 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 b7f4a3f0d9..5b9387eb13 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/hmr/json_types.rs b/cli/tools/run/hmr/json_types.rs new file mode 100644 index 0000000000..3ac80344b6 --- /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 0000000000..1a57723070 --- /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.rs b/cli/tools/run/mod.rs similarity index 92% rename from cli/tools/run.rs rename to cli/tools/run/mod.rs index 80e80577e9..119129b1ba 100644 --- a/cli/tools/run.rs +++ b/cli/tools/run/mod.rs @@ -15,6 +15,9 @@ 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, @@ -104,12 +107,14 @@ async fn run_with_watch( flags: Flags, watch_flags: WatchFlagsWithPaths, ) -> Result { - util::file_watcher::watch_func( + util::file_watcher::watch_recv( flags, - util::file_watcher::PrintConfig { - job_name: "Process".to_string(), - clear_screen: !watch_flags.no_clear_screen, - }, + 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() @@ -125,12 +130,17 @@ async fn run_with_watch( let permissions = PermissionsContainer::new(Permissions::from_options( &cli_options.permissions_options(), )?); - let worker = factory + let mut worker = factory .create_cli_main_worker_factory() .await? .create_main_worker(main_module, permissions) .await?; - worker.run_for_watcher().await?; + + if watch_flags.hmr { + worker.run().await?; + } else { + worker.run_for_watcher().await?; + } Ok(()) }) diff --git a/cli/tools/test/mod.rs b/cli/tools/test/mod.rs index 8e29ba2cbf..5e34e345f0 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 8d6b4e8fb6..5a316139cc 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 d8738d4921..58bd96642e 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,