From 20c4a9bc48d053fed198eba126eee2b7dfe693d2 Mon Sep 17 00:00:00 2001 From: Pedro Tacla Yamada Date: Wed, 1 May 2024 12:30:49 +1000 Subject: [PATCH] Start to add rust backed request execution under a feature-flag by rewriting ConfigRequest (#9658) --- .github/workflows/ci.yml | 1 + .mocharc.json | 2 +- Cargo.lock | 110 ++++- crates/node-bindings/Cargo.toml | 12 +- .../filesystem/in_memory_file_system/mod.rs | 186 +++++++++ .../filesystem/js_delegate_file_system/mod.rs | 108 +++++ .../node-bindings/src/core/filesystem/mod.rs | 6 + crates/node-bindings/src/core/mod.rs | 5 + .../src/core/requests/config_request/mod.rs | 382 ++++++++++++++++++ crates/node-bindings/src/core/requests/mod.rs | 111 +++++ .../requests/request_api/js_request_api.rs | 123 ++++++ .../src/core/requests/request_api/mod.rs | 56 +++ crates/node-bindings/src/lib.rs | 1 + crates/node-bindings/src/resolver.rs | 12 +- .../core/core/src/requests/ConfigRequest.js | 27 ++ .../core/test/requests/ConfigRequest.test.js | 326 +++++++++++++++ packages/core/core/test/test-utils.js | 1 + packages/core/feature-flags/src/index.js | 1 + packages/core/feature-flags/src/types.js | 4 + .../core/graph/test/AdjacencyList.test.js | 3 +- .../adjacency-list-shared-array.js | 2 +- packages/core/integration-tests/test/cache.js | 3 + packages/core/rust/index.js.flow | 34 +- .../rules/no-self-package-imports.test.js | 2 +- .../reporters/cli/test/CLIReporter.test.js | 41 +- packages/utils/node-resolver-rs/src/fs.rs | 2 +- 26 files changed, 1517 insertions(+), 44 deletions(-) create mode 100644 crates/node-bindings/src/core/filesystem/in_memory_file_system/mod.rs create mode 100644 crates/node-bindings/src/core/filesystem/js_delegate_file_system/mod.rs create mode 100644 crates/node-bindings/src/core/filesystem/mod.rs create mode 100644 crates/node-bindings/src/core/mod.rs create mode 100644 crates/node-bindings/src/core/requests/config_request/mod.rs create mode 100644 crates/node-bindings/src/core/requests/mod.rs create mode 100644 crates/node-bindings/src/core/requests/request_api/js_request_api.rs create mode 100644 crates/node-bindings/src/core/requests/request_api/mod.rs create mode 100644 packages/core/core/test/requests/ConfigRequest.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46e9ff57702..3b0e3cc1aaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: Continuous Integration on: + merge_group: pull_request: push: branches: diff --git a/.mocharc.json b/.mocharc.json index 5b18837e463..090b32f352b 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,5 +1,5 @@ { - "spec": "packages/*/!(integration-tests)/test/*.js", + "spec": "packages/*/!(integration-tests)/test/{*.js,**/*.{test,spec}.js}", "require": ["@parcel/babel-register", "@parcel/test-utils/src/mochaSetup.js"], // TODO: Remove this when https://github.com/nodejs/node/pull/28788 is resolved "exit": true diff --git a/Cargo.lock b/Cargo.lock index 02eddd3f1f4..652f4f8c74b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,9 +73,9 @@ checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" [[package]] name = "anyhow" -version = "1.0.75" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" dependencies = [ "backtrace", ] @@ -525,6 +525,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dunce" version = "1.0.3" @@ -656,6 +662,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + [[package]] name = "from_variant" version = "0.1.7" @@ -1303,6 +1315,33 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mozjpeg-sys" version = "1.0.3" @@ -1621,6 +1660,7 @@ dependencies = [ name = "parcel-node-bindings" version = "0.1.0" dependencies = [ + "anyhow", "crossbeam-channel", "dashmap", "getrandom", @@ -1629,6 +1669,7 @@ dependencies = [ "libc", "log", "mimalloc", + "mockall", "mozjpeg-sys", "napi", "napi-build", @@ -1643,6 +1684,7 @@ dependencies = [ "sentry", "serde", "serde_json", + "toml", "whoami", "xxhash-rust", ] @@ -2413,9 +2455,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.197" +version = "1.0.198" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" dependencies = [ "serde_derive", ] @@ -2431,9 +2473,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.198" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" dependencies = [ "proc-macro2", "quote", @@ -2442,15 +2484,24 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3543,6 +3594,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef" +dependencies = [ + "indexmap 2.1.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower-service" version = "0.3.2" @@ -4043,6 +4128,15 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "winnow" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c976aaaa0e1f90dbb21e9587cdaf1d9679a1cde8875c0d6bd83ab96a208352" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/crates/node-bindings/Cargo.toml b/crates/node-bindings/Cargo.toml index 3775c18fa57..7fdd5fe9d5e 100644 --- a/crates/node-bindings/Cargo.toml +++ b/crates/node-bindings/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" crate-type = ["cdylib"] [features] -canary = ["sentry", "once_cell", "whoami", "serde", "serde_json", "rustls"] +canary = ["sentry", "once_cell", "whoami", "rustls"] rustls = ["sentry/rustls"] openssl = ["sentry/native-tls"] @@ -24,8 +24,12 @@ log = "0.4.21" sentry = { version = "0.32.2", optional = true, default-features = false, features = ["backtrace", "contexts", "panic", "reqwest", "debug-images", "anyhow"]} once_cell = { version = "1.19.0", optional = true } whoami = { version = "1.5.1", optional = true } -serde = { version = "1.0.197", optional = true } -serde_json = { version = "1.0.114", optional = true } + +serde = "1.0.198" +serde_json = "1.0.116" +toml = "0.8.12" +anyhow = "1.0.82" +mockall = "0.12.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] napi = { version = "2.12.6", features = ["serde-json", "napi4", "napi5"] } @@ -48,5 +52,7 @@ jemallocator = { version = "0.3.2", features = ["disable_initial_exec_tls"] } [target.'cfg(windows)'.dependencies] mimalloc = { version = "0.1.25", default-features = false } +[dev-dependencies] + [build-dependencies] napi-build = "2" diff --git a/crates/node-bindings/src/core/filesystem/in_memory_file_system/mod.rs b/crates/node-bindings/src/core/filesystem/in_memory_file_system/mod.rs new file mode 100644 index 00000000000..cd2c8ea15d0 --- /dev/null +++ b/crates/node-bindings/src/core/filesystem/in_memory_file_system/mod.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use parcel_resolver::FileSystem; + +/// In memory implementation of a file-system entry +enum InMemoryFileSystemEntry { + File { contents: String }, + Directory, +} + +/// In memory implementation of the `FileSystem` trait, for testing purpouses. +pub struct InMemoryFileSystem { + files: HashMap, + current_working_directory: PathBuf, +} + +impl InMemoryFileSystem { + /// Change the current working directory. Used for resolving relative paths. + pub fn set_current_working_directory(&mut self, cwd: PathBuf) { + self.current_working_directory = cwd; + } + + /// Create a directory at path. + pub fn create_directory(&mut self, path: impl AsRef) { + self + .files + .insert(path.as_ref().into(), InMemoryFileSystemEntry::Directory); + } + + /// Write a file at path. + pub fn write_file(&mut self, path: impl AsRef, contents: String) { + self.files.insert( + path.as_ref().into(), + InMemoryFileSystemEntry::File { contents }, + ); + } +} + +impl Default for InMemoryFileSystem { + fn default() -> Self { + Self { + files: Default::default(), + current_working_directory: PathBuf::from("/"), + } + } +} + +impl FileSystem for InMemoryFileSystem { + fn canonicalize>( + &self, + path: P, + _cache: &dashmap::DashMap>, + ) -> std::io::Result { + let path = path.as_ref(); + + let mut result = if path.is_absolute() { + vec![] + } else { + self.current_working_directory.components().collect() + }; + + let components = path.components(); + for component in components { + match component { + Component::Prefix(prefix) => { + result = vec![Component::Prefix(prefix)]; + } + Component::RootDir => { + result = vec![Component::RootDir]; + } + Component::CurDir => {} + Component::ParentDir => { + result.pop(); + } + Component::Normal(path) => { + result.push(Component::Normal(path)); + } + } + } + + Ok(PathBuf::from_iter(result)) + } + + fn read_to_string>(&self, path: P) -> std::io::Result { + self.files.get(path.as_ref()).map_or_else( + || { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + )) + }, + |entry| match entry { + InMemoryFileSystemEntry::File { contents } => Ok(contents.clone()), + InMemoryFileSystemEntry::Directory => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is a directory", + )), + }, + ) + } + + fn is_file>(&self, path: P) -> bool { + let file = self.files.get(path.as_ref()); + matches!(file, Some(InMemoryFileSystemEntry::File { .. })) + } + + fn is_dir>(&self, path: P) -> bool { + let file = self.files.get(path.as_ref()); + matches!(file, Some(InMemoryFileSystemEntry::Directory { .. })) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_canonicalize_noop() { + let fs = InMemoryFileSystem::default(); + let path = Path::new("/foo/bar"); + let result = fs.canonicalize(path, &Default::default()).unwrap(); + assert_eq!(result, path); + } + + #[test] + fn test_remove_relative_dots() { + let fs = InMemoryFileSystem::default(); + let result = fs + .canonicalize(Path::new("/foo/./bar"), &Default::default()) + .unwrap(); + assert_eq!(result, PathBuf::from("/foo/bar")); + } + + #[test] + fn test_remove_relative_parent_dots() { + let fs = InMemoryFileSystem::default(); + let result = fs + .canonicalize(Path::new("/foo/./bar/../baz/"), &Default::default()) + .unwrap(); + assert_eq!(result, PathBuf::from("/foo/baz")); + } + + #[test] + fn test_with_cwd() { + let mut fs = InMemoryFileSystem::default(); + fs.set_current_working_directory(PathBuf::from("/other")); + let result = fs + .canonicalize(Path::new("./foo/./bar/../baz/"), &Default::default()) + .unwrap(); + assert_eq!(result, PathBuf::from("/other/foo/baz")); + } + + #[test] + fn test_read_file() { + let mut fs = InMemoryFileSystem::default(); + fs.write_file(PathBuf::from("/foo/bar"), "contents".to_string()); + let result = fs.read_to_string(Path::new("/foo/bar")).unwrap(); + assert_eq!(result, "contents"); + } + + #[test] + fn test_read_file_not_found() { + let fs = InMemoryFileSystem::default(); + let result = fs.read_to_string(Path::new("/foo/bar")); + assert!(result.is_err()); + } + + #[test] + fn test_is_file() { + let mut fs = InMemoryFileSystem::default(); + fs.write_file(PathBuf::from("/foo/bar"), "contents".to_string()); + assert!(fs.is_file(Path::new("/foo/bar"))); + assert!(!fs.is_file(Path::new("/foo"))); + } + + #[test] + fn test_is_dir() { + let mut fs = InMemoryFileSystem::default(); + fs.create_directory(PathBuf::from("/foo")); + assert!(fs.is_dir(Path::new("/foo"))); + assert!(!fs.is_dir(Path::new("/foo/bar"))); + } +} diff --git a/crates/node-bindings/src/core/filesystem/js_delegate_file_system/mod.rs b/crates/node-bindings/src/core/filesystem/js_delegate_file_system/mod.rs new file mode 100644 index 00000000000..c0eb90c0f70 --- /dev/null +++ b/crates/node-bindings/src/core/filesystem/js_delegate_file_system/mod.rs @@ -0,0 +1,108 @@ +use std::path::Path; +use std::path::PathBuf; +use std::rc::Rc; + +use dashmap::DashMap; +use napi::bindgen_prelude::FromNapiValue; +use napi::Env; +use napi::JsObject; +use parcel_resolver::FileSystem; + +use crate::core::requests::call_method; + +/// An implementation of `FileSystem` that delegates calls to a `JsObject`. +/// +/// This is going to be very slow at runtime due to the overhead of converting +/// between rust and JS types. +pub struct JSDelegateFileSystem { + env: Rc, + js_delegate: JsObject, +} + +impl JSDelegateFileSystem { + pub fn new(env: Rc, js_delegate: JsObject) -> Self { + Self { env, js_delegate } + } +} + +// Convert arbitrary errors to io errors. This is wrong; the `FileSystem` trait should use +// `anyhow::Result` +fn run_with_errors(block: impl FnOnce() -> anyhow::Result) -> Result { + let result = block(); + result.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string())) +} + +impl FileSystem for JSDelegateFileSystem { + fn canonicalize>( + &self, + path: P, + _cache: &DashMap>, + ) -> std::io::Result { + run_with_errors(|| { + let path = path.as_ref().to_str().unwrap(); + let js_path = self.env.create_string(path)?; + let result = call_method( + &self.env, + &self.js_delegate, + "canonicalize", + &[&js_path.into_unknown()], + )?; + let result_string = result.coerce_to_string()?; + let result_string = result_string.into_utf8()?.as_str()?.to_string(); + Ok(PathBuf::from(result_string)) + }) + } + + fn read_to_string>(&self, path: P) -> std::io::Result { + run_with_errors(|| { + let path = path.as_ref().to_str().unwrap(); + let js_path = self.env.create_string(path)?; + let result = call_method( + &self.env, + &self.js_delegate, + "readFileSync", + &[&js_path.into_unknown()], + )?; + // Using buffer hopefully avoids a copy + let buffer = napi::JsBuffer::from_unknown(result)?; + let buffer = buffer.into_value()?; + let buffer: &[u8] = buffer.as_ref(); + let result = String::from_utf8(buffer.to_vec())?; + Ok(result) + }) + } + + fn is_file>(&self, path: P) -> bool { + run_with_errors(|| { + let path = path.as_ref().to_str().unwrap(); + let js_path = self.env.create_string(path)?; + let result = call_method( + &self.env, + &self.js_delegate, + "isFile", + &[&js_path.into_unknown()], + )?; + let result_bool = result.coerce_to_bool()?.get_value()?; + Ok(result_bool) + // TODO error handling is messed up here; this should return `Result + }) + .unwrap_or(false) + } + + fn is_dir>(&self, path: P) -> bool { + run_with_errors(|| { + let path = path.as_ref().to_str().unwrap(); + let js_path = self.env.create_string(path)?; + let result = call_method( + &self.env, + &self.js_delegate, + "isDir", + &[&js_path.into_unknown()], + )?; + let result_bool = result.coerce_to_bool()?.get_value()?; + Ok(result_bool) + // TODO error handling is messed up here; this should return `Result + }) + .unwrap_or(false) + } +} diff --git a/crates/node-bindings/src/core/filesystem/mod.rs b/crates/node-bindings/src/core/filesystem/mod.rs new file mode 100644 index 00000000000..94686e69686 --- /dev/null +++ b/crates/node-bindings/src/core/filesystem/mod.rs @@ -0,0 +1,6 @@ +/// FileSystem implementation that delegates calls to a JS object +pub(crate) mod js_delegate_file_system; + +/// In-memory file-system for testing +#[cfg(test)] +pub(crate) mod in_memory_file_system; diff --git a/crates/node-bindings/src/core/mod.rs b/crates/node-bindings/src/core/mod.rs new file mode 100644 index 00000000000..7abd3086ed8 --- /dev/null +++ b/crates/node-bindings/src/core/mod.rs @@ -0,0 +1,5 @@ +//! Core re-implementation in Rust + +mod filesystem; +/// Request types and run functions +mod requests; diff --git a/crates/node-bindings/src/core/requests/config_request/mod.rs b/crates/node-bindings/src/core/requests/config_request/mod.rs new file mode 100644 index 00000000000..430da2c5e7a --- /dev/null +++ b/crates/node-bindings/src/core/requests/config_request/mod.rs @@ -0,0 +1,382 @@ +//! Implements the `ConfigRequest` execution in rust. +//! +//! This is a rewrite of the `packages/core/core/src/requests/ConfigRequest.js` +//! file. +use std::path::Path; + +use napi_derive::napi; +use parcel_resolver::FileSystem; + +use crate::core::requests::request_api::RequestApi; + +pub type ProjectPath = String; + +pub type InternalGlob = String; + +#[napi(object)] +pub struct ConfigKeyChange { + pub file_path: ProjectPath, + pub config_key: String, +} + +#[napi(object)] +#[derive(Clone, PartialEq)] +pub struct InternalFileCreateInvalidation { + // file + pub file_path: Option, + // glob + pub glob: Option, + // file above + pub file_name: Option, + pub above_file_path: Option, +} + +#[napi(object)] +pub struct ConfigRequest { + pub id: String, + // Set<...> + pub invalidate_on_file_change: Vec, + pub invalidate_on_config_key_change: Vec, + pub invalidate_on_file_create: Vec, + // Set<...> + pub invalidate_on_env_change: Vec, + // Set<...> + pub invalidate_on_option_change: Vec, + pub invalidate_on_startup: bool, + pub invalidate_on_build: bool, +} + +/// Read a TOML or JSON configuration file as a value and return it +fn read_config(input_fs: &impl FileSystem, config_path: &Path) -> napi::Result { + let contents = input_fs.read_to_string(config_path)?; + let Some(extension) = config_path.extension().map(|ext| ext.to_str()).flatten() else { + // TODO: current JS behaviour might be to read it as JSON + return Err(napi::Error::from_reason( + "Configuration file has no extension or extension isn't unicode", + )); + }; + let contents = match extension { + "json" => serde_json::from_str(&contents) + .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string())), + "toml" => toml::from_str(&contents) + .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string())), + extension => Err(napi::Error::from_reason(format!( + "Invalid configuration format: {}", + extension + ))), + }?; + + Ok(contents) +} + +/// Hash a `serde_json::Value`. This does not do special handling yet, but +/// it should match the parcel utils implementation. That implementation +fn hash_serde_value(value: &serde_json::Value) -> anyhow::Result { + // TODO: this doesn't handle sorting keys + Ok(crate::hash::hash_string(serde_json::to_string(value)?)) +} + +/// Hash a certain key in a configuration file. +fn get_config_key_content_hash( + config_key: &str, + input_fs: &impl FileSystem, + project_root: &str, + file_path: &str, +) -> napi::Result { + let mut path = Path::new(project_root).to_path_buf(); + path.push(file_path); + + let contents = read_config(input_fs, &path)?; + + let Some(config_value) = contents.get(config_key) else { + // TODO: need to try to match behaviour of `ConfigRequest.js` + return Ok("".to_string()); + }; + + let content_hash = + hash_serde_value(config_value).map_err(|err| napi::Error::from_reason(err.to_string()))?; + Ok(content_hash) +} + +/// A config request triggers several invalidations to be tracked. +/// +/// This is ported to rust to serve as an example of Parcel requests being ported. +pub fn run_config_request( + config_request: &ConfigRequest, + api: &impl RequestApi, + input_fs: &impl FileSystem, + project_root: &str, +) -> napi::Result<()> { + for file_path in &config_request.invalidate_on_file_change { + let file_path = Path::new(file_path); + api.invalidate_on_file_update(file_path)?; + api.invalidate_on_file_delete(file_path)?; + } + + for config_key_change in &config_request.invalidate_on_config_key_change { + let content_hash = get_config_key_content_hash( + &config_key_change.config_key, + input_fs, + &project_root, + &config_key_change.file_path, + )?; + api.invalidate_on_config_key_change( + Path::new(&config_key_change.file_path), + &config_key_change.config_key, + &content_hash, + )?; + } + + for invalidation in &config_request.invalidate_on_file_create { + api.invalidate_on_file_create(invalidation)?; + } + + for env in &config_request.invalidate_on_env_change { + api.invalidate_on_env_change(env)?; + } + + for option in &config_request.invalidate_on_option_change { + api.invalidate_on_option_change(option)?; + } + + if config_request.invalidate_on_startup { + api.invalidate_on_startup()?; + } + + if config_request.invalidate_on_build { + api.invalidate_on_build()?; + } + + Ok(()) +} + +#[napi(object)] +struct RequestOptions {} + +#[cfg(test)] +mod test { + use parcel_resolver::OsFileSystem; + + use super::*; + use crate::core::filesystem::in_memory_file_system::InMemoryFileSystem; + use crate::core::requests::config_request::run_config_request; + use crate::core::requests::request_api::MockRequestApi; + + #[test] + fn test_run_empty_config_request_does_nothing() { + let config_request = ConfigRequest { + id: "".to_string(), + invalidate_on_file_change: vec![], + invalidate_on_config_key_change: vec![], + invalidate_on_file_create: vec![], + invalidate_on_env_change: vec![], + invalidate_on_option_change: vec![], + invalidate_on_startup: false, + invalidate_on_build: false, + }; + // The mock will panic if it's called with no mock set + let request_api = MockRequestApi::new(); + let file_system = OsFileSystem::default(); + let project_root = ""; + + run_config_request(&config_request, &request_api, &file_system, project_root).unwrap(); + } + + #[test] + fn test_run_config_request_with_invalidate_on_file_change() { + let config_request = ConfigRequest { + id: "".to_string(), + invalidate_on_file_change: vec!["path1".to_string(), "path2".to_string()], + invalidate_on_config_key_change: vec![], + invalidate_on_file_create: vec![], + invalidate_on_env_change: vec![], + invalidate_on_option_change: vec![], + invalidate_on_startup: false, + invalidate_on_build: false, + }; + // The mock will panic if it's called with no mock set + let mut request_api = MockRequestApi::new(); + let file_system = OsFileSystem::default(); + let project_root = ""; + + request_api + .expect_invalidate_on_file_update() + .times(2) + .withf(|p| p.to_str().unwrap() == "path1" || p.to_str().unwrap() == "path2") + .returning(|_| Ok(())); + request_api + .expect_invalidate_on_file_delete() + .times(2) + .withf(|p| p.to_str().unwrap() == "path1" || p.to_str().unwrap() == "path2") + .returning(|_| Ok(())); + + run_config_request(&config_request, &request_api, &file_system, project_root).unwrap(); + } + + #[test] + fn test_run_config_request_with_invalidate_on_file_create() { + let config_request = ConfigRequest { + id: "".to_string(), + invalidate_on_file_change: vec![], + invalidate_on_config_key_change: vec![], + invalidate_on_file_create: vec![InternalFileCreateInvalidation { + file_path: Some("path1".to_string()), + glob: None, + file_name: None, + above_file_path: None, + }], + invalidate_on_env_change: vec![], + invalidate_on_option_change: vec![], + invalidate_on_startup: false, + invalidate_on_build: false, + }; + // The mock will panic if it's called with no mock set + let mut request_api = MockRequestApi::new(); + let file_system = OsFileSystem::default(); + let project_root = ""; + + request_api + .expect_invalidate_on_file_create() + .times(1) + .withf(|p| { + *p == InternalFileCreateInvalidation { + file_path: Some("path1".to_string()), + glob: None, + file_name: None, + above_file_path: None, + } + }) + .returning(|_| Ok(())); + + run_config_request(&config_request, &request_api, &file_system, project_root).unwrap(); + } + + #[test] + fn test_run_config_request_with_invalidate_on_env_change() { + let config_request = ConfigRequest { + id: "".to_string(), + invalidate_on_file_change: vec![], + invalidate_on_config_key_change: vec![], + invalidate_on_file_create: vec![], + invalidate_on_env_change: vec!["env1".to_string()], + invalidate_on_option_change: vec![], + invalidate_on_startup: false, + invalidate_on_build: false, + }; + // The mock will panic if it's called with no mock set + let mut request_api = MockRequestApi::new(); + let file_system = OsFileSystem::default(); + let project_root = ""; + + request_api + .expect_invalidate_on_env_change() + .times(1) + .withf(|p| p == "env1") + .returning(|_| Ok(())); + + run_config_request(&config_request, &request_api, &file_system, project_root).unwrap(); + } + + #[test] + fn test_run_config_request_with_invalidate_on_option_change() { + let config_request = ConfigRequest { + id: "".to_string(), + invalidate_on_file_change: vec![], + invalidate_on_config_key_change: vec![], + invalidate_on_file_create: vec![], + invalidate_on_env_change: vec![], + invalidate_on_option_change: vec!["option1".to_string()], + invalidate_on_startup: false, + invalidate_on_build: false, + }; + // The mock will panic if it's called with no mock set + let mut request_api = MockRequestApi::new(); + let file_system = OsFileSystem::default(); + let project_root = ""; + + request_api + .expect_invalidate_on_option_change() + .times(1) + .withf(|p| p == "option1") + .returning(|_| Ok(())); + + run_config_request(&config_request, &request_api, &file_system, project_root).unwrap(); + } + + #[test] + fn test_run_config_request_with_invalidate_on_startup() { + let config_request = ConfigRequest { + id: "".to_string(), + invalidate_on_file_change: vec![], + invalidate_on_config_key_change: vec![], + invalidate_on_file_create: vec![], + invalidate_on_env_change: vec![], + invalidate_on_option_change: vec![], + invalidate_on_startup: true, + invalidate_on_build: false, + }; + // The mock will panic if it's called with no mock set + let mut request_api = MockRequestApi::new(); + let file_system = OsFileSystem::default(); + let project_root = ""; + + request_api + .expect_invalidate_on_startup() + .times(1) + .returning(|| Ok(())); + + run_config_request(&config_request, &request_api, &file_system, project_root).unwrap(); + } + + #[test] + fn test_run_config_request_with_invalidate_on_build() { + let config_request = ConfigRequest { + id: "".to_string(), + invalidate_on_file_change: vec![], + invalidate_on_config_key_change: vec![], + invalidate_on_file_create: vec![], + invalidate_on_env_change: vec![], + invalidate_on_option_change: vec![], + invalidate_on_startup: false, + invalidate_on_build: true, + }; + // The mock will panic if it's called with no mock set + let mut request_api = MockRequestApi::new(); + let file_system = OsFileSystem::default(); + let project_root = ""; + + request_api + .expect_invalidate_on_build() + .times(1) + .returning(|| Ok(())); + + run_config_request(&config_request, &request_api, &file_system, project_root).unwrap(); + } + + #[test] + fn test_read_json_config() { + let mut file_system = InMemoryFileSystem::default(); + let config_path = Path::new("/config.json"); + file_system.write_file(config_path, String::from(r#"{"key": "value"}"#)); + + let contents = read_config(&file_system, config_path).unwrap(); + assert_eq!(contents, serde_json::json!({"key": "value"})); + } + + #[test] + fn test_read_toml_config() { + let mut file_system = InMemoryFileSystem::default(); + let config_path = Path::new("/config.toml"); + file_system.write_file(config_path, String::from(r#"key = "value""#)); + + let contents = read_config(&file_system, config_path).unwrap(); + assert_eq!(contents, serde_json::json!({"key": "value"})); + } + + #[test] + fn test_hash_serde_value() { + let value = serde_json::json!({"key": "value", "key2": "value2"}); + let hash = hash_serde_value(&value).unwrap(); + assert_eq!(hash, "17666ca1af93de5d".to_string()); + } +} diff --git a/crates/node-bindings/src/core/requests/mod.rs b/crates/node-bindings/src/core/requests/mod.rs new file mode 100644 index 00000000000..51a3dae94b4 --- /dev/null +++ b/crates/node-bindings/src/core/requests/mod.rs @@ -0,0 +1,111 @@ +use std::rc::Rc; + +use napi::bindgen_prelude::FromNapiValue; +use napi::Env; +use napi::JsFunction; +use napi::JsObject; +use napi::JsString; +use napi::JsUnknown; +use napi::NapiRaw; +use napi_derive::napi; + +use crate::core::filesystem::js_delegate_file_system::JSDelegateFileSystem; +use crate::core::requests::config_request::ConfigRequest; +use crate::core::requests::request_api::js_request_api::JSRequestApi; + +mod config_request; +mod request_api; + +/// Get an object field as a JSFunction. Will error out if the field is not present or isn't an +/// instance of the global `"Function"`. +/// +/// ## Safety +/// Uses raw NAPI casts, but checks that object field is a function +pub fn get_function(env: &Env, js_object: &JsObject, field_name: &str) -> napi::Result { + let Some(method): Option = js_object.get(field_name)? else { + return Err(napi::Error::from_reason(format!( + "[napi] Method not found: {}", + field_name + ))); + }; + let function_class: JsUnknown = env.get_global()?.get_named_property("Function")?; + let is_function = method.instanceof(function_class)?; + if !is_function { + return Err(napi::Error::from_reason(format!( + "[napi] Method is not a function: {}", + field_name + ))); + } + + let method_fn = unsafe { JsFunction::from_napi_value(env.raw(), method.raw()) }?; + Ok(method_fn) +} + +/// Call a method on an object with a set of arguments. +/// +/// Will error out if the method doesn't exist or if the field is not a function. +/// +/// This does some redundant work ; so you may want to call `get_function` +/// directly if calling a method on a loop. +/// +/// The function takes `JsUnknown` references so any type can be used as an +/// argument. +/// +/// ## Safety +/// Uses raw NAPI casts, but checks that object field is a function +/// +/// ## Example +/// ```skip +/// let string_parameter = env.create_string(path.to_str().unwrap())?; +/// let args = [&string_parameter.into_unknown()]; +/// let field_name = "method"; +/// +/// call_method(&self.env, &js_object, field_name, &args)?; +/// ``` +pub fn call_method( + env: &Env, + js_object: &JsObject, + field_name: &str, + args: &[&JsUnknown], +) -> napi::Result { + let method_fn = get_function(env, js_object, field_name)?; + let result = method_fn.call(Some(&js_object), &args)?; + Ok(result) +} + +/// JavaScript API for running a config request. +/// At the moment the request fields themselves will be copied on call. +/// +/// This is not efficient but can be worked around when it becomes an issue. +/// +/// This should have exhaustive unit-tests on `packages/core/core/test/requests/ConfigRequest.test.js`. +#[napi] +fn napi_run_config_request( + env: Env, + config_request: ConfigRequest, + api: JsObject, + options: JsObject, +) -> napi::Result<()> { + // Technically we could move `env` to JSRequestAPI but in order to + // be able to use env on more places we rc it. + let env = Rc::new(env); + let api = JSRequestApi::new(env.clone(), api); + let input_fs = options.get("inputFS")?; + let Some(input_fs) = input_fs.map(|input_fs| JSDelegateFileSystem::new(env, input_fs)) else { + // We need to make the `FileSystem` trait object-safe so we can use dynamic + // dispatch. + return Err(napi::Error::from_reason( + "[napi] Missing required inputFS options field", + )); + }; + let Some(project_root): Option = options.get("projectRoot")? else { + return Err(napi::Error::from_reason( + "[napi] Missing required projectRoot options field", + )); + }; + // TODO: what if the string is UTF16 or latin? + let project_root = project_root.into_utf8()?; + let project_root = project_root.as_str()?; + + config_request::run_config_request(&config_request, &api, &input_fs, project_root) +} diff --git a/crates/node-bindings/src/core/requests/request_api/js_request_api.rs b/crates/node-bindings/src/core/requests/request_api/js_request_api.rs new file mode 100644 index 00000000000..a6a76356c7e --- /dev/null +++ b/crates/node-bindings/src/core/requests/request_api/js_request_api.rs @@ -0,0 +1,123 @@ +use std::path::Path; +use std::rc::Rc; + +use napi::Env; +use napi::JsObject; +use napi::JsUnknown; + +use crate::core::requests::call_method; +use crate::core::requests::config_request::InternalFileCreateInvalidation; +use crate::core::requests::request_api::RequestApi; +use crate::core::requests::request_api::RequestApiResult; + +pub struct JSRequestApi { + // TODO: Make sure it is safe to hold the environment like this + env: Rc, + js_object: JsObject, +} + +impl JSRequestApi { + pub fn new(env: Rc, js_object: JsObject) -> Self { + Self { env, js_object } + } +} + +impl RequestApi for JSRequestApi { + fn invalidate_on_file_update(&self, path: &Path) -> RequestApiResult<()> { + let path_js_string = self.env.create_string(path.to_str().unwrap())?; + call_method( + &self.env, + &self.js_object, + "invalidateOnFileUpdate", + &[&path_js_string.into_unknown()], + )?; + Ok(()) + } + + fn invalidate_on_file_delete(&self, path: &Path) -> RequestApiResult<()> { + let path_js_string = self.env.create_string(path.to_str().unwrap())?; + call_method( + &self.env, + &self.js_object, + "invalidateOnFileDelete", + &[&path_js_string.into_unknown()], + )?; + Ok(()) + } + + fn invalidate_on_file_create( + &self, + invalidation: &InternalFileCreateInvalidation, + ) -> RequestApiResult<()> { + use napi::bindgen_prelude::ToNapiValue; + use napi::NapiValue; + + let js_invalidation = unsafe { + JsUnknown::from_raw( + self.env.raw(), + ToNapiValue::to_napi_value(self.env.raw(), invalidation.clone())?, + ) + }?; + call_method( + &self.env, + &self.js_object, + "invalidateOnFileCreate", + &[&js_invalidation], + )?; + Ok(()) + } + + fn invalidate_on_config_key_change( + &self, + file_path: &Path, + config_key: &str, + content_hash: &str, + ) -> RequestApiResult<()> { + let path_js_string = self.env.create_string(file_path.to_str().unwrap())?; + let config_key_js_string = self.env.create_string(config_key)?; + let content_hash_js_string = self.env.create_string(content_hash)?; + call_method( + &self.env, + &self.js_object, + "invalidateOnConfigKeyChange", + &[ + &path_js_string.into_unknown(), + &config_key_js_string.into_unknown(), + &content_hash_js_string.into_unknown(), + ], + )?; + Ok(()) + } + + fn invalidate_on_startup(&self) -> RequestApiResult<()> { + call_method(&self.env, &self.js_object, "invalidateOnStartup", &[])?; + Ok(()) + } + + fn invalidate_on_build(&self) -> RequestApiResult<()> { + call_method(&self.env, &self.js_object, "invalidateOnBuild", &[])?; + Ok(()) + } + + fn invalidate_on_env_change(&self, env_change: &str) -> RequestApiResult<()> { + let env_change_js_string = self.env.create_string(env_change)?; + call_method( + &self.env, + &self.js_object, + "invalidateOnEnvChange", + &[&env_change_js_string.into_unknown()], + )?; + Ok(()) + } + + fn invalidate_on_option_change(&self, option: &str) -> RequestApiResult<()> { + let option_js_string = self.env.create_string(option)?; + call_method( + &self.env, + &self.js_object, + "invalidateOnOptionChange", + &[&option_js_string.into_unknown()], + )?; + Ok(()) + } +} diff --git a/crates/node-bindings/src/core/requests/request_api/mod.rs b/crates/node-bindings/src/core/requests/request_api/mod.rs new file mode 100644 index 00000000000..ddaa2eb4894 --- /dev/null +++ b/crates/node-bindings/src/core/requests/request_api/mod.rs @@ -0,0 +1,56 @@ +use std::path::Path; + +use mockall::automock; + +use crate::core::requests::config_request::InternalFileCreateInvalidation; + +pub mod js_request_api; + +// TODO: Move this into an associated type of the struct +pub type RequestApiResult = napi::Result; + +/// RequestTracker API with the requests. +/// +/// We will implement these as we need them. While working on integrating +/// with the existing JavaScript codebase, `JSRequestApi` will be used and will +/// delegate these calls into the JavaScript implementation. +/// +/// `mockall::automock` also generates a `MockRequestApi` to be used internally. +#[automock] +pub trait RequestApi { + /// Invalidate the current request when a file at `path` is updated + fn invalidate_on_file_update(&self, path: &Path) -> RequestApiResult<()>; + /// Invalidate the current request when a file at `path` is deleted + fn invalidate_on_file_delete(&self, path: &Path) -> RequestApiResult<()>; + /// Invalidate the current request when a file at `path` is created + fn invalidate_on_file_create( + &self, + path: &InternalFileCreateInvalidation, + ) -> RequestApiResult<()>; + /// Invalidate the current request when a config key from the configuration + /// file at path is changed + fn invalidate_on_config_key_change( + &self, + file_path: &Path, + config_key: &str, + content_hash: &str, + ) -> RequestApiResult<()>; + /// Invalidate the current request on start-up + fn invalidate_on_startup(&self) -> RequestApiResult<()>; + /// Invalidate the current request on builds + fn invalidate_on_build(&self) -> RequestApiResult<()>; + /// Invalidate the current request on environment variable changes + fn invalidate_on_env_change(&self, env_change: &str) -> RequestApiResult<()>; + /// Invalidate the current request on option changes + fn invalidate_on_option_change(&self, option: &str) -> RequestApiResult<()>; + + // Missing functions: + // fn getInvalidations() -> Vec; + // fn store_result(result: RequestResult, cacheKey: &str); + // fn get_request_result(contentKey: &str); + // fn getPreviousResult(ifMatch: string); + // fn getSubRequests() -> Vec; + // fn getInvalidSubRequests() -> Vec; + // fn canSkipSubrequest(content_key: &str) -> bool; + // fn runRequest(subRequest: Request, opts?: RunRequestOpts, ) => Promise, +} diff --git a/crates/node-bindings/src/lib.rs b/crates/node-bindings/src/lib.rs index 681f9e82ed7..b494592d7e0 100644 --- a/crates/node-bindings/src/lib.rs +++ b/crates/node-bindings/src/lib.rs @@ -15,6 +15,7 @@ static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; #[global_allocator] static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; +mod core; #[cfg(not(target_arch = "wasm32"))] mod fs_search; mod hash; diff --git a/crates/node-bindings/src/resolver.rs b/crates/node-bindings/src/resolver.rs index 182171fb276..5e9ea393de0 100644 --- a/crates/node-bindings/src/resolver.rs +++ b/crates/node-bindings/src/resolver.rs @@ -57,7 +57,7 @@ pub struct JsResolverOptions { pub typescript: Option, } -struct FunctionRef { +pub struct FunctionRef { env: Env, reference: Ref<()>, } @@ -85,11 +85,11 @@ impl Drop for FunctionRef { } } -struct JsFileSystem { - canonicalize: FunctionRef, - read: FunctionRef, - is_file: FunctionRef, - is_dir: FunctionRef, +pub struct JsFileSystem { + pub canonicalize: FunctionRef, + pub read: FunctionRef, + pub is_file: FunctionRef, + pub is_dir: FunctionRef, } impl FileSystem for JsFileSystem { diff --git a/packages/core/core/src/requests/ConfigRequest.js b/packages/core/core/src/requests/ConfigRequest.js index de41ce0140c..a9e1384a8d0 100644 --- a/packages/core/core/src/requests/ConfigRequest.js +++ b/packages/core/core/src/requests/ConfigRequest.js @@ -17,6 +17,7 @@ import type { import type {LoadedPlugin} from '../ParcelConfig'; import type {RunAPI} from '../RequestTracker'; import type {ProjectPath} from '../projectPath'; +import {napiRunConfigRequest} from '@parcel/rust'; import {serializeRaw} from '../serializer.js'; import {PluginLogger} from '@parcel/logger'; @@ -30,6 +31,7 @@ import {PluginTracer} from '@parcel/profiler'; import {requestTypes} from '../RequestTracker'; import {fromProjectPath, fromProjectPathRelative} from '../projectPath'; import {createBuildCache} from '../buildCache'; +import {getFeatureFlag} from '@parcel/feature-flags'; export type PluginWithLoadConfig = { loadConfig?: ({| @@ -161,6 +163,7 @@ export async function runConfigRequest( invalidateOnConfigKeyChange.length === 0 && invalidateOnFileCreate.length === 0 && invalidateOnOptionChange.size === 0 && + invalidateOnEnvChange.size === 0 && !invalidateOnStartup && !invalidateOnBuild ) { @@ -171,6 +174,30 @@ export async function runConfigRequest( id: 'config_request:' + configRequest.id, type: requestTypes.config_request, run: async ({api, options}) => { + if (getFeatureFlag('parcelV3')) { + return napiRunConfigRequest( + { + id: configRequest.id, + invalidateOnBuild: configRequest.invalidateOnBuild, + invalidateOnConfigKeyChange: + configRequest.invalidateOnConfigKeyChange, + invalidateOnFileCreate: configRequest.invalidateOnFileCreate, + invalidateOnEnvChange: Array.from( + configRequest.invalidateOnEnvChange, + ), + invalidateOnOptionChange: Array.from( + configRequest.invalidateOnOptionChange, + ), + invalidateOnStartup: configRequest.invalidateOnStartup, + invalidateOnFileChange: Array.from( + configRequest.invalidateOnFileChange, + ), + }, + api, + options, + ); + } + for (let filePath of invalidateOnFileChange) { api.invalidateOnFileUpdate(filePath); api.invalidateOnFileDelete(filePath); diff --git a/packages/core/core/test/requests/ConfigRequest.test.js b/packages/core/core/test/requests/ConfigRequest.test.js new file mode 100644 index 00000000000..c91964dba7d --- /dev/null +++ b/packages/core/core/test/requests/ConfigRequest.test.js @@ -0,0 +1,326 @@ +// @flow strict-local + +import WorkerFarm from '@parcel/workers'; +import path from 'path'; +import assert from 'assert'; +import sinon from 'sinon'; +import {DEFAULT_FEATURE_FLAGS, setFeatureFlags} from '@parcel/feature-flags'; +import {MemoryFS} from '@parcel/fs'; +import {hashString} from '@parcel/rust'; + +import type {ConfigRequest} from '../../src/requests/ConfigRequest'; +import type {RunAPI} from '../../src/RequestTracker'; +import {runConfigRequest} from '../../src/requests/ConfigRequest'; +import {toProjectPath} from '../../src/projectPath'; + +// $FlowFixMe unclear-type forgive me +const mockCast = (f: any): any => f; + +async function assertThrows(block: () => Promise) { + let error: Error | null = null; + try { + await block(); + } catch (e) { + error = e; + } + assert(error != null, 'Function finished without errors'); + return error; +} + +describe('ConfigRequest tests', () => { + const projectRoot = 'project_root'; + const farm = new WorkerFarm({ + workerPath: require.resolve('../../src/worker.js'), + maxConcurrentWorkers: 1, + }); + let fs = new MemoryFS(farm); + beforeEach(() => { + fs = new MemoryFS(farm); + }); + + const getMockRunApi = ( + options: mixed = {projectRoot, inputFS: fs}, + ): RunAPI => { + const mockRunApi = { + storeResult: sinon.spy(), + canSkipSubrequest: sinon.spy(), + invalidateOnFileCreate: sinon.spy(), + getInvalidSubRequests: sinon.spy(), + getInvalidations: sinon.spy(), + getPreviousResult: sinon.spy(), + getRequestResult: sinon.spy(), + getSubRequests: sinon.spy(), + invalidateOnBuild: sinon.spy(), + invalidateOnConfigKeyChange: sinon.spy(), + invalidateOnEnvChange: sinon.spy(), + invalidateOnFileDelete: sinon.spy(), + invalidateOnFileUpdate: sinon.spy(), + invalidateOnOptionChange: sinon.spy(), + invalidateOnStartup: sinon.spy(), + runRequest: sinon.spy(request => { + return request.run({ + api: mockRunApi, + options, + }); + }), + }; + return mockRunApi; + }; + + const baseRequest: ConfigRequest = { + id: 'config_request_test', + invalidateOnBuild: false, + invalidateOnConfigKeyChange: [], + invalidateOnFileCreate: [], + invalidateOnEnvChange: new Set(), + invalidateOnOptionChange: new Set(), + invalidateOnStartup: false, + invalidateOnFileChange: new Set(), + }; + + ['rust', 'js'].forEach(backend => { + describe(`${backend} backed`, () => { + beforeEach(() => { + setFeatureFlags({ + ...DEFAULT_FEATURE_FLAGS, + parcelV3: backend === 'rust', + }); + }); + + it('can execute a config request', async () => { + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + }); + }); + + if (backend === 'rust') { + // Adding this here mostly to prove that the rust backend is actually running on + // this suite + + it('errors out if the options are missing projectRoot', async () => { + const mockRunApi = getMockRunApi({ + inputFS: fs, + }); + const error = await assertThrows(async () => { + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnStartup: true, + }); + }); + assert.equal( + error?.message, + '[napi] Missing required projectRoot options field', + ); + }); + + it('errors out if the options are missing inputFS', async () => { + const mockRunApi = getMockRunApi({ + projectRoot, + }); + const error = await assertThrows(async () => { + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnStartup: true, + }); + }); + assert.equal( + error?.message, + '[napi] Missing required inputFS options field', + ); + }); + } + + it('forwards "invalidateOnFileChange" calls to runAPI', async () => { + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnFileChange: new Set([ + toProjectPath(projectRoot, 'path1'), + toProjectPath(projectRoot, 'path2'), + ]), + }); + + assert( + mockCast(mockRunApi.invalidateOnFileUpdate).called, + 'Invalidate was called', + ); + assert( + mockCast(mockRunApi.invalidateOnFileUpdate).calledWith('path1'), + 'Invalidate was called with path1', + ); + assert( + mockCast(mockRunApi.invalidateOnFileUpdate).calledWith('path2'), + 'Invalidate was called with path2', + ); + assert( + mockCast(mockRunApi.invalidateOnFileDelete).calledWith('path1'), + 'Invalidate was called with path1', + ); + assert( + mockCast(mockRunApi.invalidateOnFileDelete).calledWith('path2'), + 'Invalidate was called with path2', + ); + }); + + it('forwards "invalidateOnFileCreate" calls to runAPI', async () => { + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnFileCreate: [ + {filePath: toProjectPath(projectRoot, 'filePath')}, + {glob: toProjectPath(projectRoot, 'glob')}, + { + fileName: 'package.json', + aboveFilePath: toProjectPath(projectRoot, 'fileAbove'), + }, + ], + }); + + assert( + mockCast(mockRunApi.invalidateOnFileCreate).called, + 'Invalidate was called', + ); + assert( + mockCast(mockRunApi.invalidateOnFileCreate).calledWithMatch({ + filePath: 'filePath', + }), + 'Invalidate was called for path', + ); + assert( + mockCast(mockRunApi.invalidateOnFileCreate).calledWithMatch({ + glob: 'glob', + }), + 'Invalidate was called for glob', + ); + assert( + mockCast(mockRunApi.invalidateOnFileCreate).calledWithMatch({ + fileName: 'package.json', + aboveFilePath: 'fileAbove', + }), + 'Invalidate was called for fileAbove', + ); + }); + + it('forwards "invalidateOnEnvChange" calls to runAPI', async () => { + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnEnvChange: new Set(['env1', 'env2']), + }); + + assert( + mockCast(mockRunApi.invalidateOnEnvChange).called, + 'Invalidate was called', + ); + assert( + mockCast(mockRunApi.invalidateOnEnvChange).calledWithMatch('env1'), + 'Invalidate was called for env1', + ); + assert( + mockCast(mockRunApi.invalidateOnEnvChange).calledWithMatch('env2'), + 'Invalidate was called for env1', + ); + }); + + it('forwards "invalidateOnOptionChange" calls to runAPI', async () => { + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnOptionChange: new Set(['option1', 'option2']), + }); + + assert( + mockCast(mockRunApi.invalidateOnOptionChange).called, + 'Invalidate was called', + ); + assert( + mockCast(mockRunApi.invalidateOnOptionChange).calledWithMatch( + 'option1', + ), + 'Invalidate was called for option1', + ); + assert( + mockCast(mockRunApi.invalidateOnOptionChange).calledWithMatch( + 'option2', + ), + 'Invalidate was called for option2', + ); + }); + + it('forwards "invalidateOnStartup" calls to runAPI', async () => { + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnStartup: true, + }); + + assert( + mockCast(mockRunApi.invalidateOnStartup).called, + 'Invalidate was called', + ); + }); + + it('forwards "invalidateOnBuild" calls to runAPI', async () => { + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnBuild: true, + }); + + assert( + mockCast(mockRunApi.invalidateOnBuild).called, + 'Invalidate was called', + ); + }); + + it('forwards "invalidateOnConfigKeyChange" calls to runAPI', async () => { + await fs.mkdirp('/project_root'); + await fs.writeFile( + '/project_root/config.json', + JSON.stringify({key1: 'value1'}), + ); + sinon.spy(fs, 'readFile'); + sinon.spy(fs, 'readFileSync'); + const mockRunApi = getMockRunApi(); + await runConfigRequest(mockRunApi, { + ...baseRequest, + invalidateOnConfigKeyChange: [ + { + configKey: 'key1', + filePath: toProjectPath( + projectRoot, + path.join('project_root', 'config.json'), + ), + }, + ], + }); + + if (backend === 'rust') { + const fsCall = mockCast(fs).readFileSync.getCall(0); + assert.deepEqual( + fsCall?.args, + [path.join('project_root', 'config.json')], + 'readFile was called', + ); + } else { + const fsCall = mockCast(fs).readFile.getCall(0); + assert.deepEqual( + fsCall?.args, + [path.join('project_root', 'config.json'), 'utf8'], + 'readFile was called', + ); + } + + const call = mockCast(mockRunApi.invalidateOnConfigKeyChange).getCall( + 0, + ); + assert.deepEqual( + call.args, + ['config.json', 'key1', hashString('"value1"')], + 'Invalidate was called for key1', + ); + }); + }); + }); +}); diff --git a/packages/core/core/test/test-utils.js b/packages/core/core/test/test-utils.js index 37784a57837..21ca21969ae 100644 --- a/packages/core/core/test/test-utils.js +++ b/packages/core/core/test/test-utils.js @@ -55,6 +55,7 @@ export const DEFAULT_OPTIONS: ParcelOptions = { featureFlags: { exampleFeature: false, configKeyInvalidation: false, + parcelV3: false, dfsFasterRefactor: false, }, }; diff --git a/packages/core/feature-flags/src/index.js b/packages/core/feature-flags/src/index.js index 5835b8cb686..8e9d6612eae 100644 --- a/packages/core/feature-flags/src/index.js +++ b/packages/core/feature-flags/src/index.js @@ -8,6 +8,7 @@ export type FeatureFlags = _FeatureFlags; export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { exampleFeature: false, configKeyInvalidation: false, + parcelV3: false, dfsFasterRefactor: false, }; diff --git a/packages/core/feature-flags/src/types.js b/packages/core/feature-flags/src/types.js index 58250a4a62b..87d3f20615a 100644 --- a/packages/core/feature-flags/src/types.js +++ b/packages/core/feature-flags/src/types.js @@ -13,4 +13,8 @@ export type FeatureFlags = {| * Refactors dfsNew to use an iterative approach. */ +dfsFasterRefactor: boolean, + /** + * Rust backed requests + */ + +parcelV3: boolean, |}; diff --git a/packages/core/graph/test/AdjacencyList.test.js b/packages/core/graph/test/AdjacencyList.test.js index 9d320819381..acef303f3d6 100644 --- a/packages/core/graph/test/AdjacencyList.test.js +++ b/packages/core/graph/test/AdjacencyList.test.js @@ -292,7 +292,8 @@ describe('AdjacencyList', () => { let work = new Promise(resolve => worker.on('message', resolve)); worker.postMessage(originalSerialized); let received = AdjacencyList.deserialize(await work); - await worker.terminate(); + // eslint-disable-next-line no-unused-vars + const _terminatePromise = worker.terminate(); assert.deepEqual(received.serialize().nodes, graph.serialize().nodes); assert.deepEqual(received.serialize().edges, graph.serialize().edges); diff --git a/packages/core/graph/test/integration/adjacency-list-shared-array.js b/packages/core/graph/test/integration/adjacency-list-shared-array.js index 0c3460f92a8..174e7473238 100644 --- a/packages/core/graph/test/integration/adjacency-list-shared-array.js +++ b/packages/core/graph/test/integration/adjacency-list-shared-array.js @@ -6,7 +6,7 @@ const { EdgeTypeMap, } = require('../../src/AdjacencyList'); -parentPort.once('message', (serialized) => { +parentPort.once('message', serialized => { let graph = AdjacencyList.deserialize(serialized); serialized.nodes.forEach((v, i) => { if (i < NodeTypeMap.HEADER_SIZE) return; diff --git a/packages/core/integration-tests/test/cache.js b/packages/core/integration-tests/test/cache.js index 8f207b28bed..d5246bccf46 100644 --- a/packages/core/integration-tests/test/cache.js +++ b/packages/core/integration-tests/test/cache.js @@ -1319,6 +1319,7 @@ describe('cache', function () { featureFlags: { exampleFeature: false, configKeyInvalidation: true, + parcelV3: false, dfsFasterRefactor: false, }, async setup() { @@ -1380,6 +1381,7 @@ describe('cache', function () { featureFlags: { exampleFeature: false, configKeyInvalidation: true, + parcelV3: false, dfsFasterRefactor: false, }, async setup() { @@ -1441,6 +1443,7 @@ describe('cache', function () { featureFlags: { exampleFeature: false, configKeyInvalidation: true, + parcelV3: false, dfsFasterRefactor: false, }, async setup() { diff --git a/packages/core/rust/index.js.flow b/packages/core/rust/index.js.flow index e74fe87719a..a0b9c61fd82 100644 --- a/packages/core/rust/index.js.flow +++ b/packages/core/rust/index.js.flow @@ -3,21 +3,29 @@ import type {FileCreateInvalidation} from '@parcel/types'; declare export var init: void | (() => void); +export type ProjectPath = any; +export interface ConfigRequest { + id: string, + invalidateOnFileChange: Array, + invalidateOnConfigKeyChange: Array, + invalidateOnFileCreate: Array, + invalidateOnEnvChange: Array, + invalidateOnOptionChange: Array, + invalidateOnStartup: boolean, + invalidateOnBuild: boolean, +} +export interface RequestOptions { +} + declare export function initSentry(): void; declare export function closeSentry(): void; -declare export function findAncestorFile( - filenames: Array, - from: string, - root: string, -): string | null; -declare export function findFirstFile(names: Array): string | null; -declare export function findNodeModule( - module: string, - from: string, -): string | null; -declare export function hashString(s: string): string; -declare export function hashBuffer(buf: Buffer): string; -declare export function optimizeImage(kind: string, buf: Buffer): Buffer; +declare export function napiRunConfigRequest(configRequest: ConfigRequest, api: any, options: any): void +declare export function findAncestorFile(filenames: Array, from: string, root: string): string | null +declare export function findFirstFile(names: Array): string | null +declare export function findNodeModule(module: string, from: string): string | null +declare export function hashString(s: string): string +declare export function hashBuffer(buf: Buffer): string +declare export function optimizeImage(kind: string, buf: Buffer): Buffer export interface JsFileSystemOptions { canonicalize: string => string; read: string => Buffer; diff --git a/packages/dev/eslint-plugin/test/rules/no-self-package-imports.test.js b/packages/dev/eslint-plugin/test/rules/no-self-package-imports.test.js index d62013b131a..a8f43b91f73 100644 --- a/packages/dev/eslint-plugin/test/rules/no-self-package-imports.test.js +++ b/packages/dev/eslint-plugin/test/rules/no-self-package-imports.test.js @@ -9,7 +9,7 @@ const message = const filename = __filename; new RuleTester({ - parser: '@babel/eslint-parser', + parser: require.resolve('@babel/eslint-parser'), parserOptions: {ecmaVersion: 2018, sourceType: 'module'}, }).run('no-self-package-imports', rule, { valid: [ diff --git a/packages/reporters/cli/test/CLIReporter.test.js b/packages/reporters/cli/test/CLIReporter.test.js index 0e9e99d246b..4c5ad8baa45 100644 --- a/packages/reporters/cli/test/CLIReporter.test.js +++ b/packages/reporters/cli/test/CLIReporter.test.js @@ -4,12 +4,12 @@ import assert from 'assert'; import sinon from 'sinon'; import {PassThrough} from 'stream'; import {_report} from '../src/CLIReporter'; +import * as render from '../src/render'; import {_setStdio} from '../src/render'; import {inputFS, outputFS} from '@parcel/test-utils'; import {NodePackageManager} from '@parcel/package-manager'; import stripAnsi from 'strip-ansi'; import * as bundleReport from '../src/bundleReport'; -import * as render from '../src/render'; import {DEFAULT_FEATURE_FLAGS} from '@parcel/feature-flags'; const EMPTY_OPTIONS = { @@ -200,7 +200,7 @@ describe('CLIReporter', () => { // emit a buildSuccess event to reset the timings and seen phases // from the previous test process.env['PARCEL_SHOW_PHASE_TIMES'] = undefined; - // $FlowFixMe[incompatible-call] + // $FlowFixMe await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); process.env['PARCEL_SHOW_PHASE_TIMES'] = 'true'; @@ -209,9 +209,18 @@ describe('CLIReporter', () => { EMPTY_OPTIONS, ); await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); - // $FlowFixMe[incompatible-call] - await _report({type: 'buildProgress', phase: 'packaging'}, EMPTY_OPTIONS); - // $FlowFixMe[incompatible-call] + await _report( + // $FlowFixMe + { + type: 'buildProgress', + phase: 'packaging', + bundle: { + displayName: 'test', + }, + }, + EMPTY_OPTIONS, + ); + // $FlowFixMe await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); const expected = /Building...\nBundling...\nPackaging & Optimizing...\nTransforming finished in [0-9]ms\nBundling finished in [0-9]ms\nPackaging & Optimizing finished in [0-9]ms/; @@ -225,10 +234,24 @@ describe('CLIReporter', () => { EMPTY_OPTIONS, ); await _report({type: 'buildProgress', phase: 'bundling'}, EMPTY_OPTIONS); - // $FlowFixMe[incompatible-call] - await _report({type: 'buildProgress', phase: 'packaging'}, EMPTY_OPTIONS); - // $FlowFixMe[incompatible-call] + await _report( + // $FlowFixMe + { + type: 'buildProgress', + phase: 'packaging', + bundle: { + displayName: 'test', + }, + }, + EMPTY_OPTIONS, + ); + // $FlowFixMe await _report({type: 'buildSuccess'}, EMPTY_OPTIONS); - assert.equal(expected.test(stdoutOutput), true); + + assert.equal( + expected.test(stdoutOutput), + true, + 'STDOUT output did not match', + ); }); }); diff --git a/packages/utils/node-resolver-rs/src/fs.rs b/packages/utils/node-resolver-rs/src/fs.rs index a46d7a70a87..9820f756f6b 100644 --- a/packages/utils/node-resolver-rs/src/fs.rs +++ b/packages/utils/node-resolver-rs/src/fs.rs @@ -7,7 +7,7 @@ use dashmap::DashMap; #[cfg(not(target_arch = "wasm32"))] use crate::path::canonicalize; -pub trait FileSystem: Send + Sync { +pub trait FileSystem { fn canonicalize>( &self, path: P,