diff --git a/crates/swc_ecma_loader/src/resolvers/exports.rs b/crates/swc_ecma_loader/src/resolvers/exports.rs new file mode 100644 index 000000000000..80c64b452ef9 --- /dev/null +++ b/crates/swc_ecma_loader/src/resolvers/exports.rs @@ -0,0 +1,273 @@ +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::path::PathBuf; + +use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor}; + +/// The parsed representation of the "exports" field in a package.json file. +/// See https://nodejs.org/api/packages.html#package-entry-points for syntax. +#[derive(Debug)] +pub(super) struct Exports { + subpaths: BTreeMap +} + +impl Exports { + /// Resolves a relative path to a target file path. + pub fn resolve_import_path(&self, rel_path: &str, conditions: &HashSet<&str>) -> Option { + let mut wildcard_match = None; + for (candidate, subpath) in self.subpaths.iter() { + match candidate_matches(candidate, rel_path) { + None => continue, + Some(Match::Exact) => { + return subpath.matches(&conditions).and_then(|m| { + match m { + SubpathMatch::Target(path) => Some(path.into()), + SubpathMatch::Exclude => None, + } + }); + } + Some(Match::Wildcard { replacement }) => { + match subpath.matches(&conditions) { + None => continue, + + // If we have a target, save it as a candidate. + // We have to keep looking as there may be an exclude directive later. + Some(SubpathMatch::Target(path)) => { + if wildcard_match.is_none() { + wildcard_match = Some((path, replacement)); + } + } + + // If we have an exclude, stop looking and immediately return none. + Some(SubpathMatch::Exclude) => return None, + } + } + } + } + + match wildcard_match { + None => None, + Some((path, replacement)) => { + Some(path.replace("*", replacement).into()) + } + } + } +} + + +enum Match<'a> { + Exact, + Wildcard { replacement: &'a str }, +} + +fn candidate_matches<'a>(candidate: &str, rel_path: &'a str) -> Option> { + let candidate = match (candidate, rel_path) { + (".", "") => return Some(Match::Exact), + (".", _) => return None, + (_, _) => { + // Strip "./" prefix. + let candidate = candidate.strip_prefix("./").unwrap_or(candidate); + if candidate == rel_path { + return Some(Match::Exact); + } + candidate + } + }; + + if let Some(idx) = candidate.find('*') { + let (prefix, suffix) = candidate.split_at(idx); + if rel_path.starts_with(prefix) && rel_path.ends_with(suffix) { + // Get the middle part of the path, to be injected into the result. + let replacement = &rel_path[prefix.len()..(rel_path.len() - suffix.len())]; + return Some(Match::Wildcard { replacement }) + } + } + + None +} + +#[derive(Debug, PartialEq, Eq)] +enum Subpath { + Target(String), + Conditions(BTreeMap), + Exclude, +} + +enum SubpathMatch<'a> { + Target(&'a str), + Exclude, +} + +impl Subpath { + fn matches(&self, active_conditions: &HashSet<&str>) -> Option { + match self { + Subpath::Target(path) => Some(SubpathMatch::Target(&path)), + Subpath::Exclude => Some(SubpathMatch::Exclude), + Subpath::Conditions(conds) => { + for (cond, subpath) in conds.iter() { + if active_conditions.contains(cond.as_str()) { + return subpath.matches(&active_conditions); + } + } + None + } + } + } +} + +// The "exports" value can be defined in lots of ways. +// +// - A single subpath, e.g. "exports": "./index.js". +// Syntactic sugar for {".": "./index.js"} +// +// - A condition spec: {"node": "./index.node.js"} +// Conditions can be nested: {"node": {"import": "./index.node.js", "default": "./index.js"}} +// +// - An ordered map of subpaths key-value pairs: {".": "./index.js", "./foo/*": "./foo/*.js"} +// - Each subpath value can be one of: +// - A single target path, e.g. "./index.js" (possibly with wildcards, "./foo/*.js") +// - A condition spec. +// - Null, indicating the subpath is not exported (overriding other exports that may match). + +impl<'de> Deserialize<'de> for Exports { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> + { + struct StringOrMap; + impl<'de> Visitor<'de> for StringOrMap { + type Value = Exports; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string or map") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let mut subpaths = BTreeMap::::new(); + subpaths.insert(".".into(), Subpath::Target(value.to_string())); + Ok(Exports { subpaths }) + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + + let mut subpaths = BTreeMap::new(); + + // Peek at the first entry to decide whether it's a map of subpaths or conditions. + let Some((key, value)) = access.next_entry::()? else { + // Empty map. + return Ok(Exports { subpaths }); + }; + + if !key.starts_with(".") { + let mut conditions: BTreeMap = Deserialize::deserialize(de::value::MapAccessDeserializer::new(access))?; + conditions.insert(key, value); + subpaths.insert(".".to_string(), Subpath::Conditions(conditions)); + return Ok(Exports { subpaths }); + } + + subpaths.insert(key, value); + while let Some((key, value)) = access.next_entry::()? { + subpaths.insert(key, value); + } + + Ok(Exports { subpaths }) + } + } + + deserializer.deserialize_any(StringOrMap) + } +} + +struct SubpathVisitor; + +impl<'de> Visitor<'de> for SubpathVisitor { + type Value = Subpath; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string or map") + } + + fn visit_unit(self) -> Result where E: de::Error { + Ok(Subpath::Exclude) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(Subpath::Target(value.to_string())) + } + + fn visit_map(self, map: M) -> Result + where + M: MapAccess<'de>, + { + let conditions: BTreeMap = Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?; + Ok(Subpath::Conditions(conditions)) + } +} + +impl<'de> Deserialize<'de> for Subpath { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> + { + deserializer.deserialize_any(SubpathVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_subpaths() { + let exports: Exports = serde_json::from_str(r#"{ + ".": "./index.js", + "./foo/*": "./foo/*.js", + "./bar": {"node": "./bar.node.js", "default": "./bar.js"}, + "./baz": {"node": "./baz.node.js", "default": null}, + "./qux": null + }"#).unwrap(); + assert_eq!(exports.subpaths.len(), 5); + assert_eq!(exports.subpaths.get(".").unwrap(), &Subpath::Target("./index.js".into())); + assert_eq!(exports.subpaths.get("./foo/*").unwrap(), &Subpath::Target("./foo/*.js".into())); + assert_eq!(exports.subpaths.get("./bar").unwrap(), &Subpath::Conditions({ + let mut map = BTreeMap::new(); + map.insert("node".to_owned(), Subpath::Target("./bar.node.js".into())); + map.insert("default".to_owned(), Subpath::Target("./bar.js".into())); + map + })); + assert_eq!(exports.subpaths.get("./baz").unwrap(), &Subpath::Conditions({ + let mut map = BTreeMap::new(); + map.insert("node".to_owned(), Subpath::Target("./baz.node.js".into())); + map.insert("default".to_owned(), Subpath::Exclude); + map + })); + assert_eq!(exports.subpaths.get("./qux").unwrap(), &Subpath::Exclude); + } + + #[test] + fn parse_toplevel_conditions() { + let exports: Exports = serde_json::from_str(r#"{ + "node": {"import": "./bar.node.js", "default": "./bar.js"}, + "default": "./index.js" + }"#).unwrap(); + assert_eq!(exports.subpaths.len(), 1); + assert_eq!(exports.subpaths.get(".").unwrap(), &Subpath::Conditions({ + let mut map = BTreeMap::new(); + map.insert("node".to_owned(), Subpath::Conditions({ + let mut map = BTreeMap::new(); + map.insert("import".to_owned(), Subpath::Target("./bar.node.js".into())); + map.insert("default".to_owned(), Subpath::Target("./bar.js".into())); + map + })); + map.insert("default".to_owned(), Subpath::Target("./index.js".into())); + map + })); + } +} diff --git a/crates/swc_ecma_loader/src/resolvers/mod.rs b/crates/swc_ecma_loader/src/resolvers/mod.rs index c4c3f30d16cb..0d5c546d38ce 100644 --- a/crates/swc_ecma_loader/src/resolvers/mod.rs +++ b/crates/swc_ecma_loader/src/resolvers/mod.rs @@ -7,3 +7,6 @@ pub mod node; #[cfg(feature = "tsc")] #[cfg_attr(docsrs, doc(cfg(feature = "tsc")))] pub mod tsc; +#[cfg(feature = "node")] +#[cfg_attr(docsrs, doc(cfg(feature = "node")))] +mod exports; diff --git a/crates/swc_ecma_loader/src/resolvers/node.rs b/crates/swc_ecma_loader/src/resolvers/node.rs index 80ade412a995..2f50be38ddf5 100644 --- a/crates/swc_ecma_loader/src/resolvers/node.rs +++ b/crates/swc_ecma_loader/src/resolvers/node.rs @@ -8,6 +8,7 @@ use std::{ io::BufReader, path::{Component, Path, PathBuf}, }; +use std::collections::HashSet; use anyhow::{bail, Context, Error}; use dashmap::DashMap; @@ -24,6 +25,7 @@ use swc_common::{ use tracing::{debug, trace, Level}; use crate::{resolve::Resolve, TargetEnv, NODE_BUILTINS}; +use crate::resolvers::exports::Exports; static PACKAGE: &str = "package.json"; @@ -81,6 +83,8 @@ struct PackageJson { browser: Option, #[serde(default)] module: Option, + #[serde(default)] + exports: Option, } #[derive(Deserialize)] @@ -97,6 +101,7 @@ enum StringOrBool { Bool(bool), } + #[derive(Debug, Default)] pub struct NodeModulesResolver { target_env: TargetEnv, @@ -104,6 +109,7 @@ pub struct NodeModulesResolver { // if true do not resolve symlink preserve_symlinks: bool, ignore_node_modules: bool, + extra_export_conditions: Vec, } static EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "json", "node"]; @@ -120,6 +126,7 @@ impl NodeModulesResolver { alias, preserve_symlinks, ignore_node_modules: false, + extra_export_conditions: vec![], } } @@ -134,6 +141,22 @@ impl NodeModulesResolver { alias, preserve_symlinks, ignore_node_modules: true, + extra_export_conditions: vec![], + } + } + + pub fn with_export_conditions( + target_env: TargetEnv, + alias: AHashMap, + preserve_symlinks: bool, + extra_export_conditions: Vec, + ) -> Self { + Self { + target_env, + alias, + preserve_symlinks, + ignore_node_modules: false, + extra_export_conditions, } } @@ -148,6 +171,44 @@ impl NodeModulesResolver { bail!("index not found") } + /// Resolve a path from the "exports" directive in the package.json file, if present. + fn resolve_export( + &self, + pkg_dir: &Path, + rel_target: &str, + ) -> Result, Error> { + if cfg!(debug_assertions) { + trace!("resolve_export({:?}, {:?})", pkg_dir, rel_target); + } + + let package_json_path = pkg_dir.join(PACKAGE); + if !package_json_path.is_file() { + bail!("package.json not found: {}", package_json_path.display()); + } + + let file = File::open(&package_json_path)?; + let reader = BufReader::new(file); + let pkg: PackageJson = serde_json::from_reader(reader) + .context(format!("failed to deserialize {}", package_json_path.display()))?; + + let Some(exports) = &pkg.exports else { + bail!("no exports field in {}", package_json_path.display()); + }; + + let mut conditions = HashSet::from_iter(self.extra_export_conditions.iter().map(|s| s.as_str())); + conditions.extend({ + let slice : &[_] = match self.target_env { + TargetEnv::Node => &["node-addons", "node", "import", "require", "default"], + TargetEnv::Browser => &["browser", "import", "default"], + }; + slice + }); + + // The result is relative to the package directory, whereas we want to return an absolute path. + let result = exports.resolve_import_path(rel_target, &conditions).map(|p| p.to_path_buf()); + Ok(result.map(|p| pkg_dir.join(p))) + } + /// Resolve a path as a file. If `path` refers to a file, it is returned; /// otherwise the `path` + each extension is tried. fn resolve_as_file(&self, path: &Path) -> Result, Error> { @@ -226,6 +287,7 @@ impl NodeModulesResolver { bail!("file not found: {}", path.display()) } + /// Resolve a path as a directory, using the "main" key from a package.json /// file if it exists, or resolving to the index.EXT file if it exists. fn resolve_as_directory( @@ -394,10 +456,12 @@ impl NodeModulesResolver { while let Some(dir) = path { let node_modules = dir.join("node_modules"); if node_modules.is_dir() { + let (pkg_name, pkg_path) = self.pkg_name_from_target(target); let path = node_modules.join(target); + let pkg_dir = node_modules.join(pkg_name); if let Some(result) = self - .resolve_as_file(&path) - .ok() + .resolve_export(&pkg_dir, pkg_path).ok() + .or_else(|| self.resolve_as_file(&path).ok()) .or_else(|| self.resolve_as_directory(&path, true).ok()) .flatten() { @@ -409,6 +473,31 @@ impl NodeModulesResolver { Ok(None) } + + /// Resolve the package name from a target import path, e.g.: + /// - "foo" => ("foo", "") + /// - "foo/bar" => ("foo", "bar") + /// - "@foo/bar" => ("@foo/bar", "") + /// - "@foo/bar/baz" => ("@foo/bar", "baz") + fn pkg_name_from_target<'a>(&self, target: &'a str) -> (&'a str, &'a str) { + match target.find('/') { + None => (target, ""), + Some(idx) => { + if target.starts_with('@') { + let rem = &target[idx + 1..]; + match rem.find('/') { + None => (target, ""), + Some(rem_idx) => { + let sep = idx + rem_idx + 1; + (&target[..sep], &target[sep + 1..]) + } + } + } else { + (&target[..idx], &target[(idx + 1)..]) + } + }, + } + } } impl Resolve for NodeModulesResolver {