swc_ecma_loader/resolvers/
node.rs

1//! Faster version of node-resolve.
2//!
3//! See: https://github.com/goto-bus-stop/node-resolve
4
5use std::{
6    env::current_dir,
7    fs::File,
8    io::BufReader,
9    path::{Component, Path, PathBuf},
10};
11
12use anyhow::{bail, Context, Error};
13use dashmap::DashMap;
14#[cfg(windows)]
15use normpath::BasePath;
16use once_cell::sync::Lazy;
17use path_clean::PathClean;
18use pathdiff::diff_paths;
19use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
20use serde::Deserialize;
21use swc_common::FileName;
22use tracing::{debug, trace, Level};
23
24use crate::{
25    resolve::{Resolution, Resolve},
26    TargetEnv, NODE_BUILTINS,
27};
28
29static PACKAGE: &str = "package.json";
30
31/// Map of cached `browser` fields from deserialized package.json
32/// used to determine if we need to map to alternative paths when
33/// bundling for the browser runtime environment. The key is the
34/// directory containing the package.json file which is important
35/// to ensure we only apply these `browser` rules to modules in
36/// the owning package.
37static BROWSER_CACHE: Lazy<DashMap<PathBuf, BrowserCache, FxBuildHasher>> =
38    Lazy::new(Default::default);
39
40#[derive(Debug, Default)]
41struct BrowserCache {
42    rewrites: FxHashMap<PathBuf, PathBuf>,
43    ignores: FxHashSet<PathBuf>,
44    module_rewrites: FxHashMap<String, PathBuf>,
45    module_ignores: FxHashSet<String>,
46}
47
48/// Helper to find the nearest `package.json` file to get
49/// the base directory for a package.
50fn find_package_root(path: &Path) -> Option<PathBuf> {
51    let mut parent = path.parent();
52    while let Some(p) = parent {
53        let pkg = p.join(PACKAGE);
54        if pkg.is_file() {
55            return Some(p.to_path_buf());
56        }
57        parent = p.parent();
58    }
59    None
60}
61
62pub fn to_absolute_path(path: &Path) -> Result<PathBuf, Error> {
63    let absolute_path = if path.is_absolute() {
64        path.to_path_buf()
65    } else {
66        current_dir()?.join(path)
67    }
68    .clean();
69
70    Ok(absolute_path)
71}
72
73pub(crate) fn is_core_module(s: &str) -> bool {
74    NODE_BUILTINS.contains(&s)
75}
76
77#[derive(Deserialize)]
78struct PackageJson {
79    #[serde(default)]
80    main: Option<String>,
81    #[serde(default)]
82    browser: Option<Browser>,
83    #[serde(default)]
84    module: Option<String>,
85}
86
87#[derive(Deserialize)]
88#[serde(untagged)]
89enum Browser {
90    Str(String),
91    Obj(FxHashMap<String, StringOrBool>),
92}
93
94#[derive(Deserialize, Clone)]
95#[serde(untagged)]
96enum StringOrBool {
97    Str(String),
98    Bool(bool),
99}
100
101#[derive(Debug, Default)]
102pub struct NodeModulesResolver {
103    target_env: TargetEnv,
104    alias: FxHashMap<String, String>,
105    // if true do not resolve symlink
106    preserve_symlinks: bool,
107    ignore_node_modules: bool,
108}
109
110static EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "node"];
111
112impl NodeModulesResolver {
113    /// Create a node modules resolver for the target runtime environment.
114    pub fn new(
115        target_env: TargetEnv,
116        alias: FxHashMap<String, String>,
117        preserve_symlinks: bool,
118    ) -> Self {
119        Self {
120            target_env,
121            alias,
122            preserve_symlinks,
123            ignore_node_modules: false,
124        }
125    }
126
127    /// Create a node modules resolver which does not care about `node_modules`
128    pub fn without_node_modules(
129        target_env: TargetEnv,
130        alias: FxHashMap<String, String>,
131        preserve_symlinks: bool,
132    ) -> Self {
133        Self {
134            target_env,
135            alias,
136            preserve_symlinks,
137            ignore_node_modules: true,
138        }
139    }
140
141    fn wrap(&self, path: Option<PathBuf>) -> Result<FileName, Error> {
142        if let Some(path) = path {
143            if self.preserve_symlinks {
144                return Ok(FileName::Real(path.clean()));
145            } else {
146                return Ok(FileName::Real(path.canonicalize()?));
147            }
148        }
149        bail!("index not found")
150    }
151
152    /// Resolve a path as a file. If `path` refers to a file, it is returned;
153    /// otherwise the `path` + each extension is tried.
154    fn resolve_as_file(&self, path: &Path) -> Result<Option<PathBuf>, Error> {
155        let _tracing = if cfg!(debug_assertions) {
156            Some(
157                tracing::span!(
158                    Level::TRACE,
159                    "resolve_as_file",
160                    path = tracing::field::display(path.display())
161                )
162                .entered(),
163            )
164        } else {
165            None
166        };
167
168        if cfg!(debug_assertions) {
169            trace!("resolve_as_file({})", path.display());
170        }
171
172        let try_exact = path.extension().is_some();
173        if try_exact {
174            if path.is_file() {
175                return Ok(Some(path.to_path_buf()));
176            }
177        } else {
178            // We try `.js` first.
179            let mut path = path.to_path_buf();
180            path.set_extension("js");
181            if path.is_file() {
182                return Ok(Some(path));
183            }
184        }
185
186        // Try exact file after checking .js, for performance
187        if !try_exact && path.is_file() {
188            return Ok(Some(path.to_path_buf()));
189        }
190
191        if let Some(name) = path.file_name() {
192            let mut ext_path = path.to_path_buf();
193            let name = name.to_string_lossy();
194            for ext in EXTENSIONS {
195                ext_path.set_file_name(format!("{name}.{ext}"));
196                if ext_path.is_file() {
197                    return Ok(Some(ext_path));
198                }
199            }
200
201            // TypeScript-specific behavior: if the extension is ".js" or ".jsx",
202            // try replacing it with ".ts" or ".tsx".
203            ext_path.set_file_name(name.into_owned());
204            let old_ext = path.extension().and_then(|ext| ext.to_str());
205
206            if let Some(old_ext) = old_ext {
207                let extensions: &[&str] = match old_ext {
208                    // Note that the official compiler code always tries ".ts" before
209                    // ".tsx" even if the original extension was ".jsx".
210                    "js" => &["ts", "tsx"],
211                    "jsx" => &["ts", "tsx"],
212                    "mjs" => &["mts"],
213                    "cjs" => &["cts"],
214                    _ => &[],
215                };
216
217                for ext in extensions {
218                    ext_path.set_extension(ext);
219
220                    if ext_path.is_file() {
221                        return Ok(Some(ext_path));
222                    }
223                }
224            }
225        }
226
227        bail!("file not found: {}", path.display())
228    }
229
230    /// Resolve a path as a directory, using the "main" key from a package.json
231    /// file if it exists, or resolving to the index.EXT file if it exists.
232    fn resolve_as_directory(
233        &self,
234        path: &Path,
235        allow_package_entry: bool,
236    ) -> Result<Option<PathBuf>, Error> {
237        let _tracing = if cfg!(debug_assertions) {
238            Some(
239                tracing::span!(
240                    Level::TRACE,
241                    "resolve_as_directory",
242                    path = tracing::field::display(path.display())
243                )
244                .entered(),
245            )
246        } else {
247            None
248        };
249
250        if cfg!(debug_assertions) {
251            trace!("resolve_as_directory({})", path.display());
252        }
253
254        let pkg_path = path.join(PACKAGE);
255        if allow_package_entry && pkg_path.is_file() {
256            if let Some(main) = self.resolve_package_entry(path, &pkg_path)? {
257                return Ok(Some(main));
258            }
259        }
260
261        // Try to resolve to an index file.
262        for ext in EXTENSIONS {
263            let ext_path = path.join(format!("index.{ext}"));
264            if ext_path.is_file() {
265                return Ok(Some(ext_path));
266            }
267        }
268        Ok(None)
269    }
270
271    /// Resolve using the package.json "main" or "browser" keys.
272    fn resolve_package_entry(
273        &self,
274        pkg_dir: &Path,
275        pkg_path: &Path,
276    ) -> Result<Option<PathBuf>, Error> {
277        let _tracing = if cfg!(debug_assertions) {
278            Some(
279                tracing::span!(
280                    Level::TRACE,
281                    "resolve_package_entry",
282                    pkg_dir = tracing::field::display(pkg_dir.display()),
283                    pkg_path = tracing::field::display(pkg_path.display()),
284                )
285                .entered(),
286            )
287        } else {
288            None
289        };
290
291        let file = File::open(pkg_path)?;
292        let reader = BufReader::new(file);
293        let pkg: PackageJson = serde_json::from_reader(reader)
294            .context(format!("failed to deserialize {}", pkg_path.display()))?;
295
296        let main_fields = match self.target_env {
297            TargetEnv::Node => {
298                vec![pkg.module.as_ref(), pkg.main.as_ref()]
299            }
300            TargetEnv::Browser => {
301                if let Some(browser) = &pkg.browser {
302                    match browser {
303                        Browser::Str(path) => {
304                            vec![Some(path), pkg.module.as_ref(), pkg.main.as_ref()]
305                        }
306                        Browser::Obj(map) => {
307                            let mut bucket = BrowserCache::default();
308
309                            for (k, v) in map {
310                                let target_key = Path::new(k);
311                                let mut components = target_key.components();
312
313                                // Relative file paths are sources for this package
314                                let source = if let Some(Component::CurDir) = components.next() {
315                                    let path = pkg_dir.join(k);
316                                    if let Ok(file) = self
317                                        .resolve_as_file(&path)
318                                        .or_else(|_| self.resolve_as_directory(&path, false))
319                                    {
320                                        file.map(|file| file.clean())
321                                    } else {
322                                        None
323                                    }
324                                } else {
325                                    None
326                                };
327
328                                match v {
329                                    StringOrBool::Str(dest) => {
330                                        let path = pkg_dir.join(dest);
331                                        let file = self
332                                            .resolve_as_file(&path)
333                                            .or_else(|_| self.resolve_as_directory(&path, false))?;
334                                        if let Some(file) = file {
335                                            let target = file.clean();
336                                            let target = target
337                                                .strip_prefix(current_dir().unwrap_or_default())
338                                                .map(|target| target.to_path_buf())
339                                                .unwrap_or(target);
340
341                                            if let Some(source) = source {
342                                                bucket.rewrites.insert(source, target);
343                                            } else {
344                                                bucket.module_rewrites.insert(k.clone(), target);
345                                            }
346                                        }
347                                    }
348                                    StringOrBool::Bool(flag) => {
349                                        // If somebody set boolean `true` which is an
350                                        // invalid value we will just ignore it
351                                        if !flag {
352                                            if let Some(source) = source {
353                                                bucket.ignores.insert(source);
354                                            } else {
355                                                bucket.module_ignores.insert(k.clone());
356                                            }
357                                        }
358                                    }
359                                }
360                            }
361
362                            BROWSER_CACHE.insert(pkg_dir.to_path_buf(), bucket);
363
364                            vec![pkg.module.as_ref(), pkg.main.as_ref()]
365                        }
366                    }
367                } else {
368                    vec![pkg.module.as_ref(), pkg.main.as_ref()]
369                }
370            }
371        };
372
373        if let Some(Some(target)) = main_fields.iter().find(|x| x.is_some()) {
374            let path = pkg_dir.join(target);
375            return self
376                .resolve_as_file(&path)
377                .or_else(|_| self.resolve_as_directory(&path, false));
378        }
379
380        Ok(None)
381    }
382
383    /// Resolve by walking up node_modules folders.
384    fn resolve_node_modules(
385        &self,
386        base_dir: &Path,
387        target: &str,
388    ) -> Result<Option<PathBuf>, Error> {
389        if self.ignore_node_modules {
390            return Ok(None);
391        }
392
393        let absolute_path = to_absolute_path(base_dir)?;
394        let mut path = Some(&*absolute_path);
395        while let Some(dir) = path {
396            let node_modules = dir.join("node_modules");
397            if node_modules.is_dir() {
398                let path = node_modules.join(target);
399                if let Some(result) = self
400                    .resolve_as_file(&path)
401                    .ok()
402                    .or_else(|| self.resolve_as_directory(&path, true).ok())
403                    .flatten()
404                {
405                    return Ok(Some(result));
406                }
407            }
408            path = dir.parent();
409        }
410
411        Ok(None)
412    }
413
414    fn resolve_filename(&self, base: &FileName, module_specifier: &str) -> Result<FileName, Error> {
415        debug!(
416            "Resolving {} from {:#?} for {:#?}",
417            module_specifier, base, self.target_env
418        );
419
420        let path = Path::new(module_specifier);
421        if path.is_absolute() {
422            if let Ok(file) = self
423                .resolve_as_file(path)
424                .or_else(|_| self.resolve_as_directory(path, false))
425            {
426                if let Ok(file) = self.wrap(file) {
427                    return Ok(file);
428                }
429            }
430        }
431
432        let base = match base {
433            FileName::Real(v) => v,
434            _ => bail!("node-resolver supports only files"),
435        };
436
437        let base_dir = if base.is_file() {
438            let cwd = &Path::new(".");
439            base.parent().unwrap_or(cwd)
440        } else {
441            base
442        };
443
444        // Handle module references for the `browser` package config
445        // before we map aliases.
446        if let TargetEnv::Browser = self.target_env {
447            if let Some(pkg_base) = find_package_root(base) {
448                if let Some(item) = BROWSER_CACHE.get(&pkg_base) {
449                    let value = item.value();
450                    if value.module_ignores.contains(module_specifier) {
451                        return Ok(FileName::Custom(module_specifier.into()));
452                    }
453                    if let Some(rewrite) = value.module_rewrites.get(module_specifier) {
454                        return self.wrap(Some(rewrite.to_path_buf()));
455                    }
456                }
457            }
458        }
459
460        // Handle builtin modules for nodejs
461        if let TargetEnv::Node = self.target_env {
462            if module_specifier.starts_with("node:") {
463                return Ok(FileName::Custom(module_specifier.into()));
464            }
465
466            if is_core_module(module_specifier) {
467                return Ok(FileName::Custom(format!("node:{module_specifier}")));
468            }
469        }
470
471        // Aliases allow browser shims to be renamed so we can
472        // map `stream` to `stream-browserify` for example
473        let target = if let Some(alias) = self.alias.get(module_specifier) {
474            &alias[..]
475        } else {
476            module_specifier
477        };
478
479        let target_path = Path::new(target);
480
481        let file_name = {
482            if target_path.is_absolute() {
483                let path = PathBuf::from(target_path);
484                self.resolve_as_file(&path)
485                    .or_else(|_| self.resolve_as_directory(&path, true))
486                    .and_then(|p| self.wrap(p))
487            } else {
488                let mut components = target_path.components();
489
490                if let Some(Component::CurDir | Component::ParentDir) = components.next() {
491                    #[cfg(windows)]
492                    let path = {
493                        let base_dir = BasePath::new(base_dir).unwrap();
494                        base_dir
495                            .join(target.replace('/', "\\"))
496                            .normalize_virtually()
497                            .unwrap()
498                            .into_path_buf()
499                    };
500                    #[cfg(not(windows))]
501                    let path = base_dir.join(target);
502                    self.resolve_as_file(&path)
503                        .or_else(|_| self.resolve_as_directory(&path, true))
504                        .and_then(|p| self.wrap(p))
505                } else {
506                    self.resolve_node_modules(base_dir, target)
507                        .and_then(|path| {
508                            let file_path = path.context("failed to get the node_modules path");
509                            let current_directory = current_dir()?;
510                            let relative_path = diff_paths(file_path?, current_directory);
511                            self.wrap(relative_path)
512                        })
513                }
514            }
515        }
516        .and_then(|v| {
517            // Handle path references for the `browser` package config
518            if let TargetEnv::Browser = self.target_env {
519                if let FileName::Real(path) = &v {
520                    if let Some(pkg_base) = find_package_root(path) {
521                        let pkg_base = to_absolute_path(&pkg_base).unwrap();
522                        if let Some(item) = BROWSER_CACHE.get(&pkg_base) {
523                            let value = item.value();
524                            let path = to_absolute_path(path).unwrap();
525                            if value.ignores.contains(&path) {
526                                return Ok(FileName::Custom(path.display().to_string()));
527                            }
528                            if let Some(rewrite) = value.rewrites.get(&path) {
529                                return self.wrap(Some(rewrite.to_path_buf()));
530                            }
531                        }
532                    }
533                }
534            }
535            Ok(v)
536        });
537
538        file_name
539    }
540}
541
542impl Resolve for NodeModulesResolver {
543    fn resolve(&self, base: &FileName, module_specifier: &str) -> Result<Resolution, Error> {
544        self.resolve_filename(base, module_specifier)
545            .map(|filename| Resolution {
546                filename,
547                slug: None,
548            })
549    }
550}