From 617eeabe8369d7bfca7951d1cd55ac58ede1f9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 18 Oct 2021 19:36:28 +0200 Subject: [PATCH] feat(unstable): Node CJS and ESM resolvers for compat mode (#12424) This commit adds CJS and ESM Node resolvers to the "--compat" mode. The functionality is spread across "cli/compat" module and Node compatibility layer in "deno_std/node"; this stems from the fact that ES module resolution can only be implemented in Rust as it needs to directly integrated with "deno_core"; however "deno_std/node" already provided CJS module resolution. Currently this resolution is only active when running a files using "deno run --compat --unstable ", and is not available in other subcommands, which will be changed in follow up commits. --- cli/compat.rs | 88 -- cli/compat/errors.rs | 145 ++ cli/compat/esm_resolver.rs | 1182 +++++++++++++++++ cli/compat/mod.rs | 132 ++ cli/compat/testdata/basic/main.js | 1 + .../testdata/basic/node_modules/foo/index.js | 0 .../basic/node_modules/foo/package.json | 5 + cli/compat/testdata/basic/package.json | 7 + cli/compat/testdata/basic_deps/main.js | 1 + .../basic_deps/node_modules/bar/bar.js | 1 + .../basic_deps/node_modules/bar/package.json | 6 + .../basic_deps/node_modules/foo/foo.js | 1 + .../basic_deps/node_modules/foo/package.json | 8 + cli/compat/testdata/basic_deps/package.json | 7 + cli/compat/testdata/conditions/main.js | 1 + .../imports_exports/import_export.js | 6 + .../imports_exports/import_polyfill.js | 3 + .../node_modules/imports_exports/package.json | 17 + .../imports_exports/require_export.cjs | 6 + .../imports_exports/require_polyfill.js | 3 + cli/compat/testdata/conditions/package.json | 7 + cli/compat/testdata/deep/a/b/c/d/main.js | 1 + .../testdata/deep/node_modules/foo/index.js | 0 .../deep/node_modules/foo/package.json | 5 + cli/main.rs | 38 +- cli/proc_state.rs | 48 +- cli/tests/integration/compat_tests.rs | 10 +- .../testdata/compat/existing_import_map.json | 5 - .../testdata/compat/existing_import_map.out | 7 - .../{fs_promises.js => fs_promises.mjs} | 0 cli/tests/testdata/compat/globals.out | 4 +- ...de_fs_promises.js => node_fs_promises.mjs} | 0 32 files changed, 1606 insertions(+), 139 deletions(-) delete mode 100644 cli/compat.rs create mode 100644 cli/compat/errors.rs create mode 100644 cli/compat/esm_resolver.rs create mode 100644 cli/compat/mod.rs create mode 100644 cli/compat/testdata/basic/main.js create mode 100644 cli/compat/testdata/basic/node_modules/foo/index.js create mode 100644 cli/compat/testdata/basic/node_modules/foo/package.json create mode 100644 cli/compat/testdata/basic/package.json create mode 100644 cli/compat/testdata/basic_deps/main.js create mode 100644 cli/compat/testdata/basic_deps/node_modules/bar/bar.js create mode 100644 cli/compat/testdata/basic_deps/node_modules/bar/package.json create mode 100644 cli/compat/testdata/basic_deps/node_modules/foo/foo.js create mode 100644 cli/compat/testdata/basic_deps/node_modules/foo/package.json create mode 100644 cli/compat/testdata/basic_deps/package.json create mode 100644 cli/compat/testdata/conditions/main.js create mode 100644 cli/compat/testdata/conditions/node_modules/imports_exports/import_export.js create mode 100644 cli/compat/testdata/conditions/node_modules/imports_exports/import_polyfill.js create mode 100644 cli/compat/testdata/conditions/node_modules/imports_exports/package.json create mode 100644 cli/compat/testdata/conditions/node_modules/imports_exports/require_export.cjs create mode 100644 cli/compat/testdata/conditions/node_modules/imports_exports/require_polyfill.js create mode 100644 cli/compat/testdata/conditions/package.json create mode 100644 cli/compat/testdata/deep/a/b/c/d/main.js create mode 100644 cli/compat/testdata/deep/node_modules/foo/index.js create mode 100644 cli/compat/testdata/deep/node_modules/foo/package.json delete mode 100644 cli/tests/testdata/compat/existing_import_map.json delete mode 100644 cli/tests/testdata/compat/existing_import_map.out rename cli/tests/testdata/compat/{fs_promises.js => fs_promises.mjs} (100%) rename cli/tests/testdata/compat/{node_fs_promises.js => node_fs_promises.mjs} (100%) diff --git a/cli/compat.rs b/cli/compat.rs deleted file mode 100644 index f4cc2a08d4994..0000000000000 --- a/cli/compat.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. - -use deno_core::url::Url; -use std::collections::HashMap; - -// TODO(bartlomieju): this needs to be bumped manually for -// each release, a better mechanism is preferable, but it's a quick and dirty -// solution to avoid printing `X-Deno-Warning` headers when the compat layer is -// downloaded -static STD_URL: &str = "https://deno.land/std@0.111.0/"; -static GLOBAL_MODULE: &str = "global.ts"; - -static SUPPORTED_MODULES: &[&str] = &[ - "assert", - "assert/strict", - "async_hooks", - "buffer", - "child_process", - "cluster", - "console", - "constants", - "crypto", - "dgram", - "dns", - "domain", - "events", - "fs", - "fs/promises", - "http", - "https", - "module", - "net", - "os", - "path", - "path/posix", - "path/win32", - "perf_hooks", - "process", - "querystring", - "readline", - "stream", - "stream/promises", - "stream/web", - "string_decoder", - "sys", - "timers", - "timers/promises", - "tls", - "tty", - "url", - "util", - "util/types", - "v8", - "vm", - "zlib", -]; - -lazy_static::lazy_static! { - static ref GLOBAL_URL_STR: String = format!("{}node/{}", STD_URL, GLOBAL_MODULE); - pub(crate) static ref GLOBAL_URL: Url = Url::parse(&GLOBAL_URL_STR).unwrap(); - static ref COMPAT_IMPORT_URL: Url = Url::parse("flags:compat").unwrap(); -} - -/// Provide imports into a module graph when the compat flag is true. -pub(crate) fn get_node_imports() -> Vec<(Url, Vec)> { - vec![(COMPAT_IMPORT_URL.clone(), vec![GLOBAL_URL_STR.clone()])] -} - -/// Create a map that can be used to update import map. -/// -/// Keys are built-in Node modules (and built-ins prefixed with "node:"), while -/// values are URLs pointing to relevant files in deno.land/std/node/ directory. -pub fn get_mapped_node_builtins() -> HashMap { - let mut mappings = HashMap::new(); - - for module in SUPPORTED_MODULES { - // TODO(bartlomieju): this is unversioned, and should be fixed to use latest stable? - let module_url = format!("{}node/{}.ts", STD_URL, module); - mappings.insert(module.to_string(), module_url.clone()); - - // Support for `node:` - // https://nodejs.org/api/esm.html#esm_node_imports - let node_prefixed = format!("node:{}", module); - mappings.insert(node_prefixed, module_url); - } - - mappings -} diff --git a/cli/compat/errors.rs b/cli/compat/errors.rs new file mode 100644 index 0000000000000..3a44b2c88fdc6 --- /dev/null +++ b/cli/compat/errors.rs @@ -0,0 +1,145 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +use deno_core::error::generic_error; +use deno_core::error::type_error; +use deno_core::error::AnyError; +use deno_core::url::Url; + +pub(crate) fn err_invalid_module_specifier( + request: &str, + reason: &str, + maybe_base: Option, +) -> AnyError { + let mut msg = format!( + "[ERR_INVALID_MODULE_SPECIFIER] Invalid module \"{}\" {}", + request, reason + ); + + if let Some(base) = maybe_base { + msg = format!("{} imported from {}", msg, base); + } + + type_error(msg) +} + +pub(crate) fn err_invalid_package_config( + path: &str, + maybe_base: Option, + maybe_message: Option, +) -> AnyError { + let mut msg = format!( + "[ERR_INVALID_PACKAGE_CONFIG] Invalid package config {}", + path + ); + + if let Some(base) = maybe_base { + msg = format!("{} while importing {}", msg, base); + } + + if let Some(message) = maybe_message { + msg = format!("{}. {}", msg, message); + } + + generic_error(msg) +} + +pub(crate) fn err_module_not_found( + path: &str, + base: &str, + typ: &str, +) -> AnyError { + generic_error(format!( + "[ERR_MODULE_NOT_FOUND] Cannot find {} '{}' imported from {}", + typ, path, base + )) +} + +pub(crate) fn err_unsupported_dir_import(path: &str, base: &str) -> AnyError { + generic_error(format!("[ERR_UNSUPPORTED_DIR_IMPORT] Directory import '{}' is not supported resolving ES modules imported from {}", path, base)) +} + +pub(crate) fn err_unsupported_esm_url_scheme(url: &Url) -> AnyError { + let mut msg = + "[ERR_UNSUPPORTED_ESM_URL_SCHEME] Only file and data URLS are supported by the default ESM loader" + .to_string(); + + if cfg!(window) && url.scheme().len() == 2 { + msg = format!( + "{}. On Windows, absolute path must be valid file:// URLs", + msg + ); + } + + msg = format!("{}. Received protocol '{}'", msg, url.scheme()); + generic_error(msg) +} + +pub(crate) fn err_invalid_package_target( + pkg_path: String, + key: String, + target: String, + is_import: bool, + maybe_base: Option, +) -> AnyError { + let rel_error = !is_import && !target.is_empty() && !target.starts_with("./"); + let mut msg = "[ERR_INVALID_PACKAGE_TARGET]".to_string(); + + if key == "." { + assert!(!is_import); + msg = format!("{} Invalid \"exports\" main target {} defined in the package config {}package.json", msg, target, pkg_path) + } else { + let ie = if is_import { "imports" } else { "exports" }; + msg = format!("{} Invalid \"{}\" target {} defined for '{}' in the package config {}package.json", msg, ie, target, key, pkg_path) + }; + + if let Some(base) = maybe_base { + msg = format!("{} imported from {}", msg, base); + }; + if rel_error { + msg = format!("{}; target must start with \"./\"", msg); + } + + generic_error(msg) +} + +pub(crate) fn err_package_path_not_exported( + pkg_path: String, + subpath: String, + maybe_base: Option, +) -> AnyError { + let mut msg = "[ERR_PACKAGE_PATH_NOT_EXPORTED]".to_string(); + + if subpath == "." { + msg = format!( + "{} No \"exports\" main defined in {}package.json", + msg, pkg_path + ); + } else { + msg = format!("{} Package subpath \'{}\' is not defined by \"exports\" in {}package.json", msg, subpath, pkg_path); + }; + + if let Some(base) = maybe_base { + msg = format!("{} imported from {}", msg, base); + } + + generic_error(msg) +} + +pub(crate) fn err_package_import_not_defined( + specifier: &str, + package_path: Option, + base: &str, +) -> AnyError { + let mut msg = format!( + "[ERR_PACKAGE_IMPORT_NOT_DEFINED] Package import specifier \"{}\" is not defined in", + specifier + ); + + if let Some(package_path) = package_path { + msg = format!("{} in package {}package.json", msg, package_path); + } + + msg = format!("{} imported from {}", msg, base); + + type_error(msg) +} diff --git a/cli/compat/esm_resolver.rs b/cli/compat/esm_resolver.rs new file mode 100644 index 0000000000000..c0366c27b04ba --- /dev/null +++ b/cli/compat/esm_resolver.rs @@ -0,0 +1,1182 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +use super::errors; +use deno_core::error::generic_error; +use deno_core::error::AnyError; +use deno_core::serde_json; +use deno_core::serde_json::Map; +use deno_core::serde_json::Value; +use deno_core::url::Url; +use deno_core::ModuleSpecifier; +use deno_graph::source::Resolver; +use regex::Regex; +use std::path::PathBuf; + +#[derive(Debug, Default)] +pub struct NodeEsmResolver; + +impl NodeEsmResolver { + pub fn as_resolver(&self) -> &dyn Resolver { + self + } +} + +impl Resolver for NodeEsmResolver { + fn resolve( + &self, + specifier: &str, + referrer: &ModuleSpecifier, + ) -> Result { + node_resolve(specifier, referrer.as_str(), &std::env::current_dir()?) + } +} + +static DEFAULT_CONDITIONS: &[&str] = &["deno", "node", "import"]; + +/// This function is an implementation of `defaultResolve` in +/// `lib/internal/modules/esm/resolve.js` from Node. +fn node_resolve( + specifier: &str, + referrer: &str, + cwd: &std::path::Path, +) -> Result { + // TODO(bartlomieju): skipped "policy" part as we don't plan to support it + + if let Some(resolved) = crate::compat::try_resolve_builtin_module(specifier) { + return Ok(resolved); + } + + if let Ok(url) = Url::parse(specifier) { + if url.scheme() == "data" { + return Ok(url); + } + + let protocol = url.scheme(); + + if protocol == "node" { + let split_specifier = url.as_str().split(':'); + let specifier = split_specifier.skip(1).collect::>().join(""); + if let Some(resolved) = + crate::compat::try_resolve_builtin_module(&specifier) + { + return Ok(resolved); + } else { + return Err(generic_error(format!("Unknown module {}", specifier))); + } + } + + if protocol != "file" && protocol != "data" { + return Err(errors::err_unsupported_esm_url_scheme(&url)); + } + + if referrer.starts_with("data:") { + let referrer_url = Url::parse(referrer)?; + return referrer_url.join(specifier).map_err(AnyError::from); + } + } + + let is_main = referrer.is_empty(); + let parent_url = if is_main { + Url::from_directory_path(cwd).unwrap() + } else { + Url::parse(referrer).expect("referrer was not proper url") + }; + + let conditions = DEFAULT_CONDITIONS; + let url = module_resolve(specifier, &parent_url, conditions)?; + + // TODO(bartlomieju): skipped checking errors for commonJS resolution and + // "preserveSymlinksMain"/"preserveSymlinks" options. + Ok(url) +} + +fn to_file_path(url: &ModuleSpecifier) -> PathBuf { + url + .to_file_path() + .unwrap_or_else(|_| panic!("Provided URL was not file:// URL: {}", url)) +} + +fn to_file_path_string(url: &ModuleSpecifier) -> String { + to_file_path(url).display().to_string() +} + +fn should_be_treated_as_relative_or_absolute_path(specifier: &str) -> bool { + if specifier.is_empty() { + return false; + } + + if specifier.starts_with('/') { + return true; + } + + is_relative_specifier(specifier) +} + +// TODO(ry) We very likely have this utility function elsewhere in Deno. +fn is_relative_specifier(specifier: &str) -> bool { + let specifier_len = specifier.len(); + let specifier_chars: Vec<_> = specifier.chars().collect(); + + if !specifier_chars.is_empty() && specifier_chars[0] == '.' { + if specifier_len == 1 || specifier_chars[1] == '/' { + return true; + } + if specifier_chars[1] == '.' + && (specifier_len == 2 || specifier_chars[2] == '/') + { + return true; + } + } + false +} + +fn module_resolve( + specifier: &str, + base: &ModuleSpecifier, + conditions: &[&str], +) -> Result { + let resolved = if should_be_treated_as_relative_or_absolute_path(specifier) { + base.join(specifier)? + } else if specifier.starts_with('#') { + package_imports_resolve(specifier, base, conditions)? + } else if let Ok(resolved) = Url::parse(specifier) { + resolved + } else { + package_resolve(specifier, base, conditions)? + }; + finalize_resolution(resolved, base) +} + +fn finalize_resolution( + resolved: ModuleSpecifier, + base: &ModuleSpecifier, +) -> Result { + // TODO(bartlomieju): this is not part of Node resolution algorithm + // (as it doesn't support http/https); but I had to short circuit here + // for remote modules because they are mainly used to polyfill `node` built + // in modules. Another option would be to leave the resolved URLs + // as `node:` and do the actual remapping to std's polyfill + // in module loader. I'm not sure which approach is better. + if resolved.scheme().starts_with("http") { + return Ok(resolved); + } + + let encoded_sep_re = Regex::new(r"%2F|%2C").expect("bad regex"); + + if encoded_sep_re.is_match(resolved.path()) { + return Err(errors::err_invalid_module_specifier( + resolved.path(), + "must not include encoded \"/\" or \"\\\\\" characters", + Some(to_file_path_string(base)), + )); + } + + let path = to_file_path(&resolved); + + // TODO(bartlomieju): currently not supported + // if (getOptionValue('--experimental-specifier-resolution') === 'node') { + // ... + // } + + let p_str = path.to_str().unwrap(); + let p = if p_str.ends_with('/') { + p_str[p_str.len() - 1..].to_string() + } else { + p_str.to_string() + }; + + let (is_dir, is_file) = if let Ok(stats) = std::fs::metadata(&p) { + (stats.is_dir(), stats.is_file()) + } else { + (false, false) + }; + if is_dir { + return Err(errors::err_unsupported_dir_import( + &path.display().to_string(), + &to_file_path_string(base), + )); + } else if !is_file { + return Err(errors::err_module_not_found( + &path.display().to_string(), + &to_file_path_string(base), + "module", + )); + } + + Ok(resolved) +} + +fn throw_import_not_defined( + specifier: &str, + package_json_url: Option, + base: &ModuleSpecifier, +) -> AnyError { + errors::err_package_import_not_defined( + specifier, + package_json_url.map(|u| to_file_path_string(&u.join(".").unwrap())), + &to_file_path_string(base), + ) +} + +fn pattern_key_compare(a: &str, b: &str) -> i32 { + let a_pattern_index = a.find('*'); + let b_pattern_index = b.find('*'); + + let base_len_a = if let Some(index) = a_pattern_index { + index + 1 + } else { + a.len() + }; + let base_len_b = if let Some(index) = b_pattern_index { + index + 1 + } else { + b.len() + }; + + if base_len_a > base_len_b { + return -1; + } + + if base_len_b > base_len_a { + return 1; + } + + if a_pattern_index.is_none() { + return 1; + } + + if b_pattern_index.is_none() { + return -1; + } + + if a.len() > b.len() { + return -1; + } + + if b.len() > a.len() { + return 1; + } + + 0 +} + +fn package_imports_resolve( + name: &str, + base: &ModuleSpecifier, + conditions: &[&str], +) -> Result { + if name == "#" || name.starts_with("#/") || name.ends_with('/') { + let reason = "is not a valid internal imports specifier name"; + return Err(errors::err_invalid_module_specifier( + name, + reason, + Some(to_file_path_string(base)), + )); + } + + let mut package_json_url = None; + + let package_config = get_package_scope_config(base)?; + if package_config.exists { + package_json_url = + Some(Url::from_file_path(package_config.pjsonpath).unwrap()); + if let Some(imports) = &package_config.imports { + if imports.contains_key(name) && !name.contains('*') { + let maybe_resolved = resolve_package_target( + package_json_url.clone().unwrap(), + imports.get(name).unwrap().to_owned(), + "".to_string(), + name.to_string(), + base, + false, + true, + conditions, + )?; + if let Some(resolved) = maybe_resolved { + return Ok(resolved); + } + } else { + let mut best_match = ""; + let mut best_match_subpath = None; + for key in imports.keys() { + let pattern_index = key.find('*'); + if let Some(pattern_index) = pattern_index { + let key_sub = &key[0..=pattern_index]; + if name.starts_with(key_sub) { + let pattern_trailer = &key[pattern_index + 1..]; + if name.len() > key.len() + && name.ends_with(&pattern_trailer) + && pattern_key_compare(best_match, key) == 1 + && key.rfind('*') == Some(pattern_index) + { + best_match = key; + best_match_subpath = Some( + name[pattern_index..=(name.len() - pattern_trailer.len())] + .to_string(), + ); + } + } + } + } + + if !best_match.is_empty() { + let target = imports.get(best_match).unwrap().to_owned(); + let maybe_resolved = resolve_package_target( + package_json_url.clone().unwrap(), + target, + best_match_subpath.unwrap(), + best_match.to_string(), + base, + true, + true, + conditions, + )?; + if let Some(resolved) = maybe_resolved { + return Ok(resolved); + } + } + } + } + } + + Err(throw_import_not_defined(name, package_json_url, base)) +} + +fn is_conditional_exports_main_sugar( + exports: &Value, + package_json_url: &ModuleSpecifier, + base: &ModuleSpecifier, +) -> Result { + if exports.is_string() || exports.is_array() { + return Ok(true); + } + + if exports.is_null() || !exports.is_object() { + return Ok(false); + } + + let exports_obj = exports.as_object().unwrap(); + let mut is_conditional_sugar = false; + let mut i = 0; + for key in exports_obj.keys() { + let cur_is_conditional_sugar = key.is_empty() || !key.starts_with('.'); + if i == 0 { + is_conditional_sugar = cur_is_conditional_sugar; + i += 1; + } else if is_conditional_sugar != cur_is_conditional_sugar { + return Err(errors::err_invalid_package_config( + &to_file_path_string(package_json_url), + Some(base.as_str().to_string()), + Some("\"exports\" cannot contains some keys starting with \'.\' and some not. + The exports object must either be an object of package subpath keys + or an object of main entry condition name keys only.".to_string()) + )); + } + } + + Ok(is_conditional_sugar) +} + +fn throw_invalid_package_target( + subpath: String, + target: String, + package_json_url: &ModuleSpecifier, + internal: bool, + base: &ModuleSpecifier, +) -> AnyError { + errors::err_invalid_package_target( + to_file_path_string(&package_json_url.join(".").unwrap()), + subpath, + target, + internal, + Some(base.as_str().to_string()), + ) +} + +fn throw_invalid_subpath( + subpath: String, + package_json_url: &ModuleSpecifier, + internal: bool, + base: &ModuleSpecifier, +) -> AnyError { + let ie = if internal { "imports" } else { "exports" }; + let reason = format!( + "request is not a valid subpath for the \"{}\" resolution of {}", + ie, + to_file_path_string(package_json_url) + ); + errors::err_invalid_module_specifier( + &subpath, + &reason, + Some(to_file_path_string(base)), + ) +} + +#[allow(clippy::too_many_arguments)] +fn resolve_package_target_string( + target: String, + subpath: String, + match_: String, + package_json_url: ModuleSpecifier, + base: &ModuleSpecifier, + pattern: bool, + internal: bool, + conditions: &[&str], +) -> Result { + if !subpath.is_empty() && !pattern && !target.ends_with('/') { + return Err(throw_invalid_package_target( + match_, + target, + &package_json_url, + internal, + base, + )); + } + + let invalid_segment_re = + Regex::new(r"(^|\|/)(..?|node_modules)(\|/|$)").expect("bad regex"); + let pattern_re = Regex::new(r"\*").expect("bad regex"); + + if !target.starts_with("./") { + if internal && !target.starts_with("../") && !target.starts_with('/') { + let is_url = Url::parse(&target).is_ok(); + if !is_url { + let export_target = if pattern { + pattern_re + .replace(&target, |_caps: ®ex::Captures| subpath.clone()) + .to_string() + } else { + format!("{}{}", target, subpath) + }; + return package_resolve(&export_target, &package_json_url, conditions); + } + } + return Err(throw_invalid_package_target( + match_, + target, + &package_json_url, + internal, + base, + )); + } + + if invalid_segment_re.is_match(&target[2..]) { + return Err(throw_invalid_package_target( + match_, + target, + &package_json_url, + internal, + base, + )); + } + + let resolved = package_json_url.join(&target)?; + let resolved_path = resolved.path(); + let package_url = package_json_url.join(".").unwrap(); + let package_path = package_url.path(); + + if !resolved_path.starts_with(package_path) { + return Err(throw_invalid_package_target( + match_, + target, + &package_json_url, + internal, + base, + )); + } + + if subpath.is_empty() { + return Ok(resolved); + } + + if invalid_segment_re.is_match(&subpath) { + let request = if pattern { + match_.replace("*", &subpath) + } else { + format!("{}{}", match_, subpath) + }; + return Err(throw_invalid_subpath( + request, + &package_json_url, + internal, + base, + )); + } + + if pattern { + let replaced = pattern_re + .replace(resolved.as_str(), |_caps: ®ex::Captures| subpath.clone()); + let url = Url::parse(&replaced)?; + return Ok(url); + } + + Ok(resolved.join(&subpath)?) +} + +#[allow(clippy::too_many_arguments)] +fn resolve_package_target( + package_json_url: ModuleSpecifier, + target: Value, + subpath: String, + package_subpath: String, + base: &ModuleSpecifier, + pattern: bool, + internal: bool, + conditions: &[&str], +) -> Result, AnyError> { + if let Some(target) = target.as_str() { + return Ok(Some(resolve_package_target_string( + target.to_string(), + subpath, + package_subpath, + package_json_url, + base, + pattern, + internal, + conditions, + )?)); + } else if let Some(target_arr) = target.as_array() { + if target_arr.is_empty() { + return Ok(None); + } + + let mut last_error = None; + for target_item in target_arr { + let resolved_result = resolve_package_target( + package_json_url.clone(), + target_item.to_owned(), + subpath.clone(), + package_subpath.clone(), + base, + pattern, + internal, + conditions, + ); + + if let Err(e) = resolved_result { + let err_string = e.to_string(); + last_error = Some(e); + if err_string.starts_with("[ERR_INVALID_PACKAGE_TARGET]") { + continue; + } + return Err(last_error.unwrap()); + } + let resolved = resolved_result.unwrap(); + if resolved.is_none() { + last_error = None; + continue; + } + return Ok(resolved); + } + if last_error.is_none() { + return Ok(None); + } + return Err(last_error.unwrap()); + } else if let Some(target_obj) = target.as_object() { + for key in target_obj.keys() { + // TODO(bartlomieju): verify that keys are not numeric + // return Err(errors::err_invalid_package_config( + // to_file_path_string(package_json_url), + // Some(base.as_str().to_string()), + // Some("\"exports\" cannot contain numeric property keys.".to_string()), + // )); + + if key == "default" || conditions.contains(&key.as_str()) { + let condition_target = target_obj.get(key).unwrap().to_owned(); + let resolved = resolve_package_target( + package_json_url.clone(), + condition_target, + subpath.clone(), + package_subpath.clone(), + base, + pattern, + internal, + conditions, + )?; + if resolved.is_none() { + continue; + } + return Ok(resolved); + } + } + } else if target.is_null() { + return Ok(None); + } + + Err(throw_invalid_package_target( + package_subpath, + target.to_string(), + &package_json_url, + internal, + base, + )) +} + +fn throw_exports_not_found( + subpath: String, + package_json_url: &ModuleSpecifier, + base: &ModuleSpecifier, +) -> AnyError { + errors::err_package_path_not_exported( + to_file_path_string(&package_json_url.join(".").unwrap()), + subpath, + Some(to_file_path_string(base)), + ) +} + +fn package_exports_resolve( + package_json_url: ModuleSpecifier, + package_subpath: String, + package_config: PackageConfig, + base: &ModuleSpecifier, + conditions: &[&str], +) -> Result { + let exports = &package_config.exports.unwrap(); + + let exports_map = + if is_conditional_exports_main_sugar(exports, &package_json_url, base)? { + let mut map = Map::new(); + map.insert(".".to_string(), exports.to_owned()); + map + } else { + exports.as_object().unwrap().to_owned() + }; + + if exports_map.contains_key(&package_subpath) + && package_subpath.find('*').is_none() + && !package_subpath.ends_with('/') + { + let target = exports_map.get(&package_subpath).unwrap().to_owned(); + let resolved = resolve_package_target( + package_json_url.clone(), + target, + "".to_string(), + package_subpath.to_string(), + base, + false, + false, + conditions, + )?; + if resolved.is_none() { + return Err(throw_exports_not_found( + package_subpath, + &package_json_url, + base, + )); + } + return Ok(resolved.unwrap()); + } + + let mut best_match = ""; + let mut best_match_subpath = None; + for key in exports_map.keys() { + let pattern_index = key.find('*'); + if let Some(pattern_index) = pattern_index { + let key_sub = &key[0..=pattern_index]; + if package_subpath.starts_with(key_sub) { + // When this reaches EOL, this can throw at the top of the whole function: + // + // if (StringPrototypeEndsWith(packageSubpath, '/')) + // throwInvalidSubpath(packageSubpath) + // + // To match "imports" and the spec. + if package_subpath.ends_with('/') { + // TODO(bartlomieju): + // emitTrailingSlashPatternDeprecation(); + } + let pattern_trailer = &key[pattern_index + 1..]; + if package_subpath.len() > key.len() + && package_subpath.ends_with(&pattern_trailer) + && pattern_key_compare(best_match, key) == 1 + && key.rfind('*') == Some(pattern_index) + { + best_match = key; + best_match_subpath = Some( + package_subpath + [pattern_index..=(package_subpath.len() - pattern_trailer.len())] + .to_string(), + ); + } + } + } + } + + if !best_match.is_empty() { + let target = exports.get(best_match).unwrap().to_owned(); + let maybe_resolved = resolve_package_target( + package_json_url.clone(), + target, + best_match_subpath.unwrap(), + best_match.to_string(), + base, + true, + false, + conditions, + )?; + if let Some(resolved) = maybe_resolved { + return Ok(resolved); + } else { + return Err(throw_exports_not_found( + package_subpath, + &package_json_url, + base, + )); + } + } + + Err(throw_exports_not_found( + package_subpath, + &package_json_url, + base, + )) +} + +fn package_resolve( + specifier: &str, + base: &ModuleSpecifier, + conditions: &[&str], +) -> Result { + let (package_name, package_subpath, is_scoped) = + parse_package_name(specifier, base)?; + + // ResolveSelf + let package_config = get_package_scope_config(base)?; + if package_config.exists { + let package_json_url = + Url::from_file_path(&package_config.pjsonpath).unwrap(); + if package_config.name.as_ref() == Some(&package_name) { + if let Some(exports) = &package_config.exports { + if !exports.is_null() { + return package_exports_resolve( + package_json_url, + package_subpath, + package_config, + base, + conditions, + ); + } + } + } + } + + let mut package_json_url = + base.join(&format!("./node_modules/{}/package.json", package_name))?; + let mut package_json_path = to_file_path(&package_json_url); + let mut last_path; + loop { + let p_str = package_json_path.to_str().unwrap(); + let package_str_len = "/package.json".len(); + let p = p_str[0..=p_str.len() - package_str_len].to_string(); + let is_dir = if let Ok(stats) = std::fs::metadata(&p) { + stats.is_dir() + } else { + false + }; + if !is_dir { + last_path = package_json_path; + + let prefix = if is_scoped { + "../../../../node_modules/" + } else { + "../../../node_modules/" + }; + package_json_url = package_json_url + .join(&format!("{}{}/package.json", prefix, package_name))?; + package_json_path = to_file_path(&package_json_url); + if package_json_path.to_str().unwrap().len() + == last_path.to_str().unwrap().len() + { + break; + } else { + continue; + } + } + + // Package match. + let package_config = + get_package_config(package_json_path.clone(), specifier, Some(base))?; + if package_config.exports.is_some() { + return package_exports_resolve( + package_json_url, + package_subpath, + package_config, + base, + conditions, + ); + } + if package_subpath == "." { + return legacy_main_resolve(&package_json_url, &package_config, base); + } + + return package_json_url + .join(&package_subpath) + .map_err(AnyError::from); + } + + Err(errors::err_module_not_found( + &package_json_url + .join(".") + .unwrap() + .to_file_path() + .unwrap() + .display() + .to_string(), + &to_file_path_string(base), + "package", + )) +} + +fn parse_package_name( + specifier: &str, + base: &ModuleSpecifier, +) -> Result<(String, String, bool), AnyError> { + let mut separator_index = specifier.find('/'); + let mut valid_package_name = true; + let mut is_scoped = false; + if specifier.is_empty() { + valid_package_name = false; + } else if specifier.starts_with('@') { + is_scoped = true; + if let Some(index) = separator_index { + separator_index = specifier[index + 1..].find('/'); + } else { + valid_package_name = false; + } + } + + let package_name = if let Some(index) = separator_index { + specifier[0..=index].to_string() + } else { + specifier.to_string() + }; + + // Package name cannot have leading . and cannot have percent-encoding or separators. + for ch in package_name.chars() { + if ch == '%' || ch == '\\' { + valid_package_name = false; + break; + } + } + + if !valid_package_name { + return Err(errors::err_invalid_module_specifier( + specifier, + "is not a valid package name", + Some(to_file_path_string(base)), + )); + } + + let package_subpath = if let Some(index) = separator_index { + format!(".{}", specifier.chars().skip(index).collect::()) + } else { + ".".to_string() + }; + + Ok((package_name, package_subpath, is_scoped)) +} + +#[derive(Clone, Debug)] +struct PackageConfig { + exists: bool, + exports: Option, + imports: Option>, + main: Option, + name: Option, + pjsonpath: PathBuf, + typ: String, +} + +fn get_package_config( + path: PathBuf, + specifier: &str, + maybe_base: Option<&ModuleSpecifier>, +) -> Result { + // TODO(bartlomieju): + // if let Some(existing) = package_json_cache.get(path) { + // return Ok(existing.clone()); + // } + + let result = std::fs::read_to_string(&path); + + let source = result.unwrap_or_else(|_| "".to_string()); + if source.is_empty() { + let package_config = PackageConfig { + pjsonpath: path, + exists: false, + main: None, + name: None, + typ: "none".to_string(), + exports: None, + imports: None, + }; + // TODO(bartlomieju): + // package_json_cache.set(package_json_path, package_config.clone()); + return Ok(package_config); + } + + let package_json: Value = serde_json::from_str(&source).map_err(|err| { + let base_msg = maybe_base.map(|base| { + format!("\"{}\" from {}", specifier, to_file_path(base).display()) + }); + errors::err_invalid_package_config( + &path.display().to_string(), + base_msg, + Some(err.to_string()), + ) + })?; + + let imports_val = package_json.get("imports"); + let main_val = package_json.get("main"); + let name_val = package_json.get("name"); + let typ_val = package_json.get("type"); + let exports = package_json.get("exports").map(|e| e.to_owned()); + + let imports = if let Some(imp) = imports_val { + imp.as_object().map(|imp| imp.to_owned()) + } else { + None + }; + let main = if let Some(m) = main_val { + m.as_str().map(|m| m.to_string()) + } else { + None + }; + let name = if let Some(n) = name_val { + n.as_str().map(|n| n.to_string()) + } else { + None + }; + + // Ignore unknown types for forwards compatibility + let typ = if let Some(t) = typ_val { + if let Some(t) = t.as_str() { + if t != "module" && t != "commonjs" { + "none".to_string() + } else { + t.to_string() + } + } else { + "none".to_string() + } + } else { + "none".to_string() + }; + + let package_config = PackageConfig { + pjsonpath: path, + exists: true, + main, + name, + typ, + exports, + imports, + }; + // TODO(bartlomieju): + // package_json_cache.set(package_json_path, package_config.clone()); + Ok(package_config) +} + +fn get_package_scope_config( + resolved: &ModuleSpecifier, +) -> Result { + let mut package_json_url = resolved.join("./package.json")?; + + loop { + let package_json_path = package_json_url.path(); + + if package_json_path.ends_with("node_modules/package.json") { + break; + } + + let package_config = get_package_config( + to_file_path(&package_json_url), + resolved.as_str(), + None, + )?; + + if package_config.exists { + return Ok(package_config); + } + + let last_package_json_url = package_json_url.clone(); + package_json_url = package_json_url.join("../package.json")?; + + // TODO(bartlomieju): I'm not sure this will work properly + // Terminates at root where ../package.json equals ../../package.json + // (can't just check "/package.json" for Windows support) + if package_json_url.path() == last_package_json_url.path() { + break; + } + } + + let package_json_path = to_file_path(&package_json_url); + let package_config = PackageConfig { + pjsonpath: package_json_path, + exists: false, + main: None, + name: None, + typ: "none".to_string(), + exports: None, + imports: None, + }; + + // TODO(bartlomieju): + // package_json_cache.set(package_json_path, package_config.clone()); + + Ok(package_config) +} + +fn file_exists(path_url: &ModuleSpecifier) -> bool { + if let Ok(stats) = std::fs::metadata(to_file_path(path_url)) { + stats.is_file() + } else { + false + } +} + +fn legacy_main_resolve( + package_json_url: &ModuleSpecifier, + package_config: &PackageConfig, + _base: &ModuleSpecifier, +) -> Result { + let mut guess; + + if let Some(main) = &package_config.main { + guess = package_json_url.join(&format!("./{}", main))?; + if file_exists(&guess) { + return Ok(guess); + } + + let mut found = false; + for ext in [ + ".js", + ".json", + ".node", + "/index.js", + "/index.json", + "/index.node", + ] { + guess = package_json_url.join(&format!("./{}{}", main, ext))?; + if file_exists(&guess) { + found = true; + break; + } + } + + if found { + // TODO(bartlomieju): emitLegacyIndexDeprecation() + return Ok(guess); + } + } + + for p in ["./index.js", "./index.json", "./index.node"] { + guess = package_json_url.join(p)?; + if file_exists(&guess) { + // TODO(bartlomieju): emitLegacyIndexDeprecation() + return Ok(guess); + } + } + + Err(generic_error("not found")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn testdir(name: &str) -> PathBuf { + let c = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + c.join("compat/testdata/").join(name) + } + + #[test] + fn basic() { + let cwd = testdir("basic"); + let main = Url::from_file_path(cwd.join("main.js")).unwrap(); + let actual = node_resolve("foo", main.as_str(), &cwd).unwrap(); + let expected = + Url::from_file_path(cwd.join("node_modules/foo/index.js")).unwrap(); + assert_eq!(actual, expected); + + let actual = node_resolve( + "data:application/javascript,console.log(\"Hello%20Deno\");", + main.as_str(), + &cwd, + ) + .unwrap(); + eprintln!("actual {}", actual); + assert_eq!( + actual, + Url::parse("data:application/javascript,console.log(\"Hello%20Deno\");") + .unwrap() + ); + } + + #[test] + fn deep() { + let cwd = testdir("deep"); + let main = Url::from_file_path(cwd.join("a/b/c/d/main.js")).unwrap(); + let actual = node_resolve("foo", main.as_str(), &cwd).unwrap(); + let expected = + Url::from_file_path(cwd.join("node_modules/foo/index.js")).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn basic_deps() { + let cwd = testdir("basic_deps"); + let main = Url::from_file_path(cwd.join("main.js")).unwrap(); + let actual = node_resolve("foo", main.as_str(), &cwd).unwrap(); + let foo_js = + Url::from_file_path(cwd.join("node_modules/foo/foo.js")).unwrap(); + assert_eq!(actual, foo_js); + + let actual = node_resolve("bar", foo_js.as_str(), &cwd).unwrap(); + + let bar_js = + Url::from_file_path(cwd.join("node_modules/bar/bar.js")).unwrap(); + assert_eq!(actual, bar_js); + } + + #[test] + fn builtin_http() { + let cwd = testdir("basic"); + let main = Url::from_file_path(cwd.join("main.js")).unwrap(); + let expected = + Url::parse("https://deno.land/std@0.112.0/node/http.ts").unwrap(); + + let actual = node_resolve("http", main.as_str(), &cwd).unwrap(); + println!("actual {}", actual); + assert_eq!(actual, expected); + + let actual = node_resolve("node:http", main.as_str(), &cwd).unwrap(); + println!("actual {}", actual); + assert_eq!(actual, expected); + } + + #[test] + fn conditional_exports() { + // check that `exports` mapping works correctly + let cwd = testdir("conditions"); + let main = Url::from_file_path(cwd.join("main.js")).unwrap(); + let actual = node_resolve("imports_exports", main.as_str(), &cwd).unwrap(); + let expected = Url::from_file_path( + cwd.join("node_modules/imports_exports/import_export.js"), + ) + .unwrap(); + assert_eq!(actual, expected); + + // check that `imports` mapping works correctly + let cwd = testdir("conditions/node_modules/imports_exports"); + let main = Url::from_file_path(cwd.join("import_export.js")).unwrap(); + let actual = node_resolve("#dep", main.as_str(), &cwd).unwrap(); + let expected = Url::from_file_path(cwd.join("import_polyfill.js")).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn test_is_relative_specifier() { + assert!(is_relative_specifier("./foo.js")); + assert!(!is_relative_specifier("https://deno.land/std/node/http.ts")); + } +} diff --git a/cli/compat/mod.rs b/cli/compat/mod.rs new file mode 100644 index 0000000000000..b95b65ddb7cb7 --- /dev/null +++ b/cli/compat/mod.rs @@ -0,0 +1,132 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +mod errors; +mod esm_resolver; + +use deno_core::error::AnyError; +use deno_core::located_script_name; +use deno_core::url::Url; +use deno_core::JsRuntime; + +pub use esm_resolver::NodeEsmResolver; + +// TODO(bartlomieju): this needs to be bumped manually for +// each release, a better mechanism is preferable, but it's a quick and dirty +// solution to avoid printing `X-Deno-Warning` headers when the compat layer is +// downloaded +static STD_URL_STR: &str = "https://deno.land/std@0.112.0/"; + +static SUPPORTED_MODULES: &[&str] = &[ + "assert", + "assert/strict", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "dns", + "domain", + "events", + "fs", + "fs/promises", + "http", + "https", + "module", + "net", + "os", + "path", + "path/posix", + "path/win32", + "perf_hooks", + "process", + "querystring", + "readline", + "stream", + "stream/promises", + "stream/web", + "string_decoder", + "sys", + "timers", + "timers/promises", + "tls", + "tty", + "url", + "util", + "util/types", + "v8", + "vm", + "zlib", +]; + +lazy_static::lazy_static! { + static ref GLOBAL_URL_STR: String = format!("{}node/global.ts", STD_URL_STR); + pub(crate) static ref GLOBAL_URL: Url = Url::parse(&GLOBAL_URL_STR).unwrap(); + static ref MODULE_URL_STR: String = format!("{}node/module.ts", STD_URL_STR); + pub(crate) static ref MODULE_URL: Url = Url::parse(&MODULE_URL_STR).unwrap(); + static ref COMPAT_IMPORT_URL: Url = Url::parse("flags:compat").unwrap(); +} + +/// Provide imports into a module graph when the compat flag is true. +pub(crate) fn get_node_imports() -> Vec<(Url, Vec)> { + vec![(COMPAT_IMPORT_URL.clone(), vec![GLOBAL_URL_STR.clone()])] +} + +fn try_resolve_builtin_module(specifier: &str) -> Option { + if SUPPORTED_MODULES.contains(&specifier) { + let module_url = format!("{}node/{}.ts", STD_URL_STR, specifier); + Some(Url::parse(&module_url).unwrap()) + } else { + None + } +} + +pub async fn check_if_should_use_esm_loader( + js_runtime: &mut JsRuntime, + main_module: &str, +) -> Result { + // Decide if we're running with Node ESM loader or CJS loader. + let source_code = &format!( + r#"(async function checkIfEsm(main) {{ + const {{ resolveMainPath, shouldUseESMLoader }} = await import("{}"); + const resolvedMain = resolveMainPath(main); + const useESMLoader = shouldUseESMLoader(resolvedMain); + return useESMLoader; + }})('{}');"#, + MODULE_URL_STR.as_str(), + escape_for_single_quote_string(main_module), + ); + let result = + js_runtime.execute_script(&located_script_name!(), source_code)?; + let use_esm_loader_global = js_runtime.resolve_value(result).await?; + let use_esm_loader = { + let scope = &mut js_runtime.handle_scope(); + let use_esm_loader_local = use_esm_loader_global.get(scope); + use_esm_loader_local.boolean_value(scope) + }; + + Ok(use_esm_loader) +} + +pub fn load_cjs_module( + js_runtime: &mut JsRuntime, + main_module: &str, +) -> Result<(), AnyError> { + let source_code = &format!( + r#"(async function loadCjsModule(main) {{ + const Module = await import("{}"); + Module.default._load(main, null, true); + }})('{}');"#, + MODULE_URL_STR.as_str(), + escape_for_single_quote_string(main_module), + ); + + js_runtime.execute_script(&located_script_name!(), source_code)?; + Ok(()) +} + +fn escape_for_single_quote_string(text: &str) -> String { + text.replace(r"\", r"\\").replace("'", r"\'") +} diff --git a/cli/compat/testdata/basic/main.js b/cli/compat/testdata/basic/main.js new file mode 100644 index 0000000000000..c0748305d5357 --- /dev/null +++ b/cli/compat/testdata/basic/main.js @@ -0,0 +1 @@ +import "foo"; diff --git a/cli/compat/testdata/basic/node_modules/foo/index.js b/cli/compat/testdata/basic/node_modules/foo/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/cli/compat/testdata/basic/node_modules/foo/package.json b/cli/compat/testdata/basic/node_modules/foo/package.json new file mode 100644 index 0000000000000..a74d52fd35193 --- /dev/null +++ b/cli/compat/testdata/basic/node_modules/foo/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "type": "module", + "exports": "./index.js" +} diff --git a/cli/compat/testdata/basic/package.json b/cli/compat/testdata/basic/package.json new file mode 100644 index 0000000000000..cc4ac5493293f --- /dev/null +++ b/cli/compat/testdata/basic/package.json @@ -0,0 +1,7 @@ +{ + "name": "bar", + "type": "module", + "dependencies": { + "foo": "1.0.0" + } +} diff --git a/cli/compat/testdata/basic_deps/main.js b/cli/compat/testdata/basic_deps/main.js new file mode 100644 index 0000000000000..c0748305d5357 --- /dev/null +++ b/cli/compat/testdata/basic_deps/main.js @@ -0,0 +1 @@ +import "foo"; diff --git a/cli/compat/testdata/basic_deps/node_modules/bar/bar.js b/cli/compat/testdata/basic_deps/node_modules/bar/bar.js new file mode 100644 index 0000000000000..98e51675eab6f --- /dev/null +++ b/cli/compat/testdata/basic_deps/node_modules/bar/bar.js @@ -0,0 +1 @@ +export const BAR = 123; diff --git a/cli/compat/testdata/basic_deps/node_modules/bar/package.json b/cli/compat/testdata/basic_deps/node_modules/bar/package.json new file mode 100644 index 0000000000000..c2043f6100c02 --- /dev/null +++ b/cli/compat/testdata/basic_deps/node_modules/bar/package.json @@ -0,0 +1,6 @@ +{ + "name": "bar", + "version": "0.1.2", + "type": "module", + "exports": "./bar.js" +} diff --git a/cli/compat/testdata/basic_deps/node_modules/foo/foo.js b/cli/compat/testdata/basic_deps/node_modules/foo/foo.js new file mode 100644 index 0000000000000..0026acc8ebc14 --- /dev/null +++ b/cli/compat/testdata/basic_deps/node_modules/foo/foo.js @@ -0,0 +1 @@ +import "bar"; diff --git a/cli/compat/testdata/basic_deps/node_modules/foo/package.json b/cli/compat/testdata/basic_deps/node_modules/foo/package.json new file mode 100644 index 0000000000000..376dae81e635a --- /dev/null +++ b/cli/compat/testdata/basic_deps/node_modules/foo/package.json @@ -0,0 +1,8 @@ +{ + "name": "foo", + "type": "module", + "exports": "./foo.js", + "dependencies": { + "bar": "0.1.2" + } +} diff --git a/cli/compat/testdata/basic_deps/package.json b/cli/compat/testdata/basic_deps/package.json new file mode 100644 index 0000000000000..138d401ed7a97 --- /dev/null +++ b/cli/compat/testdata/basic_deps/package.json @@ -0,0 +1,7 @@ +{ + "name": "main_program", + "type": "module", + "dependencies": { + "foo": "1.0.0" + } +} diff --git a/cli/compat/testdata/conditions/main.js b/cli/compat/testdata/conditions/main.js new file mode 100644 index 0000000000000..cafddb5d68645 --- /dev/null +++ b/cli/compat/testdata/conditions/main.js @@ -0,0 +1 @@ +import "imports_exports"; diff --git a/cli/compat/testdata/conditions/node_modules/imports_exports/import_export.js b/cli/compat/testdata/conditions/node_modules/imports_exports/import_export.js new file mode 100644 index 0000000000000..3ebd222ea901a --- /dev/null +++ b/cli/compat/testdata/conditions/node_modules/imports_exports/import_export.js @@ -0,0 +1,6 @@ +import dep from "#dep"; + +export default { + bar: "bar", + dep, +}; diff --git a/cli/compat/testdata/conditions/node_modules/imports_exports/import_polyfill.js b/cli/compat/testdata/conditions/node_modules/imports_exports/import_polyfill.js new file mode 100644 index 0000000000000..76716a3ef4719 --- /dev/null +++ b/cli/compat/testdata/conditions/node_modules/imports_exports/import_polyfill.js @@ -0,0 +1,3 @@ +export default { + polyfill: "import", +}; diff --git a/cli/compat/testdata/conditions/node_modules/imports_exports/package.json b/cli/compat/testdata/conditions/node_modules/imports_exports/package.json new file mode 100644 index 0000000000000..5d26359db3485 --- /dev/null +++ b/cli/compat/testdata/conditions/node_modules/imports_exports/package.json @@ -0,0 +1,17 @@ +{ + "version": "1.0.0", + "name": "imports_exports", + "main": "./require_export.cjs", + "imports": { + "#dep": { + "import": "./import_polyfill.js", + "require": "./require_polyfill.js" + } + }, + "exports": { + ".": { + "import": "./import_export.js", + "require": "./require_export.cjs" + } + } +} diff --git a/cli/compat/testdata/conditions/node_modules/imports_exports/require_export.cjs b/cli/compat/testdata/conditions/node_modules/imports_exports/require_export.cjs new file mode 100644 index 0000000000000..11648c0d71f80 --- /dev/null +++ b/cli/compat/testdata/conditions/node_modules/imports_exports/require_export.cjs @@ -0,0 +1,6 @@ +const dep = require("#dep"); + +module.exports = { + foo: "foo", + dep, +}; \ No newline at end of file diff --git a/cli/compat/testdata/conditions/node_modules/imports_exports/require_polyfill.js b/cli/compat/testdata/conditions/node_modules/imports_exports/require_polyfill.js new file mode 100644 index 0000000000000..1023fd65cbe3d --- /dev/null +++ b/cli/compat/testdata/conditions/node_modules/imports_exports/require_polyfill.js @@ -0,0 +1,3 @@ +module.exports = { + polyfill: "require", +}; diff --git a/cli/compat/testdata/conditions/package.json b/cli/compat/testdata/conditions/package.json new file mode 100644 index 0000000000000..4d6d004dd3ee5 --- /dev/null +++ b/cli/compat/testdata/conditions/package.json @@ -0,0 +1,7 @@ +{ + "name": "conditions", + "type": "module", + "dependencies": { + "imports_exports": "1.0.0" + } +} diff --git a/cli/compat/testdata/deep/a/b/c/d/main.js b/cli/compat/testdata/deep/a/b/c/d/main.js new file mode 100644 index 0000000000000..c0748305d5357 --- /dev/null +++ b/cli/compat/testdata/deep/a/b/c/d/main.js @@ -0,0 +1 @@ +import "foo"; diff --git a/cli/compat/testdata/deep/node_modules/foo/index.js b/cli/compat/testdata/deep/node_modules/foo/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/cli/compat/testdata/deep/node_modules/foo/package.json b/cli/compat/testdata/deep/node_modules/foo/package.json new file mode 100644 index 0000000000000..a74d52fd35193 --- /dev/null +++ b/cli/compat/testdata/deep/node_modules/foo/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "type": "module", + "exports": "./index.js" +} diff --git a/cli/main.rs b/cli/main.rs index 8a85cacfbc44c..9dd7a354ee080 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -1092,6 +1092,11 @@ async fn run_command( return run_with_watch(flags, run_flags.script).await; } + // TODO(bartlomieju): it should not be resolved here if we're in compat mode + // because it might be a bare specifier + // 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 - this should + // probably call `ProcState::resolve` instead let main_module = resolve_url_or_path(&run_flags.script)?; let ps = ProcState::build(flags.clone()).await?; let permissions = Permissions::from_options(&flags.clone().into()); @@ -1114,10 +1119,41 @@ async fn run_command( }; debug!("main_module {}", main_module); + if flags.compat { + // TODO(bartlomieju): fix me + assert_eq!(main_module.scheme(), "file"); + + // Set up Node globals worker.execute_side_module(&compat::GLOBAL_URL).await?; + // And `module` module that we'll use for checking which + // loader to use and potentially load CJS module with. + // This allows to skip permission check for `--allow-net` + // which would otherwise be requested by dynamically importing + // this file. + worker.execute_side_module(&compat::MODULE_URL).await?; + + let use_esm_loader = compat::check_if_should_use_esm_loader( + &mut worker.js_runtime, + &main_module.to_file_path().unwrap().display().to_string(), + ) + .await?; + + if use_esm_loader { + // ES module execution in Node compatiblity mode + worker.execute_main_module(&main_module).await?; + } else { + // CJS module execution in Node compatiblity mode + compat::load_cjs_module( + &mut worker.js_runtime, + &main_module.to_file_path().unwrap().display().to_string(), + )?; + } + } else { + // Regular ES module execution + worker.execute_main_module(&main_module).await?; } - worker.execute_main_module(&main_module).await?; + worker.execute_script( &located_script_name!(), "window.dispatchEvent(new Event('load'))", diff --git a/cli/proc_state.rs b/cli/proc_state.rs index e45e7c5395bae..461599fc486e2 100644 --- a/cli/proc_state.rs +++ b/cli/proc_state.rs @@ -3,6 +3,7 @@ use crate::cache; use crate::colors; use crate::compat; +use crate::compat::NodeEsmResolver; use crate::config_file::ConfigFile; use crate::deno_dir; use crate::emit; @@ -195,7 +196,7 @@ impl ProcState { None }; - let mut maybe_import_map: Option = + let maybe_import_map: Option = match flags.import_map_path.as_ref() { None => None, Some(import_map_url) => { @@ -217,32 +218,6 @@ impl ProcState { } }; - if flags.compat { - let mut import_map = match maybe_import_map { - Some(import_map) => import_map, - None => { - // INFO: we're creating an empty import map, with its specifier pointing - // to `CWD/node_import_map.json` to make sure the map still works as expected. - let import_map_specifier = - std::env::current_dir()?.join("node_import_map.json"); - ImportMap::from_json(import_map_specifier.to_str().unwrap(), "{}") - .unwrap() - } - }; - let node_builtins = compat::get_mapped_node_builtins(); - let diagnostics = import_map.update_imports(node_builtins)?; - - if !diagnostics.is_empty() { - log::info!("Some Node built-ins were not added to the import map:"); - for diagnostic in diagnostics { - log::info!(" - {}", diagnostic); - } - log::info!("If you want to use Node built-ins provided by Deno remove listed specifiers from \"imports\" mapping in the import map file."); - } - - maybe_import_map = Some(import_map); - } - let maybe_inspect_host = flags.inspect.or(flags.inspect_brk); let maybe_inspector_server = maybe_inspect_host.map(|host| { Arc::new(InspectorServer::new(host, version::get_user_agent())) @@ -316,14 +291,29 @@ impl ProcState { ); let maybe_locker = as_maybe_locker(self.lockfile.clone()); let maybe_imports = self.get_maybe_imports(); - let maybe_resolver = + let node_resolver = NodeEsmResolver; + let import_map_resolver = self.maybe_import_map.as_ref().map(ImportMapResolver::new); + let maybe_resolver = if self.flags.compat { + Some(node_resolver.as_resolver()) + } else { + import_map_resolver.as_ref().map(|im| im.as_resolver()) + }; + // TODO(bartlomieju): this is very make-shift, is there an existing API + // that we could include it like with "maybe_imports"? + let roots = if self.flags.compat { + let mut r = vec![compat::GLOBAL_URL.clone()]; + r.extend(roots); + r + } else { + roots + }; let graph = deno_graph::create_graph( roots, is_dynamic, maybe_imports, &mut cache, - maybe_resolver.as_ref().map(|im| im.as_resolver()), + maybe_resolver, maybe_locker, None, ) diff --git a/cli/tests/integration/compat_tests.rs b/cli/tests/integration/compat_tests.rs index 6b6ab81b54ffc..17388a78e8a01 100644 --- a/cli/tests/integration/compat_tests.rs +++ b/cli/tests/integration/compat_tests.rs @@ -9,21 +9,15 @@ itest!(globals { }); itest!(fs_promises { - args: "run --compat --unstable -A compat/fs_promises.js", + args: "run --compat --unstable -A compat/fs_promises.mjs", output: "compat/fs_promises.out", }); itest!(node_prefix_fs_promises { - args: "run --compat --unstable -A compat/node_fs_promises.js", + args: "run --compat --unstable -A compat/node_fs_promises.mjs", output: "compat/fs_promises.out", }); -itest!(existing_import_map { - args: "run --compat --unstable --import-map compat/existing_import_map.json compat/fs_promises.js", - output: "compat/existing_import_map.out", - exit_code: 1, -}); - #[test] fn globals_in_repl() { let (out, _err) = util::run_and_collect_output_with_args( diff --git a/cli/tests/testdata/compat/existing_import_map.json b/cli/tests/testdata/compat/existing_import_map.json deleted file mode 100644 index db59c0cc2400d..0000000000000 --- a/cli/tests/testdata/compat/existing_import_map.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "imports": { - "fs/promises": "./non_existent_file.js" - } -} diff --git a/cli/tests/testdata/compat/existing_import_map.out b/cli/tests/testdata/compat/existing_import_map.out deleted file mode 100644 index 46125d411640a..0000000000000 --- a/cli/tests/testdata/compat/existing_import_map.out +++ /dev/null @@ -1,7 +0,0 @@ -[WILDCARD] -Some Node built-ins were not added to the import map: - - "fs/promises" already exists and is mapped to "file://[WILDCARD]/non_existent_file.js" -If you want to use Node built-ins provided by Deno remove listed specifiers from "imports" mapping in the import map file. -[WILDCARD] -error: Cannot load module "file://[WILDCARD]/non_existent_file.js". - at file://[WILDCARD]/fs_promises.js:1:16 diff --git a/cli/tests/testdata/compat/fs_promises.js b/cli/tests/testdata/compat/fs_promises.mjs similarity index 100% rename from cli/tests/testdata/compat/fs_promises.js rename to cli/tests/testdata/compat/fs_promises.mjs diff --git a/cli/tests/testdata/compat/globals.out b/cli/tests/testdata/compat/globals.out index 0bc09137be019..32230fda37dfd 100644 --- a/cli/tests/testdata/compat/globals.out +++ b/cli/tests/testdata/compat/globals.out @@ -2,6 +2,8 @@ process { [WILDCARD] } -[Function: Buffer] +[Function: Buffer] { +[WILDCARD] +} [Function: setImmediate] [Function: clearTimeout] diff --git a/cli/tests/testdata/compat/node_fs_promises.js b/cli/tests/testdata/compat/node_fs_promises.mjs similarity index 100% rename from cli/tests/testdata/compat/node_fs_promises.js rename to cli/tests/testdata/compat/node_fs_promises.mjs