Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions fixtures/package-map/.package-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"packages": {
"app": {
"url": "./packages/app",
"dependencies": {
"@myorg/utils": "utils",
"@myorg/ui-lib": "ui-lib"
}
},
"utils": {
"url": "./packages/utils"
},
"ui-lib": {
"url": "./packages/ui-lib",
"dependencies": {
"@myorg/utils": "utils"
}
}
}
}
22 changes: 22 additions & 0 deletions fixtures/package-map/multi-version.package-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"packages": {
"app": {
"url": "./mv/app",
"dependencies": {
"component": "component-v2"
}
},
"legacy": {
"url": "./mv/legacy",
"dependencies": {
"component": "component-v1"
}
},
"component-v1": {
"url": "./mv/vendor/component-1.0.0"
},
"component-v2": {
"url": "./mv/vendor/component-2.0.0"
}
}
}
1 change: 1 addition & 0 deletions fixtures/package-map/mv/app/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "mv/app";
4 changes: 4 additions & 0 deletions fixtures/package-map/mv/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "app",
"main": "index.js"
}
1 change: 1 addition & 0 deletions fixtures/package-map/mv/legacy/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "mv/legacy";
4 changes: 4 additions & 0 deletions fixtures/package-map/mv/legacy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "legacy",
"main": "index.js"
}
1 change: 1 addition & 0 deletions fixtures/package-map/mv/vendor/component-1.0.0/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "component@1.0.0";
5 changes: 5 additions & 0 deletions fixtures/package-map/mv/vendor/component-1.0.0/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "component",
"version": "1.0.0",
"main": "index.js"
}
1 change: 1 addition & 0 deletions fixtures/package-map/mv/vendor/component-2.0.0/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "component@2.0.0";
5 changes: 5 additions & 0 deletions fixtures/package-map/mv/vendor/component-2.0.0/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "component",
"version": "2.0.0",
"main": "index.js"
}
1 change: 1 addition & 0 deletions fixtures/package-map/packages/app/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "app";
4 changes: 4 additions & 0 deletions fixtures/package-map/packages/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "app",
"main": "index.js"
}
1 change: 1 addition & 0 deletions fixtures/package-map/packages/app/src/feature.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "feature";
1 change: 1 addition & 0 deletions fixtures/package-map/packages/ui-lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "ui-lib";
4 changes: 4 additions & 0 deletions fixtures/package-map/packages/ui-lib/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "@myorg/ui-lib",
"main": "index.js"
}
1 change: 1 addition & 0 deletions fixtures/package-map/packages/utils/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "utils/helper";
1 change: 1 addition & 0 deletions fixtures/package-map/packages/utils/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = "utils";
8 changes: 8 additions & 0 deletions fixtures/package-map/packages/utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@myorg/utils",
"main": "index.js",
"exports": {
".": "./index.js",
"./helper": "./helper.js"
}
}
2 changes: 2 additions & 0 deletions napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ impl ResolverFactory {
// merging options
Ok(ResolveOptions {
cwd: None,
// Package maps are not exposed through the Node.js binding.
package_map: None,
tsconfig: op
.tsconfig
.map(|value| -> napi::Result<_> {
Expand Down
6 changes: 6 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ pub enum ResolveError {
#[error(r#"Package import specifier "{0}" is not defined in package {1}"#)]
PackageImportNotDefined(String, PathBuf),

/// The importing file is not located within any package defined in the package map.
///
/// Corresponds to Node.js `ERR_PACKAGE_MAP_EXTERNAL_FILE`.
#[error("The importing file {0} is not within any package defined in the package map")]
PackageMapExternalFile(/* importer directory */ PathBuf),

#[error("{0} is unimplemented")]
Unimplemented(&'static str),

Expand Down
126 changes: 122 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ mod file_url;
mod node_path;
mod options;
mod package_json;
mod package_map;
mod path;
mod resolution;
mod specifier;
Expand Down Expand Up @@ -91,14 +92,15 @@ use std::{
ffi::OsStr,
fmt,
path::{Component, Path, PathBuf},
sync::Arc,
sync::{Arc, OnceLock},
};

use rustc_hash::FxHashSet;

use crate::{
alias::{CompiledAlias, compile_alias},
context::ResolveContext as Ctx,
package_map::PackageMap,
path::SLASH_START,
specifier::Specifier,
};
Expand All @@ -123,6 +125,9 @@ pub struct ResolverGeneric<Fs> {
options: ResolveOptions,
cache: Arc<Cache<Fs>>,
alias: CompiledAlias,
/// Lazily-loaded package map (`.package-map.json`), parsed on first use.
/// `None` variant inside means [`ResolveOptions::package_map`] is unset.
package_map: OnceLock<Result<Arc<PackageMap>, ResolveError>>,
}

impl<Fs> fmt::Debug for ResolverGeneric<Fs> {
Expand Down Expand Up @@ -150,13 +155,18 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
}
}
let cache = Arc::new(Cache::new(fs));
Self { options, cache, alias }
Self { options, cache, alias, package_map: OnceLock::new() }
}

pub fn new_with_file_system(file_system: Fs, options: ResolveOptions) -> Self {
let options = options.sanitize();
let alias = compile_alias(&options.alias);
Self { cache: Arc::new(Cache::new(file_system)), options, alias }
Self {
cache: Arc::new(Cache::new(file_system)),
options,
alias,
package_map: OnceLock::new(),
}
}

/// Clone the resolver using the same underlying cache.
Expand All @@ -178,7 +188,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
let cache = Arc::clone(&self.cache);
}
}
Self { options, cache, alias }
Self { options, cache, alias, package_map: OnceLock::new() }
}

/// Returns the options.
Expand Down Expand Up @@ -595,9 +605,117 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
{
return Ok(path);
}
// When a package map is configured, bare specifiers are resolved exclusively through it.
if self.options.package_map.is_some() {
return self.load_package_map(cached_path, specifier, tsconfig, ctx);
}
self.load_package_self_or_node_modules(cached_path, specifier, tsconfig, ctx)
}

/// Lazily load and parse the configured package map (`.package-map.json`).
///
/// The result is memoized: the file is read and parsed once per resolver.
fn package_map(&self) -> Result<Arc<PackageMap>, ResolveError> {
self.package_map
.get_or_init(|| {
// `require_bare` only calls this when `package_map` is `Some`.
let config_path = self.options.package_map.as_ref().unwrap();
let config_path = if config_path.is_absolute() {
Cow::Borrowed(config_path.as_path())
} else if let Some(cwd) = &self.options.cwd {
Cow::Owned(cwd.join(config_path))
} else {
Cow::Borrowed(config_path.as_path())
};
let source = self.cache.fs.read_to_string(&config_path)?;
PackageMap::parse(&config_path, &source).map(Arc::new)
})
.clone()
}

/// Resolve a bare specifier through the configured package map.
///
/// <https://nodejs.org/docs/latest/api/packages.html#package-maps>
fn load_package_map(
&self,
cached_path: &CachedPath,
specifier: &str,
tsconfig: Option<&TsConfig>,
ctx: &mut Ctx,
) -> Result<CachedPath, ResolveError> {
let package_map = self.package_map()?;

// 1. Determine which package performs the resolution request from the importer's path.
let Some(importer_id) = package_map.importer_package(cached_path.path()) else {
return Err(ResolveError::PackageMapExternalFile(cached_path.to_path_buf()));
};
Comment on lines +648 to +651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this works for a first iteration but is an approximation: multiple entries from package maps are allowed to have the same path on disk, so relying on the path to get the package ID leaves room for ambiguity.

The ideal fix would be to change CachedPath to be a combination of a PackageId and a Path. It's more involved since it touches the public interface so it probably should be a separate work.

And for context, this ability to have multiple packages share the same path with different package IDs is important to unlock proper peer dependencies semantics in workspaces (ie get rid of injectWorkspacePackages).


// 2. Look up the specifier's package name in the importing package's `dependencies`.
let (package_name, subpath) = Self::parse_package_specifier(specifier);
let Some(target) = package_map.resolve_dependency(importer_id, package_name) else {
// Not declared as a dependency (or points to an unknown package id): MODULE_NOT_FOUND.
return Err(ResolveError::NotFound(specifier.to_string()));
};

// 3. Forward to the regular resolution algorithm, treating the target `url` as the package
// directory (exports field, main, index, ...).
let package_dir = self.cache.value(target.path());
self.resolve_in_package_dir(&package_dir, specifier, subpath, tsconfig, ctx)
}

/// Finish resolution inside a package directory located via the package map.
///
/// Mirrors the per-package-directory branch of [`Self::load_node_modules`].
fn resolve_in_package_dir(
&self,
package_dir: &CachedPath,
specifier: &str,
subpath: &str,
tsconfig: Option<&TsConfig>,
ctx: &mut Ctx,
) -> Result<CachedPath, ResolveError> {
if subpath.is_empty() {
ctx.with_fully_specified(false);
}

// LOAD_PACKAGE_EXPORTS(specifier, package_dir)
if self.is_dir(package_dir, ctx)
&& let Some(path) =
self.load_package_exports(specifier, subpath, package_dir, tsconfig, ctx)?
{
return Ok(path);
}

// LOAD_AS_FILE(package_dir/subpath) / LOAD_AS_DIRECTORY(package_dir/subpath)
let target = package_dir.normalize_with(Self::dot_subpath(subpath).as_ref(), &self.cache);

if self.options.resolve_to_context {
return self
.is_dir(&target, ctx)
.then(|| target.clone())
.ok_or_else(|| ResolveError::NotFound(specifier.to_string()));
}

if !subpath.is_empty()
&& !specifier.ends_with('/')
&& let Some(path) = self.load_as_file(&target, tsconfig, ctx)?
{
return Ok(path);
}
if self.is_dir(&target, ctx) {
if let Some(path) = self.load_browser_field_or_alias(&target, tsconfig, ctx)? {
return Ok(path);
}
if let Some(path) = self.load_as_directory(&target, tsconfig, ctx)? {
return Ok(path);
}
} else if let Some(path) = self.load_as_file(&target, tsconfig, ctx)? {
return Ok(path);
}

Err(ResolveError::NotFound(specifier.to_string()))
}

/// enhanced-resolve: ParsePlugin.
///
/// It's allowed to escape # as \0# to avoid parsing it as fragment.
Expand Down
34 changes: 34 additions & 0 deletions src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ pub struct ResolveOptions {
/// Default `None`
pub tsconfig: Option<TsconfigDiscovery>,

/// Enable Node.js [package maps](https://nodejs.org/docs/latest/api/packages.html#package-maps)
/// resolution using the given `.package-map.json` file.
///
/// When set, bare specifiers (that are not Node.js builtin modules) are resolved exclusively
/// through the package map instead of walking `node_modules`. Relative, absolute and builtin
/// specifiers are unaffected.
///
/// The path may be absolute or relative to [`ResolveOptions::cwd`].
///
/// Default `None`
pub package_map: Option<PathBuf>,

/// Create aliases to import or require certain modules more easily.
///
/// An alias is used to replace a whole path or part of a path.
Expand Down Expand Up @@ -220,6 +232,23 @@ impl ResolveOptions {
/// let options = ResolveOptions::default().with_condition_names(&["bar"]);
/// assert_eq!(options.condition_names, vec!["bar".to_string()])
/// ```
/// Sets the value for [ResolveOptions::package_map]
///
/// ## Examples
///
/// ```
/// use oxc_resolver::ResolveOptions;
/// use std::path::PathBuf;
///
/// let options = ResolveOptions::default().with_package_map("/path/to/.package-map.json");
/// assert_eq!(options.package_map, Some(PathBuf::from("/path/to/.package-map.json")));
/// ```
#[must_use]
pub fn with_package_map<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.package_map = Some(path.into());
self
}

#[must_use]
pub fn with_condition_names(mut self, names: &[&str]) -> Self {
self.condition_names = names.iter().map(ToString::to_string).collect::<Vec<String>>();
Expand Down Expand Up @@ -541,6 +570,7 @@ impl Default for ResolveOptions {
Self {
cwd: None,
tsconfig: None,
package_map: None,
alias: vec![],
alias_fields: vec![],
condition_names: vec![],
Expand Down Expand Up @@ -576,6 +606,9 @@ impl fmt::Display for ResolveOptions {
if let Some(tsconfig) = &self.tsconfig {
write!(f, "tsconfig:{tsconfig:?},")?;
}
if let Some(package_map) = &self.package_map {
write!(f, "package_map:{},", package_map.display())?;
}
if !self.alias.is_empty() {
write!(f, "alias:{:?},", self.alias)?;
}
Expand Down Expand Up @@ -729,6 +762,7 @@ mod test {
roots: vec![],
symlinks: false,
tsconfig: None,
package_map: None,
module_type: false,
allow_package_exports_in_directory_resolve: false,
};
Expand Down
Loading
Loading