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 <syrupthinker@gryphno.de>
Co-authored-by: David Sherret <dsherret@gmail.com>
This commit is contained in:
Bartek Iwańczuk 2023-10-31 01:25:58 +01:00 committed by GitHub
parent 48c5c3a3fb
commit 1713df1352
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 933 additions and 108 deletions

View file

@ -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<PathBuf>,
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<String, String> {
fn watch_arg_parse(matches: &mut ArgMatches) -> Option<WatchFlags> {
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<WatchFlags> {
fn watch_arg_parse_with_paths(
matches: &mut ArgMatches,
) -> Option<WatchFlagsWithPaths> {
if let Some(paths) = matches.remove_many::<PathBuf>("watch") {
return Some(WatchFlagsWithPaths {
paths: paths.collect(),
hmr: false,
no_clear_screen: matches.get_flag("no-clear-screen"),
});
}
matches
.remove_many::<PathBuf>("watch")
.map(|f| WatchFlagsWithPaths {
paths: f.collect(),
.remove_many::<PathBuf>("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,

View file

@ -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()

View file

@ -101,6 +101,26 @@ impl Emitter {
}
}
/// Expects a file URL, panics otherwise.
pub async fn load_and_emit_for_hmr(
&self,
specifier: &ModuleSpecifier,
) -> Result<String, AnyError> {
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<str> = 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.

View file

@ -66,7 +66,7 @@ use std::future::Future;
use std::sync::Arc;
pub struct CliFactoryBuilder {
watcher_communicator: Option<WatcherCommunicator>,
watcher_communicator: Option<Arc<WatcherCommunicator>>,
}
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<WatcherCommunicator>,
) -> Result<CliFactory, AnyError> {
self.watcher_communicator = Some(watcher_communicator);
self.build_from_flags(flags).await
@ -171,7 +171,7 @@ struct CliFactoryServices {
}
pub struct CliFactory {
watcher_communicator: Option<WatcherCommunicator>,
watcher_communicator: Option<Arc<WatcherCommunicator>>,
options: Arc<CliOptions>,
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(),

View file

@ -681,12 +681,12 @@ impl<'a> ModuleGraphUpdatePermit<'a> {
#[derive(Clone, Debug)]
pub struct FileWatcherReporter {
watcher_communicator: WatcherCommunicator,
watcher_communicator: Arc<WatcherCommunicator>,
file_paths: Arc<Mutex<Vec<PathBuf>>>,
}
impl FileWatcherReporter {
pub fn new(watcher_communicator: WatcherCommunicator) -> Self {
pub fn new(watcher_communicator: Arc<WatcherCommunicator>) -> Self {
Self {
watcher_communicator,
file_paths: Default::default(),

View file

@ -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,

View file

@ -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 `<h1>Hello</h1>`;
}
"#,
);
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 <h1>Hello</h1>", &mut stdout_lines).await;
file_to_watch2.write(
r#"
export function foo() {
return `<h1>Hello world</h1>`;
}
"#,
);
wait_contains("Replaced changed module", &mut stderr_lines).await;
wait_contains("<h1>Hello world</h1>", &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 `<h1>asd1</h1>`;
}
"#,
);
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("<h1>asd1</h1>", &mut stdout_lines).await;
wait_contains("fail", &mut stderr_lines).await;
file_to_watch2.write(
r#"
export function foo() {
return `<h1>asd2</h1>`;
}
"#,
);
wait_contains("Process failed", &mut stderr_lines).await;
wait_contains("File change detected", &mut stderr_lines).await;
wait_contains("<h1>asd2</h1>", &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 `<h1>asd1</h1>`;
}
"#,
);
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 <h1>asd1</h1>", &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("<h1>asd1</h1>", &mut stdout_lines).await;
check_alive_then_kill(child);
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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,
}
}
}

242
cli/tools/run/hmr/mod.rs Normal file
View file

@ -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<WatcherCommunicator>,
script_ids: HashMap<String, String>,
emitter: Arc<Emitter>,
}
impl HmrRunner {
pub fn new(
emitter: Arc<Emitter>,
session: LocalInspectorSession,
watcher_communicator: Arc<WatcherCommunicator>,
) -> 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<SetScriptSourceReturnObject, AnyError> {
let result = self
.session
.post_message(
"Debugger.setScriptSource",
Some(json!({
"scriptId": script_id,
"scriptSource": source,
"allowTopFrameEditing": true,
})),
)
.await?;
Ok(serde_json::from_value::<SetScriptSourceReturnObject>(
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::<RpcNotification>(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::<ScriptParsed>(notification.params)?;
if params.url.starts_with("file://") {
let file_url = Url::parse(&params.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<PathBuf> = 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=<file>` 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() => {}
}
}
}
}

View file

@ -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<i32, AnyError> {
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(())
})

View file

@ -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 {

View file

@ -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<WatcherRestartMode>,
banner: String,
}
impl WatcherCommunicator {
pub fn watch_paths(&self, paths: Vec<PathBuf>) -> 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<Option<Vec<PathBuf>>, 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<O, F>(
where
O: FnMut(
Flags,
WatcherCommunicator,
Arc<WatcherCommunicator>,
Option<Vec<PathBuf>>,
) -> Result<F, AnyError>,
F: Future<Output = Result<(), AnyError>>,
@ -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<O, F>(
where
O: FnMut(
Flags,
WatcherCommunicator,
Arc<WatcherCommunicator>,
Option<Vec<PathBuf>>,
) -> Result<F, AnyError>,
F: Future<Output = Result<(), AnyError>>,
@ -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;
},
};

View file

@ -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<String>,
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<dyn ModuleLoaderFactory>,
root_cert_store_provider: Arc<dyn RootCertStoreProvider>,
fs: Arc<dyn deno_fs::FileSystem>,
emitter: Option<Arc<Emitter>>,
maybe_file_watcher_communicator: Option<Arc<WatcherCommunicator>>,
maybe_inspector_server: Option<Arc<InspectorServer>>,
maybe_lockfile: Option<Arc<Mutex<Lockfile>>>,
feature_checker: Arc<FeatureChecker>,
@ -137,6 +145,8 @@ impl CliMainWorker {
pub async fn run(&mut self) -> Result<i32, AnyError> {
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<Option<HmrRunner>, 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<dyn ModuleLoaderFactory>,
root_cert_store_provider: Arc<dyn RootCertStoreProvider>,
fs: Arc<dyn deno_fs::FileSystem>,
emitter: Option<Arc<Emitter>>,
maybe_file_watcher_communicator: Option<Arc<WatcherCommunicator>>,
maybe_inspector_server: Option<Arc<InspectorServer>>,
maybe_lockfile: Option<Arc<Mutex<Lockfile>>>,
feature_checker: Arc<FeatureChecker>,
@ -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,