summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBartek IwaƄczuk <biwanczuk@gmail.com>2022-02-27 14:38:45 +0100
committerGitHub <noreply@github.com>2022-02-27 14:38:45 +0100
commita65ce33fabb44bb2d9ed04773f7f334ed9c9a6b5 (patch)
treef5c169945377c3f806b514162408b81b5611ad44
parent4bea1d06c7ddb177ed20e0f32b70d7ff889871ab (diff)
feat(compat): CJS/ESM interoperability (#13553)
This commit adds CJS/ESM interoperability when running in --compat mode. Before executing files, they are analyzed and all CommonJS modules are transformed on the fly to a ES modules. This is done by utilizing analyze_cjs() functionality from deno_ast. After discovering exports and reexports, an ES module is rendered and saved in memory for later use. There's a caveat that all files ending with ".js" extension are considered as CommonJS modules (unless there's a related "package.json" with "type": "module").
-rw-r--r--Cargo.lock12
-rw-r--r--cli/Cargo.toml3
-rw-r--r--cli/compat/mod.rs97
-rw-r--r--cli/graph_util.rs23
-rw-r--r--cli/proc_state.rs37
-rw-r--r--cli/tests/integration/compat_tests.rs6
-rw-r--r--cli/tests/testdata/compat/import_cjs_from_esm.out1
-rw-r--r--cli/tests/testdata/compat/import_cjs_from_esm/imported.js9
-rw-r--r--cli/tests/testdata/compat/import_cjs_from_esm/main.mjs1
-rw-r--r--cli/tests/testdata/compat/import_cjs_from_esm/reexports.js1
-rw-r--r--cli/tests/testdata/compat/import_cjs_from_esm/reexports2.js2
11 files changed, 190 insertions, 2 deletions
diff --git a/Cargo.lock b/Cargo.lock
index f7a7e8cf6..c160cf8d5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -772,6 +772,7 @@ dependencies = [
"log",
"lspower",
"nix",
+ "node_resolver",
"notify",
"once_cell",
"os_pipe",
@@ -2467,6 +2468,17 @@ dependencies = [
]
[[package]]
+name = "node_resolver"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35ed1604f6f4e33b51926d6be3bc09423f35eec776165c460c313dc2970ea5a"
+dependencies = [
+ "anyhow",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
name = "notify"
version = "5.0.0-pre.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index b50efa09e..36a446dab 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -45,7 +45,7 @@ winapi = "=0.3.9"
winres = "=0.1.11"
[dependencies]
-deno_ast = { version = "0.12.0", features = ["bundler", "codegen", "dep_graph", "module_specifier", "proposal", "react", "sourcemap", "transforms", "transpiling", "typescript", "view", "visit"] }
+deno_ast = { version = "0.12.0", features = ["bundler", "cjs", "codegen", "dep_graph", "module_specifier", "proposal", "react", "sourcemap", "transforms", "transpiling", "typescript", "view", "visit"] }
deno_core = { version = "0.120.0", path = "../core" }
deno_doc = "0.32.0"
deno_graph = "0.24.0"
@@ -74,6 +74,7 @@ jsonc-parser = { version = "=0.19.0", features = ["serde"] }
libc = "=0.2.106"
log = { version = "=0.4.14", features = ["serde"] }
lspower = "=1.4.0"
+node_resolver = "0.1.0"
notify = "=5.0.0-pre.12"
once_cell = "=1.9.0"
percent-encoding = "=2.1.0"
diff --git a/cli/compat/mod.rs b/cli/compat/mod.rs
index 0c30a58fc..0f1c36084 100644
--- a/cli/compat/mod.rs
+++ b/cli/compat/mod.rs
@@ -3,11 +3,15 @@
mod errors;
mod esm_resolver;
+use crate::file_fetcher::FileFetcher;
+use deno_ast::MediaType;
use deno_core::error::AnyError;
use deno_core::located_script_name;
use deno_core::url::Url;
use deno_core::JsRuntime;
+use deno_core::ModuleSpecifier;
use once_cell::sync::Lazy;
+use std::sync::Arc;
pub use esm_resolver::check_if_should_use_esm_loader;
pub(crate) use esm_resolver::NodeEsmResolver;
@@ -155,3 +159,96 @@ pub fn setup_builtin_modules(
js_runtime.execute_script("setup_node_builtins.js", &script)?;
Ok(())
}
+
+/// Translates given CJS module into ESM. This function will perform static
+/// analysis on the file to find defined exports and reexports.
+///
+/// For all discovered reexports the analysis will be performed recursively.
+///
+/// If successful a source code for equivalent ES module is returned.
+pub async fn translate_cjs_to_esm(
+ file_fetcher: &FileFetcher,
+ specifier: &ModuleSpecifier,
+ code: String,
+ media_type: MediaType,
+) -> Result<String, AnyError> {
+ let parsed_source = deno_ast::parse_script(deno_ast::ParseParams {
+ specifier: specifier.to_string(),
+ source: deno_ast::SourceTextInfo::new(Arc::new(code)),
+ media_type,
+ capture_tokens: true,
+ scope_analysis: false,
+ maybe_syntax: None,
+ })?;
+ let analysis = parsed_source.analyze_cjs();
+
+ let mut source = vec![
+ r#"import { createRequire } from "node:module";"#.to_string(),
+ r#"const require = createRequire(import.meta.url);"#.to_string(),
+ ];
+
+ // if there are reexports, handle them first
+ for (idx, reexport) in analysis.reexports.iter().enumerate() {
+ // Firstly, resolve relate reexport specifier
+ let resolved_reexport = node_resolver::node_resolve(
+ reexport,
+ &specifier.to_file_path().unwrap(),
+ // FIXME(bartlomieju): check if these conditions are okay, probably
+ // should be `deno-require`, because `deno` is already used in `esm_resolver.rs`
+ &["deno", "require", "default"],
+ )?;
+ let reexport_specifier =
+ ModuleSpecifier::from_file_path(&resolved_reexport).unwrap();
+ // Secondly, read the source code from disk
+ let reexport_file = file_fetcher.get_source(&reexport_specifier).unwrap();
+ // Now perform analysis again
+ {
+ let parsed_source = deno_ast::parse_script(deno_ast::ParseParams {
+ specifier: reexport_specifier.to_string(),
+ source: deno_ast::SourceTextInfo::new(reexport_file.source),
+ media_type: reexport_file.media_type,
+ capture_tokens: true,
+ scope_analysis: false,
+ maybe_syntax: None,
+ })?;
+ let analysis = parsed_source.analyze_cjs();
+
+ source.push(format!(
+ "const reexport{} = require(\"{}\");",
+ idx, reexport
+ ));
+
+ for export in analysis.exports.iter().filter(|e| e.as_str() != "default")
+ {
+ // TODO(bartlomieju): Node actually checks if a given export exists in `exports` object,
+ // but it might not be necessary here since our analysis is more detailed?
+ source.push(format!(
+ "export const {} = reexport{}.{};",
+ export, idx, export
+ ));
+ }
+ }
+ }
+
+ source.push(format!(
+ "const mod = require(\"{}\");",
+ specifier
+ .to_file_path()
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .replace('\\', "\\\\")
+ .replace('\'', "\\\'")
+ .replace('\"', "\\\"")
+ ));
+ source.push("export default mod".to_string());
+
+ for export in analysis.exports.iter().filter(|e| e.as_str() != "default") {
+ // TODO(bartlomieju): Node actually checks if a given export exists in `exports` object,
+ // but it might not be necessary here since our analysis is more detailed?
+ source.push(format!("export const {} = mod.{};", export, export));
+ }
+
+ let translated_source = source.join("\n");
+ Ok(translated_source)
+}
diff --git a/cli/graph_util.rs b/cli/graph_util.rs
index f0a38b7a1..4b01f54e0 100644
--- a/cli/graph_util.rs
+++ b/cli/graph_util.rs
@@ -53,6 +53,7 @@ pub(crate) struct GraphData {
/// error messages.
referrer_map: HashMap<ModuleSpecifier, Range>,
configurations: HashSet<ModuleSpecifier>,
+ cjs_esm_translations: HashMap<ModuleSpecifier, String>,
}
impl GraphData {
@@ -254,6 +255,7 @@ impl GraphData {
modules,
referrer_map,
configurations: self.configurations.clone(),
+ cjs_esm_translations: Default::default(),
})
}
@@ -412,6 +414,27 @@ impl GraphData {
) -> Option<&'a ModuleEntry> {
self.modules.get(specifier)
}
+
+ // TODO(bartlomieju): after saving translated source
+ // it's never removed, potentially leading to excessive
+ // memory consumption
+ pub(crate) fn add_cjs_esm_translation(
+ &mut self,
+ specifier: &ModuleSpecifier,
+ source: String,
+ ) {
+ let prev = self
+ .cjs_esm_translations
+ .insert(specifier.to_owned(), source);
+ assert!(prev.is_none());
+ }
+
+ pub(crate) fn get_cjs_esm_translation<'a>(
+ &'a self,
+ specifier: &ModuleSpecifier,
+ ) -> Option<&'a String> {
+ self.cjs_esm_translations.get(specifier)
+ }
}
impl From<&ModuleGraph> for GraphData {
diff --git a/cli/proc_state.rs b/cli/proc_state.rs
index 36c7e08d4..8933cb986 100644
--- a/cli/proc_state.rs
+++ b/cli/proc_state.rs
@@ -340,6 +340,34 @@ impl ProcState {
None,
)
.await;
+
+ let needs_cjs_esm_translation = graph
+ .modules()
+ .iter()
+ .any(|m| m.kind == ModuleKind::CommonJs);
+
+ if needs_cjs_esm_translation {
+ for module in graph.modules() {
+ // TODO(bartlomieju): this is overly simplistic heuristic, once we are
+ // in compat mode, all files ending with plain `.js` extension are
+ // considered CommonJs modules. Which leads to situation where valid
+ // ESM modules with `.js` extension might undergo translation (it won't
+ // work in this situation).
+ if module.kind == ModuleKind::CommonJs {
+ let translated_source = compat::translate_cjs_to_esm(
+ &self.file_fetcher,
+ &module.specifier,
+ module.maybe_source.as_ref().unwrap().to_string(),
+ module.media_type,
+ )
+ .await?;
+ let mut graph_data = self.graph_data.write();
+ graph_data
+ .add_cjs_esm_translation(&module.specifier, translated_source);
+ }
+ }
+ }
+
// If there was a locker, validate the integrity of all the modules in the
// locker.
graph_lock_or_exit(&graph);
@@ -506,7 +534,14 @@ impl ProcState {
| MediaType::Unknown
| MediaType::Cjs
| MediaType::Mjs
- | MediaType::Json => code.as_ref().clone(),
+ | MediaType::Json => {
+ if let Some(source) = graph_data.get_cjs_esm_translation(&specifier)
+ {
+ source.to_owned()
+ } else {
+ code.as_ref().clone()
+ }
+ }
MediaType::Dts => "".to_string(),
_ => {
let emit_path = self
diff --git a/cli/tests/integration/compat_tests.rs b/cli/tests/integration/compat_tests.rs
index 189e1eb41..c8fc1c0a0 100644
--- a/cli/tests/integration/compat_tests.rs
+++ b/cli/tests/integration/compat_tests.rs
@@ -95,6 +95,12 @@ itest!(compat_worker {
output: "compat/worker/worker_test.out",
});
+itest!(cjs_esm_interop {
+ args:
+ "run --compat --unstable -A --quiet --no-check compat/import_cjs_from_esm/main.mjs",
+ output: "compat/import_cjs_from_esm.out",
+});
+
#[test]
fn globals_in_repl() {
let (out, _err) = util::run_and_collect_output_with_args(
diff --git a/cli/tests/testdata/compat/import_cjs_from_esm.out b/cli/tests/testdata/compat/import_cjs_from_esm.out
new file mode 100644
index 000000000..ffaa5e406
--- /dev/null
+++ b/cli/tests/testdata/compat/import_cjs_from_esm.out
@@ -0,0 +1 @@
+{ a: "A", b: "B", foo: "foo", bar: "bar", fizz: { buzz: "buzz", fizz: "FIZZ" } }
diff --git a/cli/tests/testdata/compat/import_cjs_from_esm/imported.js b/cli/tests/testdata/compat/import_cjs_from_esm/imported.js
new file mode 100644
index 000000000..49ab4c782
--- /dev/null
+++ b/cli/tests/testdata/compat/import_cjs_from_esm/imported.js
@@ -0,0 +1,9 @@
+exports = {
+ a: "A",
+ b: "B",
+};
+exports.foo = "foo";
+exports.bar = "bar";
+exports.fizz = require("./reexports.js");
+
+console.log(exports);
diff --git a/cli/tests/testdata/compat/import_cjs_from_esm/main.mjs b/cli/tests/testdata/compat/import_cjs_from_esm/main.mjs
new file mode 100644
index 000000000..6fbed1b7c
--- /dev/null
+++ b/cli/tests/testdata/compat/import_cjs_from_esm/main.mjs
@@ -0,0 +1 @@
+import "./imported.js";
diff --git a/cli/tests/testdata/compat/import_cjs_from_esm/reexports.js b/cli/tests/testdata/compat/import_cjs_from_esm/reexports.js
new file mode 100644
index 000000000..62edb7708
--- /dev/null
+++ b/cli/tests/testdata/compat/import_cjs_from_esm/reexports.js
@@ -0,0 +1 @@
+module.exports = require("./reexports2.js");
diff --git a/cli/tests/testdata/compat/import_cjs_from_esm/reexports2.js b/cli/tests/testdata/compat/import_cjs_from_esm/reexports2.js
new file mode 100644
index 000000000..183d833b0
--- /dev/null
+++ b/cli/tests/testdata/compat/import_cjs_from_esm/reexports2.js
@@ -0,0 +1,2 @@
+exports.buzz = "buzz";
+exports.fizz = "FIZZ";