diff options
Diffstat (limited to 'cli/tools/run')
-rw-r--r-- | cli/tools/run/hmr/json_types.rs | 59 | ||||
-rw-r--r-- | cli/tools/run/hmr/mod.rs | 242 | ||||
-rw-r--r-- | cli/tools/run/mod.rs | 205 |
3 files changed, 506 insertions, 0 deletions
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<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(¶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<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() => {} + } + } + } +} 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<i32, AnyError> { + 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<i32, AnyError> { + 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<i32, AnyError> { + 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<i32, AnyError> { + 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(()) +} |