diff --git a/Cargo.lock b/Cargo.lock index c77f87ffcaf6..aea945cfb2bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1538,6 +1538,23 @@ dependencies = [ "camino", ] +[[package]] +name = "biome_resolver_wasm" +version = "0.1.0" +dependencies = [ + "biome_deserialize", + "biome_fs", + "biome_json_parser", + "biome_package", + "biome_resolver", + "camino", + "console_error_panic_hook", + "js-sys", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + [[package]] name = "biome_rowan" version = "0.5.7" diff --git a/Cargo.toml b/Cargo.toml index 5f4f248b6fa6..4f253d30085c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -264,14 +264,26 @@ debug = "line-tables-only" [profile.dev.package."*"] debug = false +[profile.dev.package.biome_resolver_wasm] +opt-level = "s" +debug = true + [profile.dev.package.biome_wasm] opt-level = "s" debug = true +[profile.release.package.biome_resolver_wasm] +opt-level = 3 +debug = false + [profile.release.package.biome_wasm] opt-level = 3 debug = false +[profile.test.package.biome_resolver_wasm] +opt-level = "s" +debug = true + [profile.test.package.biome_wasm] opt-level = "s" debug = true diff --git a/crates/biome_package/src/node_js_package/tsconfig_json.rs b/crates/biome_package/src/node_js_package/tsconfig_json.rs index c0708d107dfc..2ea66edd021e 100644 --- a/crates/biome_package/src/node_js_package/tsconfig_json.rs +++ b/crates/biome_package/src/node_js_package/tsconfig_json.rs @@ -64,7 +64,22 @@ impl Manifest for TsConfigJson { } impl TsConfigJson { - fn parse(path: &Utf8Path, json: &str) -> (Self, Vec) { + /// Parses a `tsconfig.json` file from its content. + /// + /// # Arguments + /// + /// * `path` — absolute path to the `tsconfig.json` file. Used to resolve + /// relative paths inside the config (e.g. `baseUrl`, `paths`). Must be + /// an absolute path; passing a relative path will cause a panic inside + /// [`Self::initialise_paths`]. + /// * `json` — the raw JSON (or JSONC) content of the file. + /// + /// # Returns + /// + /// A tuple of `(TsConfigJson, Vec)`. If the `Vec` is non-empty, + /// the file contained parse errors and the returned struct may be partially + /// populated with default values. + pub fn parse(path: &Utf8Path, json: &str) -> (Self, Vec) { let (tsconfig, diagnostics) = deserialize_from_json_str( json, JsonParserOptions::default() diff --git a/crates/biome_resolver_wasm/Cargo.toml b/crates/biome_resolver_wasm/Cargo.toml new file mode 100644 index 000000000000..fa35ba8b8306 --- /dev/null +++ b/crates/biome_resolver_wasm/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "biome_resolver_wasm" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +description = "WebAssembly bindings to the Biome resolver" +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[package.metadata.wasm-pack.profile.profiling] +wasm-opt = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +biome_deserialize = { workspace = true } +biome_fs = { workspace = true } +biome_json_parser = { workspace = true } +biome_package = { workspace = true } +biome_resolver = { workspace = true } +camino = { workspace = true } +console_error_panic_hook = { version = "0.1.7", optional = true } +js-sys = "0.3.83" +serde = { workspace = true } +serde-wasm-bindgen = "0.6.5" +# IMPORTANT: if you update this package, you must update justfile and workflows +# so we install the same CLI version +wasm-bindgen = { version = "=0.2.106", features = ["serde-serialize"] } + +[features] +default = ["console_error_panic_hook"] + +[lints] +workspace = true diff --git a/crates/biome_resolver_wasm/src/lib.rs b/crates/biome_resolver_wasm/src/lib.rs new file mode 100644 index 000000000000..afdbb4c028fd --- /dev/null +++ b/crates/biome_resolver_wasm/src/lib.rs @@ -0,0 +1,531 @@ +#![deny(clippy::use_self)] + +mod utils; + +use std::sync::Arc; + +use biome_deserialize::json::deserialize_from_json_str; +use biome_json_parser::JsonParserOptions; +use biome_package::{PackageJson, TsConfigJson}; +use biome_resolver::{PathInfo, ResolveError, ResolveOptions, ResolverFsProxy}; +use camino::{Utf8Path, Utf8PathBuf}; +use js_sys::{Function, JsString, Object, Reflect}; +use wasm_bindgen::prelude::*; + +use crate::utils::{into_error, set_panic_hook}; + +#[wasm_bindgen(start)] +pub fn main() { + set_panic_hook(); +} + +// #region ResolveErrorKind + +// The reason why we keep a different enum here is because we want to break the implementation and documentation +// we have internally with the ones shipped to the users. The documentation we have here must be curated, and have +// an explanation in the readme of `@biomejs/resolver`. + +/// Identifies the reason a resolution attempt failed. +/// +/// Returned as the `errorKind` field of a failed `resolve()` result alongside +/// the human-readable `error` string. Use `errorKind` for programmatic +/// branching and `error` for display or logging. +/// +/// @example +/// ```ts +/// const result = resolver.resolve("./utils.js", "/project/src"); +/// if (result.errorKind === ResolveErrorKind.ModuleNotFound) { +/// // file does not exist or extension is missing from options +/// } +/// ``` +#[wasm_bindgen] +pub enum ResolveErrorKind { + /// The specifier could not be found anywhere the resolver looked. + /// + /// Common causes: + /// - The file or package does not exist at the given path. + /// - The package is not installed (`node_modules` is missing or stale). + /// - The required extension is not listed in the `extensions` option. + /// - The `conditionNames` option does not match any condition in the + /// package's `exports` map. + /// - `baseDir` is not an absolute path to a directory. + ModuleNotFound, + + /// The specifier resolved to a directory but no index file was found inside it. + /// + /// Fix by providing both `defaultFiles` and `extensions` in the resolver + /// options. For example, `defaultFiles: ["index"]` with + /// `extensions: ["ts", "js"]` will try `index.ts` then `index.js`. + DirectoryWithoutIndex, + + /// The specifier names a Node.js built-in module such as `node:fs` or + /// `node:path`. + /// + /// This is only returned when `resolveNodeBuiltins: true` is set. It is + /// not a failure — it signals that the import refers to the runtime itself + /// rather than a file on disk. Without that option, built-ins produce + /// `ModuleNotFound` instead. + NodeBuiltIn, + + /// No `package.json` was found walking up from `baseDir`. + /// + /// Confirm that `baseDir` is inside a directory tree that contains a + /// `package.json`. This error typically means `baseDir` points outside the + /// project root or to a temporary directory. + ManifestNotFound, + + /// A `package.json` or `tsconfig.json` was found but could not be parsed. + /// + /// The file likely contains invalid JSON. Validate it with a JSON linter. + ErrorLoadingManifest, + + /// A symlink in the resolution chain points to a target that does not exist. + /// + /// This usually means a broken symlink in `node_modules` left behind by an + /// interrupted package install. Re-running the package manager's install + /// command normally fixes it. + BrokenSymlink, + + /// The matched condition in a `package.json` `exports` or `imports` map + /// points to an invalid target. + /// + /// A valid target must be a string starting with `./`, an array of + /// fallbacks, a conditions object, or `null`. Any other value is rejected. + /// This is a bug in the package's `package.json`. If you control the + /// package, fix the manifest; otherwise check for a newer version. + InvalidExportsTarget, + + /// The specifier contains characters that are not valid in a package name. + /// + /// Check the specifier for typos such as uppercase letters in a scoped + /// package name or a path segment that begins with `.`. + InvalidPackageName, +} + +impl From for ResolveErrorKind { + fn from(err: ResolveError) -> Self { + match err { + ResolveError::NotFound => Self::ModuleNotFound, + ResolveError::DirectoryWithoutDefault => Self::DirectoryWithoutIndex, + ResolveError::NodeBuiltIn => Self::NodeBuiltIn, + ResolveError::ManifestNotFound => Self::ManifestNotFound, + ResolveError::ErrorLoadingManifest => Self::ErrorLoadingManifest, + ResolveError::BrokenSymlink => Self::BrokenSymlink, + ResolveError::InvalidMappingTarget => Self::InvalidExportsTarget, + ResolveError::InvalidPackageSpecifier => Self::InvalidPackageName, + } + } +} + +// #endregion + +// #region JsFileSystem + +/// A filesystem bridge backed by two JavaScript callbacks. +/// +/// This is the bridge between the Biome resolver and the host JavaScript +/// environment. Two synchronous callbacks must be provided: +/// +/// - `pathInfo(path: string): "file" | "directory" | { symlink: string } | null` +/// Returns the kind of the filesystem entry at `path` **without** following +/// symlinks. For symlinks, the returned object must contain `symlink` set to +/// the fully canonicalized real path (i.e. the result of `realpathSync`). +/// Returns `null` if the path does not exist or is not accessible. +/// +/// - `readFileUtf8(path: string): string | null` +/// Returns the UTF-8 content of the file at `path`, or `null` if the file +/// does not exist, is not accessible, or is not valid UTF-8. +/// +/// The Node.js implementation uses `lstatSync` + `realpathSync` for +/// `pathInfo`, and `readFileSync(path, "utf8")` for `readFileUtf8`. +#[wasm_bindgen] +pub struct JsFileSystem { + path_info_fn: Function, + read_file_utf8_fn: Function, +} + +#[wasm_bindgen] +impl JsFileSystem { + /// Creates a new `JsFileSystem` from two JavaScript callback functions. + /// + /// # Arguments + /// + /// * `path_info_fn` - `(path: string) => "file" | "directory" | { symlink: string } | null` + /// * `read_file_utf8_fn` - `(path: string) => string | null` + #[wasm_bindgen(constructor)] + pub fn new(path_info_fn: Function, read_file_utf8_fn: Function) -> Self { + Self { + path_info_fn, + read_file_utf8_fn, + } + } +} + +impl JsFileSystem { + /// Calls the `readFileUtf8` JS callback and returns the result. + fn read_file_utf8(&self, path: &Utf8Path) -> Result { + let result = self + .read_file_utf8_fn + .call1(&JsValue::null(), &JsValue::from_str(path.as_str())) + .map_err(|_| ())?; + + if result.is_null() || result.is_undefined() { + return Err(()); + } + + result.as_string().ok_or(()) + } +} + +/// Implements `ResolverFsProxy` directly on `JsFileSystem`. +/// +/// We intentionally do NOT implement the `FileSystem` trait — it requires +/// `Send + Sync`, which `js_sys::Function` cannot satisfy, and exposes many +/// methods irrelevant to resolution. Implementing `ResolverFsProxy` directly +/// keeps the surface minimal and correct. +impl ResolverFsProxy for JsFileSystem { + fn path_info(&self, path: &Utf8Path) -> Result { + let result = self + .path_info_fn + .call1(&JsValue::null(), &JsValue::from_str(path.as_str())) + .map_err(|_| ResolveError::NotFound)?; + + if result.is_null() || result.is_undefined() { + return Err(ResolveError::NotFound); + } + + // String return: "file" or "directory" + if let Some(s) = result.as_string() { + return match s.as_str() { + "file" => Ok(PathInfo::File), + "directory" => Ok(PathInfo::Directory), + _ => Err(ResolveError::NotFound), + }; + } + + // Object return: { symlink: string } — fully canonicalized target + if result.is_object() { + let symlink_key = JsValue::from_str("symlink"); + let target = Reflect::get(&result, &symlink_key) + .ok() + .and_then(|v| v.as_string()) + .ok_or(ResolveError::BrokenSymlink)?; + + return Ok(PathInfo::Symlink { + canonicalized_target: Utf8PathBuf::from(target), + }); + } + + Err(ResolveError::NotFound) + } + + fn find_package_json( + &self, + search_dir: &Utf8Path, + ) -> Result<(Utf8PathBuf, PackageJson), ResolveError> { + // Walk upward from search_dir, reading package.json at each level. + // All walking and parsing logic is in Rust; only the file read crosses + // the JS boundary. + let mut dir = search_dir.to_path_buf(); + loop { + let candidate = dir.join("package.json"); + if let Ok(content) = self.read_file_utf8(&candidate) { + // File exists but is unparseable — surface the error immediately + // rather than silently continuing upward to a parent manifest. + let manifest = + parse_package_json(&content).ok_or(ResolveError::ErrorLoadingManifest)?; + return Ok((dir, manifest)); + } + match dir.parent() { + Some(parent) => dir = parent.to_path_buf(), + None => return Err(ResolveError::ManifestNotFound), + } + } + } + + fn read_package_json_in_directory( + &self, + dir_path: &Utf8Path, + ) -> Result { + let path = dir_path.join("package.json"); + let content = self + .read_file_utf8(&path) + .map_err(|_| ResolveError::ErrorLoadingManifest)?; + parse_package_json(&content).ok_or(ResolveError::ErrorLoadingManifest) + } + + fn read_tsconfig_json(&self, path: &Utf8Path) -> Result { + let content = self + .read_file_utf8(path) + .map_err(|_| ResolveError::ErrorLoadingManifest)?; + parse_tsconfig_json(path, &content).ok_or(ResolveError::ErrorLoadingManifest) + } +} + +// #endregion + +// #region MemoryFileSystem + +/// An in-memory filesystem for use in browser environments and tests. +/// +/// Populate it with `insert_file()` before calling `Resolver.withMemoryFileSystem()`. +#[wasm_bindgen] +pub struct MemoryFileSystem { + inner: Arc, +} + +impl Default for MemoryFileSystem { + fn default() -> Self { + Self { + inner: Arc::new(biome_fs::MemoryFileSystem::default()), + } + } +} + +#[wasm_bindgen] +impl MemoryFileSystem { + /// Creates a new empty in-memory filesystem. + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self::default() + } + + /// Inserts a UTF-8 file at `path` with the given string content. + #[wasm_bindgen(js_name = insertFile)] + pub fn insert_file(&self, path: &str, content: &str) { + self.inner + .insert(Utf8PathBuf::from(path), content.as_bytes()); + } + + /// Removes the file at `path`. + pub fn remove(&self, path: &str) { + self.inner.remove(Utf8Path::new(path)); + } +} + +// #endregion + +// #region WasmResolveOptions + +/// Resolver options passed as a plain JavaScript object. +/// +/// All fields are optional. Unset fields use sensible defaults consistent +/// with the Node.js module resolution algorithm. +#[derive(serde::Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +struct WasmResolveOptions { + /// Condition names to accept in `exports` / `imports` maps. + /// + /// Example: `["node", "import"]` for ESM, `["node", "require"]` for CJS. + condition_names: Vec, + + /// File extensions to try when resolving bare paths without an extension. + /// + /// Example: `["js", "ts", "json"]` + extensions: Vec, + + /// Extension aliases: map an extension to one or more fallback extensions. + /// + /// Example: `[{ extension: "js", aliases: ["ts", "js"] }]` + extension_aliases: Vec, + + /// Index file names to look for when resolving a directory. + /// + /// Defaults to `["index"]` when not set. + default_files: Vec, + + /// When `true`, Node.js built-in modules (e.g. `node:fs`) resolve to a + /// `NodeBuiltIn` error instead of attempting further resolution. + resolve_node_builtins: bool, + + /// When `true`, resolve TypeScript declaration files (`.d.ts`) instead of + /// source files. + resolve_types: bool, +} + +#[derive(serde::Deserialize)] +struct WasmExtensionAlias { + extension: String, + aliases: Vec, +} + +/// Calls `f` with a `ResolveOptions<'_>` built from `opts`. +/// +/// `ResolveOptions<'a>` requires `&'a [&'a str]` slices, but +/// `WasmResolveOptions` stores `Vec`. We build the intermediate +/// `Vec<&str>` vecs on the stack here and pass borrows into `f`, ensuring +/// all lifetimes are consistent without self-referential structs. +fn with_resolve_options(opts: &WasmResolveOptions, f: F) -> R +where + F: FnOnce(ResolveOptions<'_>) -> R, +{ + let condition_names: Vec<&str> = opts.condition_names.iter().map(|s| s.as_str()).collect(); + let extensions: Vec<&str> = opts.extensions.iter().map(|s| s.as_str()).collect(); + let default_files: Vec<&str> = opts.default_files.iter().map(|s| s.as_str()).collect(); + + // Build the inner alias vecs first so they have stable addresses before + // we take slices of them. + let alias_vecs: Vec> = opts + .extension_aliases + .iter() + .map(|ea| ea.aliases.iter().map(|s| s.as_str()).collect()) + .collect(); + + let extension_aliases: Vec<(&str, &[&str])> = opts + .extension_aliases + .iter() + .enumerate() + .map(|(i, ea)| (ea.extension.as_str(), alias_vecs[i].as_slice())) + .collect(); + + let mut options = ResolveOptions::new() + .with_condition_names(condition_names.as_slice()) + .with_extensions(extensions.as_slice()) + .with_extension_aliases(extension_aliases.as_slice()) + .with_default_files(default_files.as_slice()); + + if opts.resolve_node_builtins { + options = options.with_resolve_node_builtins(); + } + if opts.resolve_types { + options = options.with_resolve_types(); + } + + f(options) +} + +// #endregion + +// #region Resolver + +/// The filesystem backend used by the resolver. +enum ResolverFs { + Js(JsFileSystem), + Memory(Arc), +} + +/// A module resolver that can use either a JS filesystem bridge or an +/// in-memory filesystem. +/// +/// Create with `Resolver.withJsFileSystem()` (for Node.js) or +/// `Resolver.withMemoryFileSystem()` (for browsers and tests). +#[wasm_bindgen] +pub struct Resolver { + fs: ResolverFs, + options: WasmResolveOptions, +} + +#[wasm_bindgen] +impl Resolver { + /// Creates a resolver backed by the provided JavaScript filesystem bridge. + /// + /// Use this in Node.js environments — pass a `JsFileSystem` constructed + /// with callbacks that delegate to `lstatSync`, `realpathSync`, and + /// `readFileSync` from `node:fs`. + /// + /// `options` is an optional plain JavaScript object with resolver options. + #[wasm_bindgen(js_name = "withJsFileSystem")] + pub fn with_js_fs(fs: JsFileSystem, options: JsValue) -> Result { + let options: WasmResolveOptions = if options.is_null() || options.is_undefined() { + WasmResolveOptions::default() + } else { + serde_wasm_bindgen::from_value(options).map_err(into_error)? + }; + Ok(Self { + fs: ResolverFs::Js(fs), + options, + }) + } + + /// Creates a resolver backed by the provided in-memory filesystem. + /// + /// Use this in browser environments or tests. Populate the + /// `MemoryFileSystem` with the files the resolver needs to access before + /// calling this. + /// + /// `options` is an optional plain JavaScript object with resolver options. + #[wasm_bindgen(js_name = "withMemoryFileSystem")] + pub fn with_memory_fs(fs: &MemoryFileSystem, options: JsValue) -> Result { + let options: WasmResolveOptions = if options.is_null() || options.is_undefined() { + WasmResolveOptions::default() + } else { + serde_wasm_bindgen::from_value(options).map_err(into_error)? + }; + Ok(Self { + fs: ResolverFs::Memory(Arc::clone(&fs.inner)), + options, + }) + } + + /// Resolves `specifier` starting from `base_dir`. + /// + /// `base_dir` must be an absolute path to a **directory** (not a file). + /// For example, pass `path.dirname(import.meta.url)` or `__dirname`. + /// + /// Returns a plain JavaScript object: + /// - On success: `{ path: string }` — the resolved absolute path. + /// - On failure: `{ error: string }` — a description of why resolution + /// failed. + pub fn resolve(&self, specifier: &str, base_dir: &str) -> JsValue { + let base_dir = Utf8Path::new(base_dir); + + let result = with_resolve_options(&self.options, |resolve_options| match &self.fs { + ResolverFs::Js(js_fs) => { + biome_resolver::resolve(specifier, base_dir, js_fs, &resolve_options) + } + ResolverFs::Memory(mem_fs) => { + biome_resolver::resolve(specifier, base_dir, mem_fs.as_ref(), &resolve_options) + } + }); + + match result { + Ok(path) => { + let obj = Object::new(); + Reflect::set( + &obj, + &JsString::from("path"), + &JsValue::from_str(path.as_str()), + ) + .unwrap_or_default(); + obj.into() + } + Err(err) => { + let obj = Object::new(); + Reflect::set( + &obj, + &JsString::from("error"), + &JsValue::from_str(&err.to_string()), + ) + .unwrap_or_default(); + Reflect::set( + &obj, + &JsString::from("errorKind"), + &JsValue::from(ResolveErrorKind::from(err) as u32), + ) + .unwrap_or_default(); + obj.into() + } + } + } +} + +// #endregion + +// #region JSON parsing helpers + +fn parse_package_json(content: &str) -> Option { + deserialize_from_json_str::(content, JsonParserOptions::default(), "") + .consume() + .0 +} + +fn parse_tsconfig_json(path: &Utf8Path, content: &str) -> Option { + let (tsconfig, errors) = TsConfigJson::parse(path, content); + if errors.is_empty() { + Some(tsconfig) + } else { + None + } +} + +// #endregion diff --git a/crates/biome_resolver_wasm/src/utils.rs b/crates/biome_resolver_wasm/src/utils.rs new file mode 100644 index 000000000000..0850cc66d71b --- /dev/null +++ b/crates/biome_resolver_wasm/src/utils.rs @@ -0,0 +1,18 @@ +use std::fmt::Display; + +use js_sys::Error; + +pub(crate) fn set_panic_hook() { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} + +pub(crate) fn into_error(err: E) -> Error { + Error::new(&err.to_string()) +} diff --git a/justfile b/justfile index 65522097e9f4..52f043c9d28a 100644 --- a/justfile +++ b/justfile @@ -13,14 +13,14 @@ set windows-powershell := true install-tools: cargo install cargo-binstall cargo binstall cargo-insta wasm-opt - cargo binstall wasm-bindgen-cli --version 0.2.105 + cargo binstall wasm-bindgen-cli --version 0.2.106 pnpm install # Upgrades the tools needed to develop upgrade-tools: cargo install cargo-binstall --force cargo binstall cargo-insta wasm-opt --force - cargo binstall wasm-bindgen-cli --version 0.2.105 --force + cargo binstall wasm-bindgen-cli --version 0.2.106 --force # Generate all files across crates and tools. You rarely want to use it locally. gen-all: @@ -137,6 +137,48 @@ build-wasm-web: -Os \ -g +# Build resolver WASM for Node.js target (development) +build-wasm-resolver-node-dev: + cargo build --lib --target wasm32-unknown-unknown -p biome_resolver_wasm + wasm-bindgen target/wasm32-unknown-unknown/debug/biome_resolver_wasm.wasm \ + --out-dir packages/@biomejs/wasm-resolver-nodejs \ + --target nodejs \ + --typescript + +# Build resolver WASM for Node.js target (release) +build-wasm-resolver-node: + cargo build --lib --target wasm32-unknown-unknown --release -p biome_resolver_wasm + wasm-bindgen target/wasm32-unknown-unknown/release/biome_resolver_wasm.wasm \ + --out-dir packages/@biomejs/wasm-resolver-nodejs \ + --no-demangle \ + --target nodejs \ + --typescript + wasm-opt packages/@biomejs/wasm-resolver-nodejs/biome_resolver_wasm_bg.wasm \ + -o packages/@biomejs/wasm-resolver-nodejs/biome_resolver_wasm_bg.wasm \ + -Os \ + -g + +# Build resolver WASM for web target (development) +build-wasm-resolver-web-dev: + cargo build --lib --target wasm32-unknown-unknown -p biome_resolver_wasm + wasm-bindgen target/wasm32-unknown-unknown/debug/biome_resolver_wasm.wasm \ + --out-dir packages/@biomejs/wasm-resolver-web \ + --target web \ + --typescript + +# Build resolver WASM for web target (release) +build-wasm-resolver-web: + cargo build --lib --target wasm32-unknown-unknown --release -p biome_resolver_wasm + wasm-bindgen target/wasm32-unknown-unknown/release/biome_resolver_wasm.wasm \ + --out-dir packages/@biomejs/wasm-resolver-web \ + --no-demangle \ + --target web \ + --typescript + wasm-opt packages/@biomejs/wasm-resolver-web/biome_resolver_wasm_bg.wasm \ + -o packages/@biomejs/wasm-resolver-web/biome_resolver_wasm_bg.wasm \ + -Os \ + -g + # Generates the code of the grammars available in Biome gen-grammar *args='': cargo run -p xtask_codegen -- grammar {{args}} diff --git a/packages/@biomejs/resolver/README.md b/packages/@biomejs/resolver/README.md new file mode 100644 index 000000000000..a584ec577ad6 --- /dev/null +++ b/packages/@biomejs/resolver/README.md @@ -0,0 +1,987 @@ +# @biomejs/resolver + +> **Warning:** This package is currently shipped as alpha. Its APIs could change from one release to another. + +
+ + + + Biome logo with tagline 'Toolchain of the web' + + +
+
+ +[![Discord chat][discord-badge]][discord-url] +[![npm version][npm-badge]][npm-url] + +[discord-badge]: https://badgen.net/discord/online-members/BypW39g6Yc?icon=discord&label=discord&color=60a5fa +[discord-url]: https://biomejs.dev/chat +[npm-badge]: https://badgen.net/npm/v/@biomejs/resolver?icon=npm&color=60a5fa&label=%40biomejs%2Fresolver +[npm-url]: https://www.npmjs.com/package/@biomejs/resolver/v/latest + +
+ +
+ + +This WebAssembly-based module resolver implements the [Node.js module resolution algorithm](https://nodejs.org/api/esm.html#resolution-algorithm-specification), including support for `package.json` `exports`/`imports` maps, TypeScript path aliases, extension aliases, and more. + +You can use this resolver to build tools that need to resolve JavaScript or TypeScript imports—such as bundlers, linters, type checkers, or language servers. Because it compiles to WebAssembly, it requires no native binaries, has no platform-specific dependencies, and runs synchronously. + +This package is part of the [Biome](https://biomejs.dev) project. It exposes the same resolver that Biome uses internally for its module graph, project lint rules, and type-aware lint rules. + +## Installation + +To install the resolver, run one of the following commands depending on your target environment. There are two peer packages: one for Node.js and one for browser environments. + +```sh +# Install the Node.js distribution +npm install @biomejs/resolver @biomejs/wasm-resolver-nodejs + +# Install the web distribution +npm install @biomejs/resolver @biomejs/wasm-resolver-web + +``` + +> **Note:** All the examples from now on will target the Node.js distribution. Head to the [relevant section](#resolve-in-a-browser-playground-web-distribution) if you wish to know how to use the web distribution. + +## Quick start + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; +import { ResolveErrorKind } from "@biomejs/resolver"; + +const resolver = createNodeResolver({ + extensions: ["ts", "js"], + defaultFiles: ["index"], + conditionNames: ["node", "import"], +}); + +const result = resolver.resolve("./utils", "/project/src"); + +if (result.path) { + console.log(result.path); // "/project/src/utils/index.ts" +} else if (result.errorKind === ResolveErrorKind.ModuleNotFound) { + console.error("Not found:", result.error); +} else { + console.error("Resolution failed:", result.error); +} + +resolver.free(); +``` + +## Choosing a distribution + +The package ships two entry points that differ in how they access the filesystem. + +`@biomejs/resolver/nodejs` talks to the real filesystem using Node.js built-in +`node:fs` APIs. Use this when writing CLI tools, build scripts, language server +plugins, or any program that runs in Node.js, Bun, or Deno and needs to resolve +modules from the disk. + +`@biomejs/resolver/web` uses an in-memory filesystem that you populate yourself. +Use this when writing browser-based tools such as online code playgrounds or +browser IDEs, where access to the host filesystem is not available. This entry +point is also a good fit for unit tests because you control every file precisely +without touching the disk. + +**The web distribution must be loaded asynchronously** using `await import()` +because the WASM binary needs to be fetched and compiled by the browser before +it can be used. This is different from the Node.js distribution, which can load +WASM synchronously from disk and therefore supports static imports. + +## Important: how extensions work + +You must provide extensions **without a leading dot**. The resolver adds the dot itself when it constructs candidate file paths. For example, passing `"js"` causes the resolver to look for files ending in `.js`. + +The correct way to set extensions is: + +```ts +const resolver = createNodeResolver({ + extensions: ["js", "ts", "json"], +}); +``` + +Passing extensions with a leading dot will cause the resolver to look for files +whose names literally begin with a dot, which is almost certainly not what you +want: + +```ts +const resolver = createNodeResolver({ + extensions: [".js", ".ts", ".json"], // Wrong +}); +``` + +The same rule applies to `defaultFiles`. This option accepts bare filename stems, +not full filenames. The resolver combines the stem with each extension to form +the candidates it tries. Passing `"index"` with extensions `["js", "ts"]` causes +the resolver to try `index.js` and then `index.ts`. + +```ts +const resolver = createNodeResolver({ + defaultFiles: ["index"], + extensions: ["js", "ts"], +}); +``` + +Passing a full filename like `"index.js"` as a default file will not work as +expected, because the resolver will append the extension again, producing +`index.js.js`. + +## How `package.json` fields map to resolver options + +When the resolver encounters a bare package specifier such as `import "lodash"`, it walks up the directory tree from `baseDir` until it finds a `package.json`, then reads fields from it to determine where the package's entry point is. Which fields it reads and how it interprets them depend on the options you pass. The following sections explain how each `package.json` field maps to resolver behavior. + +### `exports` + +The `exports` field is the modern way for a package to declare its entry points. +It supports conditional mapping, where the same specifier can resolve to different +files depending on the environment. The resolver evaluates these conditions against +the `conditionNames` option you provide. Without `conditionNames`, the resolver +cannot match any condition and will skip the `exports` field entirely. + +The order of keys inside `exports` determines which condition is matched first — +not the order of your `conditionNames` array. + +In the following example, `my-package` ships both an ESM and a CommonJS build. +Resolving with `conditionNames: ["import"]` returns the ESM build, while +`conditionNames: ["require"]` returns the CommonJS build. + +```json5 +{ + "name": "my-package", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + } +} +``` + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ conditionNames: ["node", "import"] }); +const result = resolver.resolve("my-package", "/project/src"); +// => { path: "/project/node_modules/my-package/dist/index.mjs" } + +resolver.free(); +``` + +### `imports` + +The `imports` field works like `exports` but for internal package imports — +specifiers that start with `#` and map to files within the same package. It also +supports conditional mapping evaluated against `conditionNames`. + +In the following example, a file inside `my-package` imports `#utils`. With +`conditionNames: ["import"]`, the resolver returns the ESM version of that +internal module. + +```json5 +{ + "name": "my-package", + "imports": { + "#utils": { + "import": "./src/utils.mjs", + "require": "./src/utils.cjs" + } + } +} +``` + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ conditionNames: ["node", "import"] }); +const result = resolver.resolve("#utils", "/project/node_modules/my-package/src"); +// => { path: "/project/node_modules/my-package/src/utils.mjs" } + +resolver.free(); +``` + +### `main` and `module` + +When a package does not have an `exports` field, the resolver falls back to `main` +as the entry point. If `main` is also absent and the resolution does not require +CommonJS, the resolver tries `module`, which is a convention for ESM entry points +used by some older packages. + +In the following example, `legacy-package` has no `exports` field. The resolver +returns the path from `main` directly. + +```json5 +{ + "name": "legacy-package", + "main": "./dist/index.js", + "module": "./dist/index.mjs" +} +``` + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ conditionNames: ["node", "require"] }); +const result = resolver.resolve("legacy-package", "/project/src"); +// => { path: "/project/node_modules/legacy-package/dist/index.js" } + +resolver.free(); +``` + +### `types` + +When `resolveTypes` is set to `true`, the resolver reads the `types` field as the +package's type declaration entry point. This is used as a fallback when no +`"types"` export condition is found in the `exports` map. + +In the following example, `my-package` declares its types via the `types` field. +With `resolveTypes: true`, the resolver returns the `.d.ts` path instead of the +runtime entry point. + +```json5 +{ + "name": "my-package", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} +``` + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ + resolveTypes: true, + conditionNames: ["types", "import", "default"], + extensions: ["js", "ts"], +}); +const result = resolver.resolve("my-package", "/project/src"); +// => { path: "/project/node_modules/my-package/dist/index.d.ts" } + +resolver.free(); +``` + +## How `tsconfig.json` fields map to resolver options + +The resolver automatically discovers `tsconfig.json` by walking up from the `baseDir` you pass to `resolve()`. When a `tsconfig.json` is found, the resolver reads certain fields from it automatically, without requiring any extra options on your part. The following sections explain how each `tsconfig.json` field affects resolution behavior. + +### `paths` + +The `paths` field in `compilerOptions` defines path aliases — mappings from short +import names to concrete file paths. These are applied automatically whenever a +`tsconfig.json` is discovered. No extra option is needed. + +In the following example, any import of `@utils/string` resolves to +`./src/utils/string` relative to the project root. The trailing `/*` on both the +key and the value means the alias applies to all specifiers under that prefix, not +just the exact string. + +```json5 +{ + "compilerOptions": { + "paths": { + "@utils/*": ["./src/utils/*"] + } + } +} +``` + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ extensions: ["ts", "js"] }); +const result = resolver.resolve("@utils/string", "/project/src/app"); +// => { path: "/project/src/utils/string.ts" } + +resolver.free(); +``` + +### `baseUrl` + +The `baseUrl` field shifts the root from which non-relative imports are resolved. +Normally, a bare specifier like `"utils/string"` would be looked up in +`node_modules`. With `baseUrl` set, the resolver also tries to find it relative +to the `baseUrl` directory. Like `paths`, this is applied automatically when the +`tsconfig.json` is found. + +In the following example, `baseUrl` is set to `./src`, so an import of +`"utils/string"` resolves to `./src/utils/string` rather than a package in +`node_modules`. + +```json5 +{ + "compilerOptions": { + "baseUrl": "./src" + } +} +``` + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ extensions: ["ts", "js"] }); +const result = resolver.resolve("utils/string", "/project/src/app"); +// => { path: "/project/src/utils/string.ts" } + +resolver.free(); +``` + +### `typeRoots` + +The `typeRoots` field lists directories where the resolver looks for `@types` +packages. It is only consulted when `resolveTypes` is set to `true`. When +`resolveTypes` is enabled and `typeRoots` is absent from `tsconfig.json`, the +resolver defaults to searching `node_modules/@types`. + +In the following example, `typeRoots` points to a local `types` directory +alongside the standard `@types` location. With `resolveTypes: true`, the resolver +searches both directories when looking up type declarations. + +```json5 +{ + "compilerOptions": { + "typeRoots": ["./types", "./node_modules/@types"] + } +} +``` + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ + resolveTypes: true, + conditionNames: ["types", "import", "default"], + extensions: ["js", "ts"], +}); +const result = resolver.resolve("my-package", "/project/src"); +// Searches /project/types and /project/node_modules/@types for type declarations + +resolver.free(); +``` + +## Examples + +### Resolve a relative path + +When one file imports another using a path that starts with `./` or `../`, the +resolver treats it as a relative path and looks for the file on disk relative to +`baseDir`. The `baseDir` must be an absolute path to a directory, not a file. If +you only have a file path, use `path.dirname()` to get its directory. + +```ts +import path from "node:path"; +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver(); + +const currentFile = "/project/src/index.ts"; +const result = resolver.resolve("./utils.js", path.dirname(currentFile)); + +if (result.path) { + console.log(result.path); // "/project/src/utils.js" +} else { + console.error(result.error); +} + +resolver.free(); +``` + +### Resolve a bare package specifier + +A bare specifier is one that does not start with `./`, `../`, or `/` — for +example, `"lodash"` or `"react/jsx-runtime"`. The resolver looks for the package +in a `node_modules` directory, walking up from `baseDir` until it finds one. + +To resolve the entry point correctly, pass the condition names that match your +environment. For a Node.js ESM context, `"node"` and `"import"` are the typical +choices. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ + conditionNames: ["node", "import"], +}); + +const result = resolver.resolve("lodash", "/project/src"); + +if (result.path) { + console.log(result.path); // "/project/node_modules/lodash/lodash.js" +} + +resolver.free(); +``` + +### Resolve using `exports` condition names (ESM vs CJS) + +Many modern packages ship both an ESM and a CommonJS version and expose them +through the `exports` field using the `"import"` and `"require"` conditions. +Which file you get depends entirely on which condition names you pass to the +resolver. + +Given a package whose `package.json` looks like this: + +```json5 +{ + "name": "my-package", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + } +} +``` + +Passing `"import"` as a condition name gives you the ESM build; passing +`"require"` gives you the CommonJS build. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const esmResolver = createNodeResolver({ + conditionNames: ["node", "import"], +}); +const esmResult = esmResolver.resolve("my-package", "/project/src"); +// => { path: "/project/node_modules/my-package/dist/index.mjs" } + +const cjsResolver = createNodeResolver({ + conditionNames: ["node", "require"], +}); +const cjsResult = cjsResolver.resolve("my-package", "/project/src"); +// => { path: "/project/node_modules/my-package/dist/index.cjs" } + +esmResolver.free(); +cjsResolver.free(); +``` + +### Resolve a TypeScript file via extension aliases + +TypeScript encourages writing import specifiers with the compiled output +extension — `"./helper.js"` — even when the file that actually exists on disk is +`helper.ts`. This means that if you ask the resolver for `"./helper.js"` and +only `helper.ts` exists, a plain resolve will fail. + +The `extensionAliases` option lets you tell the resolver to try one or more +alternative extensions whenever it sees a specific extension in an import +specifier. Each entry maps a source extension to a list of extensions to try, in +order. Mapping `"js"` to `["ts", "js"]` means the resolver first tries the `.ts` +file, then falls back to `.js` if no `.ts` is found. Like `extensions`, all +values must be written without a leading dot. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ + extensionAliases: [ + { extension: "js", aliases: ["ts", "js"] }, + { extension: "jsx", aliases: ["tsx", "jsx"] }, + ], +}); + +const result = resolver.resolve("./helper.js", "/project/src"); +// Resolves to helper.ts if it exists, otherwise helper.js + +resolver.free(); +``` + +### Resolve a JSON file + +The resolver does not try JSON files by default. To make it consider `.json` +files as candidates when resolving a specifier without an explicit extension, +include `"json"` in the `extensions` list. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ + extensions: ["json"], +}); + +const result = resolver.resolve("./config.json", "/project/src"); +// => { path: "/project/src/config.json" } + +resolver.free(); +``` + +When a package exposes a JSON file through the `exports` field, you do not need +`"json"` in `extensions`. The resolver follows the `exports` map directly, so +condition names are all that is needed. Given this `package.json`: + +```json5 +{ + "name": "my-package", + "exports": { + "./data": "./data/index.json" + } +} +``` + +```ts +const result = resolver.resolve("my-package/data", "/project/src"); +// => { path: "/project/node_modules/my-package/data/index.json" } +``` + +### Resolve a directory index file + +When a specifier points to a directory rather than a file, the resolver looks for +an index file inside that directory. This only works when you provide both +`defaultFiles` and `extensions`. The resolver combines each stem in `defaultFiles` +with each extension in `extensions` to form the list of candidates, trying them +in the order you specify. + +For example, `defaultFiles: ["index"]` and `extensions: ["ts", "js"]` causes the +resolver to try `index.ts` first, then `index.js`. Putting `"ts"` before `"js"` +gives TypeScript files priority over JavaScript files. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ + defaultFiles: ["index"], + extensions: ["ts", "js"], +}); + +const result = resolver.resolve("./utils", "/project/src"); +// Resolves to /project/src/utils/index.ts if it exists, +// otherwise /project/src/utils/index.js + +resolver.free(); +``` + +### Resolve TypeScript declaration files + +When building a tool that works with types rather than runtime code — such as a +type checker, a documentation generator, or an IDE plugin — you want the resolver +to return `.d.ts` paths instead of source paths. Set `resolveTypes: true` to +enable this behaviour. + +With `resolveTypes` enabled, the resolver changes how it interprets `package.json`. +It prefers the `"types"` export condition over `"import"` or `"require"`, falls +back to the `types` field when no `"types"` condition is found, and ignores the +`main` field. For any import that explicitly names a JavaScript file extension, +the resolver also automatically tries the corresponding declaration extension +first — `.d.ts` for `.js`, `.d.mts` for `.mjs`, and so on. + +You should pair `resolveTypes: true` with `conditionNames: ["types", "import", +"default"]` and include both JavaScript and TypeScript extensions in `extensions`. +Do not include `.d.ts` yourself — the resolver inserts declaration extensions +automatically at the right priority. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; + +const resolver = createNodeResolver({ + resolveTypes: true, + conditionNames: ["types", "import", "default"], + extensions: ["js", "ts", "mjs", "mts", "cjs", "cts"], +}); + +const result = resolver.resolve("my-package", "/project/src"); +// For a package with "types": "./dist/index.d.ts" in package.json: +// => { path: "/project/node_modules/my-package/dist/index.d.ts" } + +resolver.free(); +``` + +For a package that ships a `"types"` export condition, the resolver picks that up +directly: + +```json5 +{ + "name": "my-package", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs" + } + } +} +``` + +```ts +const result = resolver.resolve("my-package", "/project/src"); +// => { path: "/project/node_modules/my-package/dist/index.d.ts" } +``` + +### Handle Node.js built-in modules + +Node.js ships a set of built-in modules — `node:fs`, `node:path`, `node:crypto`, +and others — that are part of the runtime itself and cannot be resolved to a file +path. By default, when the resolver encounters one of these, it treats it like +any other specifier and returns a generic "module not found" error, because no +file with that name exists on disk. + +Setting `resolveNodeBuiltins: true` changes this behaviour. Instead of a generic +error, the resolver returns `ResolveErrorKind.NodeBuiltIn` the moment it +recognises a built-in specifier, without walking the filesystem at all. This +lets you tell the difference between a genuinely missing package and a deliberate +import of a built-in, so you can handle each case appropriately — for example, +by skipping the built-in, substituting a polyfill, or reporting a targeted error +message. + +This option is particularly useful when writing tools that need to support +multiple runtimes. Bun and Deno share most of the Node.js built-in names, but a +browser environment has none of them. Detecting built-ins explicitly lets you +branch per-runtime rather than treating them all as missing packages. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; +import { ResolveErrorKind } from "@biomejs/resolver"; + +const resolver = createNodeResolver({ + resolveNodeBuiltins: true, +}); + +const result = resolver.resolve("node:fs", "/project/src"); + +if (result.errorKind === ResolveErrorKind.NodeBuiltIn) { + console.log("node:fs is a built-in — substituting polyfill"); +} else if (result.error) { + console.error("Resolution failed:", result.error); +} else { + console.log("Resolved to:", result.path); +} + +resolver.free(); +``` + +### Resolve in a browser playground (web distribution) + +The web distribution is designed for environments where the host filesystem is not accessible, such as browser-based code playgrounds or in-browser IDEs. It exposes an in-memory filesystem that you populate with `insertFile()` calls before creating a resolver. + +Because the WASM binary must be fetched and compiled by the browser before it can be used, the web distribution must be loaded with a dynamic `import()`. The `await` ensures the WASM is ready before you call any resolver APIs. + +To set up a virtual project, insert every file the resolver might need: + +- Your source files +- Every `package.json` along the resolution path — both your project root manifest and the manifests of any packages in `node_modules`. Without a `package.json`, the resolver cannot locate package entry points and will return `ManifestNotFound`. +- Any `tsconfig.json` files whose `paths`, `baseUrl`, or `typeRoots` settings you want the resolver to apply. If a `tsconfig.json` is absent, the resolver resolves as if no TypeScript configuration exists. + +The paths you use must be absolute and consistent. The resolver treats the virtual filesystem exactly like a real one: it walks parent directories looking for manifests, so the directory structure implied by the paths you insert must match what you expect the resolver to traverse. + +```ts +// The web distribution must be loaded dynamically because browsers require +// asynchronous fetching and compilation of the WASM binary. +const { createMemoryFileSystem, createWebResolver } = await import( + "@biomejs/resolver/web" +); + +const fs = createMemoryFileSystem(); + +// Project manifest — required for bare package specifier resolution. +fs.insertFile( + "/project/package.json", + JSON.stringify({ name: "my-app", version: "1.0.0" }), +); + +// TypeScript configuration — required for path aliases and baseUrl. +fs.insertFile( + "/project/tsconfig.json", + JSON.stringify({ + compilerOptions: { + baseUrl: "./src", + paths: { "@utils/*": ["./src/utils/*"] }, + }, + }), +); + +// Source files. +fs.insertFile("/project/src/index.ts", ""); +fs.insertFile("/project/src/greet.ts", ""); + +// A package in node_modules — both the manifest and the entry point +// must be present for the resolver to return a path. +fs.insertFile( + "/project/node_modules/lodash/package.json", + JSON.stringify({ name: "lodash", version: "4.17.21", main: "./lodash.js" }), +); +fs.insertFile("/project/node_modules/lodash/lodash.js", ""); + +// Create resolver +const resolver = createWebResolver(fs, { + extensions: ["ts", "js"], + defaultFiles: ["index"], + conditionNames: ["import", "default"], +}); + +// Resolves relative import +const relativeResult = resolver.resolve("./greet.ts", "/project/src"); +// => { path: "/project/src/greet.ts" } + +// Resolves using the path alias from tsconfig.json +const aliasResult = resolver.resolve("@utils/format", "/project/src"); +// => { path: "/project/src/utils/format.ts" } + +// Resolves the package entry point from the node_modules manifest +const packageResult = resolver.resolve("lodash", "/project/src"); +// => { path: "/project/node_modules/lodash/lodash.js" } + +resolver.free(); +fs.free(); +``` + +If you are building a browser playground that lets users edit multiple files, keep a single `MemoryFileSystem` instance and call `insertFile()` or `remove()` as files change. You can reuse the same `Resolver` instance across edits because it reads from the filesystem on every `resolve()` call — there is no internal cache to invalidate. + +## Error handling + +`resolver.resolve()` never throws. All failures are returned as a value. A +failed result carries two fields: `error`, a human-readable string suitable for +logging, and `errorKind`, a `ResolveErrorKind` enum value for programmatic +branching. A successful result carries only `path`. Exactly one of `path` or +`error` is always present. + +```ts +type ResolveResult = + | { path: string; error?: never; errorKind?: never } + | { path?: never; error: string; errorKind: ResolveErrorKind }; +``` + +Check for the presence of `path` to distinguish the two cases. TypeScript will +narrow the type correctly inside each branch. Use `error` for display and +logging; use `errorKind` for programmatic branching. + +```ts +import { createNodeResolver } from "@biomejs/resolver/nodejs"; +import { ResolveErrorKind } from "@biomejs/resolver"; + +const resolver = createNodeResolver(); +const result = resolver.resolve("./utils.js", "/project/src"); + +if (result.path) { + processFile(result.path); +} else { + // Use errorKind for branching, error for display. + if (result.errorKind === ResolveErrorKind.ModuleNotFound) { + console.error("File not found:", result.error); + } else { + console.error("Resolution failed:", result.error); + } +} + +resolver.free(); +``` + +This design means you never need a `try`/`catch` around `resolve()` calls. All +exceptional conditions, including malformed manifests and broken symlinks, are +surfaced as values rather than thrown exceptions. + +### `ResolveErrorKind.ModuleNotFound` + +The specifier could not be found anywhere the resolver looked. This is the most +common error and almost always means one of the following: + +- The file does not exist at the path you specified. Check the path for typos. +- The package is not installed. Run your package manager's install command. +- The `extensions` option does not include the extension of the file you are + trying to resolve. Add the missing extension without a leading dot. +- The `conditionNames` option does not match any condition in the package's + `exports` map. Check which conditions the package supports and include the + right ones. +- The `baseDir` you passed is wrong. It must be an absolute path to a + **directory**, not a file path. + +### `ResolveErrorKind.DirectoryWithoutIndex` + +The specifier resolves to a directory, but the resolver does not know which file +inside that directory to use. Fix this by providing both `defaultFiles` and +`extensions`. + +```ts +const resolver = createNodeResolver({ + defaultFiles: ["index"], + extensions: ["ts", "js"], +}); +``` + +With this configuration, resolving `"./utils"` when `./utils/` is a directory +will try `./utils/index.ts` and then `./utils/index.js`. + +### `ResolveErrorKind.NodeBuiltIn` + +This is only returned when `resolveNodeBuiltins: true` is set. It means the +specifier names a built-in module such as `node:fs` or `node:path`. This is not +a failure — it signals that the import refers to the runtime itself rather than a +file on disk. Handle it by skipping the specifier, recording it as a built-in, +or substituting a polyfill. + +```ts +const result = resolver.resolve("node:fs", "/project/src"); + +if (result.errorKind === ResolveErrorKind.NodeBuiltIn) { + // not an error — the import is intentional +} else if (result.error) { + console.error("Resolution failed:", result.error); +} +``` + +Without `resolveNodeBuiltins: true`, built-in specifiers produce +`ModuleNotFound` instead, because the resolver treats them as ordinary package +names and finds no matching directory in `node_modules`. + +### `ResolveErrorKind.ManifestNotFound` + +No `package.json` was found walking up from `baseDir`. This typically happens +when `baseDir` is set to a path outside your project root or to a temporary +directory that has no manifest. Confirm that `baseDir` is inside a directory +tree that contains a `package.json`. + +### `ResolveErrorKind.ErrorLoadingManifest` + +A `package.json` or `tsconfig.json` was found on disk but could not be parsed. +The file likely contains invalid JSON. Validate it with a JSON linter. + +### `ResolveErrorKind.BrokenSymlink` + +A symlink in the resolution chain points to a target that does not exist. This +usually means a broken symlink in `node_modules` left behind by an interrupted +package install. Re-running your package manager's install command normally +fixes it. + +### `ResolveErrorKind.InvalidExportsTarget` + +The matched condition in a `package.json` `exports` or `imports` map points to +an invalid target. A valid target must be a string starting with `./`, an array +of fallbacks, a conditions object, or `null`. This is a bug in the package's +`package.json`. If you control the package, fix the manifest; otherwise check +for a newer version. + +### `ResolveErrorKind.InvalidPackageName` + +The specifier contains characters that are not valid in a package name, such as +uppercase letters in a scoped package name or a path segment that begins with +`.`. Check the specifier for typos. + +## Memory management + +`Resolver` and `MemoryFileSystem` objects hold memory inside the WebAssembly +heap. When you are done with them, call `.free()` to release that memory. If you +do not, the memory will not be reclaimed for the lifetime of the process or page. + +```ts +const resolver = createNodeResolver(); + +for (const specifier of specifiers) { + const result = resolver.resolve(specifier, baseDir); + // handle result... +} + +resolver.free(); + +``` + +Calling `free()` more than once on the same object throws an error. Do not +retain a reference to a resolver after calling `free()`. + +If you create a single resolver at startup and reuse it for the lifetime of your +process — a common pattern in long-running tools — there is no need to call +`free()` at all. + +## API Reference + +### `createNodeResolver(options?: ResolverOptions): Resolver` + +Creates a resolver instance that uses the Node.js filesystem. + +**Options:** + +- `extensions?: string[]` — List of file extensions to try when resolving specifiers without explicit extensions (e.g., `["js", "ts"]`). Do not include the leading dot. +- `defaultFiles?: string[]` — List of filename stems to try when resolving directory imports (e.g., `["index"]`). Do not include extensions. +- `conditionNames?: string[]` — List of condition names for matching `exports` and `imports` fields in `package.json` (e.g., `["node", "import"]`). +- `extensionAliases?: Array<{ extension: string; aliases: string[] }>` — Maps one extension to a list of alternatives (e.g., `[{ extension: "js", aliases: ["ts", "js"] }]`). +- `resolveTypes?: boolean` — When `true`, resolves to TypeScript declaration files (`.d.ts`) instead of source files. +- `resolveNodeBuiltins?: boolean` — When `true`, returns `ResolveErrorKind.NodeBuiltIn` for Node.js built-in modules instead of `ModuleNotFound`. + +**Returns:** A `Resolver` instance. + +### `createWebResolver(fs: MemoryFileSystem, options?: ResolverOptions): Resolver` + +Creates a resolver instance that uses an in-memory filesystem. + +**Parameters:** + +- `fs: MemoryFileSystem` — The memory filesystem instance created with `createMemoryFileSystem()`. +- `options?: ResolverOptions` — Same options as `createNodeResolver()`. + +**Returns:** A `Resolver` instance. + +### `Resolver` + +#### `resolve(specifier: string, baseDir: string): ResolveResult` + +Resolves a module specifier to an absolute file path. + +**Parameters:** + +- `specifier: string` — The module specifier to resolve (e.g., `"./utils"`, `"lodash"`, `"@utils/format"`). +- `baseDir: string` — The absolute path to the directory from which to resolve. Must be a directory, not a file path. + +**Returns:** + +```ts +type ResolveResult = + | { path: string; error?: never; errorKind?: never } + | { path?: never; error: string; errorKind: ResolveErrorKind }; +``` + +#### `free(): void` + +Releases the memory held by the resolver. After calling `free()`, do not use the resolver instance. + +### `MemoryFileSystem` + +#### `insertFile(path: string, content: string): void` + +Inserts or updates a file in the memory filesystem. + +**Parameters:** + +- `path: string` — Absolute path to the file. +- `content: string` — File contents. + +#### `remove(path: string): void` + +Removes a file from the memory filesystem. + +**Parameters:** + +- `path: string` — Absolute path to the file. + +#### `free(): void` + +Releases the memory held by the filesystem. After calling `free()`, do not use the filesystem instance. + +### `ResolveErrorKind` + +An enum representing different types of resolution errors: + +- `ModuleNotFound` — The specifier could not be found. +- `DirectoryWithoutIndex` — The specifier resolves to a directory without an index file. +- `NodeBuiltIn` — The specifier refers to a Node.js built-in module (only when `resolveNodeBuiltins: true`). +- `ManifestNotFound` — No `package.json` was found. +- `ErrorLoadingManifest` — A manifest file exists but could not be parsed. +- `BrokenSymlink` — A symlink in the resolution chain is broken. +- `InvalidExportsTarget` — The `exports` field contains an invalid target. +- `InvalidPackageName` — The specifier contains invalid package name characters. + +### Standard Condition Names + +Common values for `conditionNames`: + +- `"node"` — Node.js environment +- `"import"` — ESM import +- `"require"` — CommonJS require +- `"default"` — Default condition (fallback) +- `"types"` — TypeScript type declarations +- `"browser"` — Browser environment +- `"development"` — Development mode +- `"production"` — Production mode + +## Next steps + +- Browse the [Biome documentation](https://biomejs.dev) for more on how Biome uses this resolver +- Join the [Biome Discord](https://biomejs.dev/chat) for help and discussion +- Report issues or contribute on [GitHub](https://github.com/biomejs/biome) +- Explore related packages in the [@biomejs npm organization](https://www.npmjs.com/org/biomejs) + diff --git a/packages/@biomejs/resolver/package.json b/packages/@biomejs/resolver/package.json new file mode 100644 index 000000000000..eada940db1f1 --- /dev/null +++ b/packages/@biomejs/resolver/package.json @@ -0,0 +1,79 @@ +{ + "name": "@biomejs/resolver", + "version": "0.1.0", + "description": "JavaScript API for the Biome module resolver", + "scripts": { + "tsc": "tsc --noEmit", + "build:wasm-dev": "pnpm run \"/^build:wasm-.+-dev$/\"", + "build:wasm": "pnpm run \"/^build:wasm-.+(?=14.21.3" + }, + "publishConfig": { + "provenance": true + } +} diff --git a/packages/@biomejs/resolver/src/common.ts b/packages/@biomejs/resolver/src/common.ts new file mode 100644 index 000000000000..e59f0cfb5bcd --- /dev/null +++ b/packages/@biomejs/resolver/src/common.ts @@ -0,0 +1,373 @@ +/** + * The value returned by the `pathInfo` callback passed to + * `Resolver.fromJsFileSystem()`. + * + * - `"file"` — the path exists and is a regular file. + * - `"directory"` — the path exists and is a directory. + * - `{ symlink: string }` — the path is a symlink; `symlink` must be the + * fully canonicalized real path (i.e. the result of `realpathSync` or + * equivalent). The resolver uses this to detect and break symlink cycles. + * - `null` — the path does not exist or is not accessible. + */ +export type PathInfo = "file" | "directory" | { symlink: string } | null; + +/** + * Options for the module resolver. + */ +export interface ResolveOptions { + /** + * Condition names to accept in `exports` / `imports` maps. + * + * Example: `["node", "import"]` for ESM, `["node", "require"]` for CJS. + */ + conditionNames?: string[]; + + /** + * File extensions to try when resolving bare paths without an extension. + * + * Extensions must be provided **without a leading dot**. The resolver adds + * the dot itself when constructing candidate paths. + * + * Example: `["js", "ts", "json"]` + */ + extensions?: string[]; + + /** + * Extension aliases: map an extension to one or more fallback extensions. + * + * When the resolver sees an import that ends with the key extension, it + * also tries the listed alias extensions in order. This is typically used + * to resolve `.ts` source files when the import specifier uses `.js`. + * + * Extensions must be provided **without a leading dot**. + * + * Example: `[{ extension: "js", aliases: ["ts", "js"] }]` + */ + extensionAliases?: Array<{ extension: string; aliases: string[] }>; + + /** + * Stem names to look for when resolving a directory. The resolver combines + * these with the `extensions` list to form candidate paths such as + * `index.js`, `index.ts`, etc. + * + * Provide the bare stem **without any extension**. + * + * Example: `["index"]` — combined with `extensions: ["js", "ts"]` this + * tries `index.js` then `index.ts`. + */ + defaultFiles?: string[]; + + /** + * When `true`, Node.js built-in modules (e.g. `node:fs`) resolve to a + * `NodeBuiltIn` error instead of attempting further resolution. + */ + resolveNodeBuiltins?: boolean; + + /** + * When `true`, resolve TypeScript declaration files (`.d.ts`) instead of + * source files. + */ + resolveTypes?: boolean; +} + +/** + * Identifies the reason a resolution attempt failed. + * + * Returned as the `errorKind` field on a failed `resolve()` result. Use it + * for programmatic branching; use the `error` string for display or logging. + * + * @example + * ```ts + * const result = resolver.resolve("./utils.js", "/project/src"); + * if (!result.path) { + * if (result.errorKind === ResolveErrorKind.ModuleNotFound) { + * // file is missing or extension not listed in options + * } + * } + * ``` + */ +export enum ResolveErrorKind { + /** + * The specifier could not be found anywhere the resolver looked. + * + * Common causes: + * - The file or package does not exist at the given path. + * - The package is not installed (`node_modules` is missing or stale). + * - The required extension is not listed in the `extensions` option. + * - The `conditionNames` option does not match any condition in the + * package's `exports` map. + * - `baseDir` is not an absolute path to a directory. + */ + ModuleNotFound = 0, + + /** + * The specifier resolved to a directory but no index file was found inside it. + * + * Fix by providing both `defaultFiles` and `extensions` in the resolver + * options. For example, `defaultFiles: ["index"]` with + * `extensions: ["ts", "js"]` will try `index.ts` then `index.js`. + */ + DirectoryWithoutIndex = 1, + + /** + * The specifier names a Node.js built-in module such as `node:fs` or + * `node:path`. + * + * This is only returned when `resolveNodeBuiltins: true` is set. It is not + * a failure — it signals that the import refers to the runtime itself rather + * than a file on disk. Without that option, built-ins produce + * `ModuleNotFound` instead. + */ + NodeBuiltIn = 2, + + /** + * No `package.json` was found walking up from `baseDir`. + * + * Confirm that `baseDir` is inside a directory tree that contains a + * `package.json`. This error typically means `baseDir` points outside the + * project root or to a temporary directory. + */ + ManifestNotFound = 3, + + /** + * A `package.json` or `tsconfig.json` was found but could not be parsed. + * + * The file likely contains invalid JSON. Validate it with a JSON linter. + */ + ErrorLoadingManifest = 4, + + /** + * A symlink in the resolution chain points to a target that does not exist. + * + * This usually means a broken symlink in `node_modules` left behind by an + * interrupted package install. Re-running the package manager's install + * command normally fixes it. + */ + BrokenSymlink = 5, + + /** + * The matched condition in a `package.json` `exports` or `imports` map + * points to an invalid target. + * + * A valid target must be a string starting with `./`, an array of + * fallbacks, a conditions object, or `null`. This is a bug in the + * package's `package.json`. If you control the package, fix the manifest; + * otherwise check for a newer version. + */ + InvalidExportsTarget = 6, + + /** + * The specifier contains characters that are not valid in a package name. + * + * Check the specifier for typos such as uppercase letters in a scoped + * package name or a path segment that begins with `.`. + */ + InvalidPackageName = 7, +} + +/** + * The result of a successful resolution. + */ +export interface ResolveSuccess { + /** The resolved absolute path. */ + path: string; + error?: never; + errorKind?: never; +} + +/** + * The result of a failed resolution. + */ +export interface ResolveFailure { + path?: never; + /** A human-readable description of why resolution failed. Suitable for display and logging. */ + error: string; + /** A structured identifier for the failure reason. Use this for programmatic branching. */ + errorKind: ResolveErrorKind; +} + +export type ResolveResult = ResolveSuccess | ResolveFailure; + +/** + * Minimal interface for the WASM `MemoryFileSystem` exported by any resolver + * WASM package. + */ +export interface WasmMemoryFileSystem { + insertFile(path: string, content: string): void; + remove(path: string): void; + free(): void; +} + +/** + * Minimal interface for the WASM `JsFileSystem` exported by any resolver WASM + * package. + */ +export interface WasmJsFileSystem { + free(): void; +} + +/** + * Minimal interface for the WASM `Resolver` exported by any resolver WASM + * package. + */ +export interface WasmResolver { + resolve(specifier: string, baseDir: string): ResolveResult; + free(): void; +} + +/** + * The subset of the WASM module that `ResolverCommon` requires. + */ +export interface ResolverModule { + main(): void; + MemoryFileSystem: new () => WasmMemoryFileSystem; + JsFileSystem: new ( + pathInfoFn: (path: string) => PathInfo, + readFileUtf8Fn: (path: string) => string | null, + ) => WasmJsFileSystem; + Resolver: { + withJsFileSystem( + fs: WasmJsFileSystem, + options?: ResolveOptions | null, + ): WasmResolver; + withMemoryFileSystem( + fs: WasmMemoryFileSystem, + options?: ResolveOptions | null, + ): WasmResolver; + }; +} + +/** + * List of modules that have been initialized. + */ +const initialized = new WeakSet(); + +/** + * An in-memory filesystem suitable for use in browser environments and tests. + * + * Populate with `insertFile()` before constructing a `Resolver`. + */ +export class MemoryFileSystem { + constructor(private readonly inner: WasmMemoryFileSystem) {} + + /** + * Inserts a file at `path` with the given UTF-8 string content. + */ + insertFile(path: string, content: string): void { + this.inner.insertFile(path, content); + } + + /** + * Removes the file at `path`. + */ + remove(path: string): void { + this.inner.remove(path); + } + + /** + * Frees the underlying WASM memory. + * + * After calling this, the object must not be used. Calling `free()` more + * than once on the same instance throws an error. + */ + free(): void { + this.inner.free(); + } + + /** @internal */ + get wasmInner(): WasmMemoryFileSystem { + return this.inner; + } +} + +/** + * A module resolver. + * + * Create with the static factory methods `Resolver.fromJsFileSystem()` or + * `Resolver.fromMemoryFileSystem()`. + */ +export class Resolver { + private constructor(private readonly inner: WasmResolver) {} + + /** + * Creates a resolver backed by two JavaScript filesystem callbacks. + * + * This is the low-level constructor for environments that have synchronous + * filesystem access but are not Node.js — for example Bun, Deno, or any + * runtime that exposes its own `fs`-like APIs. Pass callbacks that + * implement `pathInfo` and `readFileUtf8` for your target runtime. + * + * For Node.js specifically, prefer `createNodeResolver()` from + * `@biomejs/resolver/nodejs`, which wires these callbacks automatically. + * + * @param module - The loaded WASM module. + * @param pathInfoFn - Returns the kind of the filesystem entry at `path` + * without following symlinks. See {@link PathInfo} for the expected return + * values. Must be synchronous. + * @param readFileUtf8Fn - Returns the UTF-8 content of the file at `path`, + * or `null` if it does not exist or is not readable. Must be synchronous. + * @param options - Optional resolver options. + */ + static fromJsFileSystem( + module: ResolverModule, + pathInfoFn: (path: string) => PathInfo, + readFileUtf8Fn: (path: string) => string | null, + options?: ResolveOptions | null, + ): Resolver { + const fs = new module.JsFileSystem(pathInfoFn, readFileUtf8Fn); + // `withJsFileSystem` takes ownership of `fs`, transferring it into Rust. + // Do NOT call `fs.free()` afterwards — the pointer has been moved. + const inner = module.Resolver.withJsFileSystem(fs, options ?? null); + return new Resolver(inner); + } + + /** + * Creates a resolver backed by the provided in-memory filesystem. + */ + static fromMemoryFileSystem( + module: ResolverModule, + fs: MemoryFileSystem, + options?: ResolveOptions | null, + ): Resolver { + const inner = module.Resolver.withMemoryFileSystem( + fs.wasmInner, + options ?? null, + ); + return new Resolver(inner); + } + + /** + * Resolves `specifier` starting from `baseDir`. Never throws. + * + * `baseDir` must be an absolute path to a **directory** (not a file). + * + * Returns `{ path: string }` on success, or + * `{ error: string; errorKind: ResolveErrorKind }` on failure. + * Use `error` for display and logging; use `errorKind` for programmatic + * branching. + */ + resolve(specifier: string, baseDir: string): ResolveResult { + return this.inner.resolve(specifier, baseDir); + } + + /** + * Frees the underlying WASM memory. + * + * After calling this, the object must not be used. Calling `free()` more + * than once on the same instance throws an error. + */ + free(): void { + this.inner.free(); + } +} + +/** + * @internal + * Initialises a WASM module at most once. + */ +export function ensureInitialized(module: ResolverModule): void { + if (!initialized.has(module as object)) { + module.main(); + initialized.add(module as object); + } +} diff --git a/packages/@biomejs/resolver/src/index.ts b/packages/@biomejs/resolver/src/index.ts new file mode 100644 index 000000000000..d8c8b4f34dbf --- /dev/null +++ b/packages/@biomejs/resolver/src/index.ts @@ -0,0 +1,12 @@ +export type * from "./common"; +export { MemoryFileSystem, ResolveErrorKind, Resolver } from "./common"; + +/** + * Which WASM distribution to load. + */ +export enum Distribution { + /** WASM built for Node.js */ + NODE = 0, + /** WASM built for the browser (web) */ + WEB = 1, +} diff --git a/packages/@biomejs/resolver/src/nodejs.ts b/packages/@biomejs/resolver/src/nodejs.ts new file mode 100644 index 000000000000..419bbfde8fab --- /dev/null +++ b/packages/@biomejs/resolver/src/nodejs.ts @@ -0,0 +1,38 @@ +import * as wasmModule from "@biomejs/wasm-resolver-nodejs"; +import { + ensureInitialized, + MemoryFileSystem, + type ResolveOptions, + type ResolveResult, + Resolver, +} from "./common"; +import { nodePathInfo, nodeReadFileUtf8 } from "./nodejsFileSystem"; + +export type * from "./common"; + +ensureInitialized(wasmModule); + +export { MemoryFileSystem, Resolver }; + +/** + * Creates a `Resolver` backed by the real Node.js filesystem. + * + * Uses `lstatSync`, `realpathSync`, and `readFileSync` from `node:fs`. + */ +export function createNodeResolver(options?: ResolveOptions | null): Resolver { + return Resolver.fromJsFileSystem( + wasmModule, + nodePathInfo, + nodeReadFileUtf8, + options, + ); +} + +/** + * Creates an empty `MemoryFileSystem` for use with `Resolver.fromMemoryFileSystem()`. + */ +export function createMemoryFileSystem(): MemoryFileSystem { + return new MemoryFileSystem(new wasmModule.MemoryFileSystem()); +} + +export type { ResolveOptions, ResolveResult }; diff --git a/packages/@biomejs/resolver/src/nodejsFileSystem.ts b/packages/@biomejs/resolver/src/nodejsFileSystem.ts new file mode 100644 index 000000000000..87baa12207c2 --- /dev/null +++ b/packages/@biomejs/resolver/src/nodejsFileSystem.ts @@ -0,0 +1,48 @@ +/** + * Creates the two filesystem callbacks required by `JsFileSystem` using + * Node.js built-in `node:fs` synchronous APIs. + * + * This module must only be imported in Node.js environments. + */ + +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import type { PathInfo } from "./common"; + +/** + * Returns `"file"`, `"directory"`, `{ symlink: }`, or `null` + * for the given path without following symlinks. + */ +export function nodePathInfo(path: string): PathInfo { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + try { + const real = realpathSync(path); + return { symlink: real }; + } catch { + return null; + } + } + if (stat.isDirectory()) { + return "directory"; + } + if (stat.isFile()) { + return "file"; + } + return null; + } catch { + return null; + } +} + +/** + * Reads the UTF-8 content of the file at `path`, or returns `null` if the + * file does not exist or is not readable. + */ +export function nodeReadFileUtf8(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} diff --git a/packages/@biomejs/resolver/src/web.ts b/packages/@biomejs/resolver/src/web.ts new file mode 100644 index 000000000000..4370742c1f33 --- /dev/null +++ b/packages/@biomejs/resolver/src/web.ts @@ -0,0 +1,39 @@ +import * as wasmModule from "@biomejs/wasm-resolver-web"; +import { + ensureInitialized, + MemoryFileSystem, + type ResolveOptions, + type ResolveResult, + Resolver, +} from "./common"; + +export type * from "./common"; + +ensureInitialized(wasmModule); + +export { MemoryFileSystem, Resolver }; + +/** + * Creates an empty `MemoryFileSystem` for use with `createWebResolver()` or + * `Resolver.fromMemoryFileSystem()`. + * + * This is the primary way to resolve modules in browser environments, since + * direct filesystem access is not available there. + */ +export function createMemoryFileSystem(): MemoryFileSystem { + return new MemoryFileSystem(new wasmModule.MemoryFileSystem()); +} + +/** + * Creates a `Resolver` backed by the provided in-memory filesystem. + * + * This is a convenience wrapper around `Resolver.fromMemoryFileSystem()`. + */ +export function createWebResolver( + fs: MemoryFileSystem, + options?: ResolveOptions | null, +): Resolver { + return Resolver.fromMemoryFileSystem(wasmModule, fs, options); +} + +export type { ResolveOptions, ResolveResult }; diff --git a/packages/@biomejs/resolver/tests/memory-fs.test.ts b/packages/@biomejs/resolver/tests/memory-fs.test.ts new file mode 100644 index 000000000000..679583861845 --- /dev/null +++ b/packages/@biomejs/resolver/tests/memory-fs.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for the `MemoryFileSystem`-backed resolver. + * + * These tests run against the Node.js WASM target because that is the only + * one available in the Vitest environment. They exercise the same + * `MemoryFileSystem` + `Resolver` code path used in browser / bundler builds. + */ + +import * as wasmModule from "@biomejs/wasm-resolver-nodejs"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + ensureInitialized, + MemoryFileSystem, + ResolveErrorKind, + Resolver, +} from "../src/common"; + +beforeAll(() => { + ensureInitialized(wasmModule); +}); + +function makeMemFs(): MemoryFileSystem { + return new MemoryFileSystem(new wasmModule.MemoryFileSystem()); +} + +describe("MemoryFileSystem resolver", () => { + it("resolves a relative path to a JS file", () => { + const fs = makeMemFs(); + try { + fs.insertFile("/project/src/index.js", ""); + fs.insertFile( + "/project/package.json", + JSON.stringify({ name: "project", version: "1.0.0" }), + ); + + const resolver = Resolver.fromMemoryFileSystem(wasmModule, fs); + try { + const result = resolver.resolve("./index.js", "/project/src"); + expect(result).toEqual({ path: "/project/src/index.js" }); + } finally { + resolver.free(); + } + } finally { + fs.free(); + } + }); + + it("returns an error for a non-existent specifier", () => { + const fs = makeMemFs(); + try { + fs.insertFile( + "/project/package.json", + JSON.stringify({ name: "project", version: "1.0.0" }), + ); + + const resolver = Resolver.fromMemoryFileSystem(wasmModule, fs); + try { + const result = resolver.resolve("./missing.js", "/project/src"); + expect(result).toHaveProperty("error"); + expect(result).toHaveProperty( + "errorKind", + ResolveErrorKind.ModuleNotFound, + ); + } finally { + resolver.free(); + } + } finally { + fs.free(); + } + }); + + it("resolves a package export when package.json is present", () => { + const fs = makeMemFs(); + try { + fs.insertFile( + "/project/node_modules/my-pkg/package.json", + JSON.stringify({ + name: "my-pkg", + version: "1.0.0", + exports: { + ".": "./dist/index.js", + }, + }), + ); + fs.insertFile("/project/node_modules/my-pkg/dist/index.js", ""); + fs.insertFile( + "/project/package.json", + JSON.stringify({ name: "project", version: "1.0.0" }), + ); + + const resolver = Resolver.fromMemoryFileSystem(wasmModule, fs, { + conditionNames: ["require"], + }); + try { + const result = resolver.resolve("my-pkg", "/project/src"); + expect(result).toEqual({ + path: "/project/node_modules/my-pkg/dist/index.js", + }); + } finally { + resolver.free(); + } + } finally { + fs.free(); + } + }); + + it("resolves a directory index file", () => { + const fs = makeMemFs(); + try { + fs.insertFile("/project/src/utils/index.js", ""); + fs.insertFile( + "/project/package.json", + JSON.stringify({ name: "project", version: "1.0.0" }), + ); + + const resolver = Resolver.fromMemoryFileSystem(wasmModule, fs, { + extensions: ["js"], + defaultFiles: ["index"], + }); + try { + const result = resolver.resolve("./utils", "/project/src"); + expect(result).toEqual({ path: "/project/src/utils/index.js" }); + } finally { + resolver.free(); + } + } finally { + fs.free(); + } + }); +}); diff --git a/packages/@biomejs/resolver/tests/nodejs-fs.test.ts b/packages/@biomejs/resolver/tests/nodejs-fs.test.ts new file mode 100644 index 000000000000..458e82b907fa --- /dev/null +++ b/packages/@biomejs/resolver/tests/nodejs-fs.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for the Node.js real-filesystem-backed resolver. + * + * These tests resolve against the actual filesystem of this repository, so + * they require the Node.js WASM target to have been built beforehand. + */ + +import * as path from "node:path"; +import * as wasmModule from "@biomejs/wasm-resolver-nodejs"; +import { beforeAll, describe, expect, it } from "vitest"; +import { ensureInitialized, ResolveErrorKind, Resolver } from "../src/common"; +import { nodePathInfo, nodeReadFileUtf8 } from "../src/nodejsFileSystem"; + +beforeAll(() => { + ensureInitialized(wasmModule); +}); + +const repoRoot = path.resolve(__dirname, "../../../.."); + +describe("Node.js filesystem resolver", () => { + it("resolves a file that exists on disk", () => { + const resolver = Resolver.fromJsFileSystem( + wasmModule, + nodePathInfo, + nodeReadFileUtf8, + ); + try { + const result = resolver.resolve("./nodejs-fs.test.ts", __dirname); + expect(result).toEqual({ + path: path.join(__dirname, "nodejs-fs.test.ts"), + }); + } finally { + resolver.free(); + } + }); + + it("resolves a relative sibling file on disk", () => { + const resolver = Resolver.fromJsFileSystem( + wasmModule, + nodePathInfo, + nodeReadFileUtf8, + ); + try { + const result = resolver.resolve("./memory-fs.test.ts", __dirname); + expect(result).toEqual({ + path: path.join(__dirname, "memory-fs.test.ts"), + }); + } finally { + resolver.free(); + } + }); + + it("returns an error for a module that does not exist", () => { + const resolver = Resolver.fromJsFileSystem( + wasmModule, + nodePathInfo, + nodeReadFileUtf8, + ); + try { + const result = resolver.resolve("this-package-does-not-exist", repoRoot); + expect(result).toHaveProperty("error"); + expect(result).toHaveProperty( + "errorKind", + ResolveErrorKind.ModuleNotFound, + ); + } finally { + resolver.free(); + } + }); +}); diff --git a/packages/@biomejs/resolver/tsconfig.json b/packages/@biomejs/resolver/tsconfig.json new file mode 100644 index 000000000000..a263e3216818 --- /dev/null +++ b/packages/@biomejs/resolver/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "lib": [ + "ES2021" + ] + }, + "exclude": [ + "./tests", + "./dist" + ], + "include": [ + "./src" + ] +} diff --git a/packages/@biomejs/resolver/vitest.config.ts b/packages/@biomejs/resolver/vitest.config.ts new file mode 100644 index 000000000000..def60221552a --- /dev/null +++ b/packages/@biomejs/resolver/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: {}, +}); diff --git a/packages/@biomejs/wasm-resolver-nodejs/.gitignore b/packages/@biomejs/wasm-resolver-nodejs/.gitignore new file mode 100644 index 000000000000..decb62af82ae --- /dev/null +++ b/packages/@biomejs/wasm-resolver-nodejs/.gitignore @@ -0,0 +1,4 @@ +biome_resolver_wasm.d.ts +biome_resolver_wasm.js +biome_resolver_wasm_bg.wasm +biome_resolver_wasm_bg.wasm.d.ts diff --git a/packages/@biomejs/wasm-resolver-nodejs/package.json b/packages/@biomejs/wasm-resolver-nodejs/package.json new file mode 100644 index 000000000000..8d502472239e --- /dev/null +++ b/packages/@biomejs/wasm-resolver-nodejs/package.json @@ -0,0 +1,29 @@ +{ + "name": "@biomejs/wasm-resolver-nodejs", + "collaborators": [ + "Biome Developers and Contributors" + ], + "description": "WebAssembly bindings to the Biome resolver API (Node.js target)", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/biomejs/biome" + }, + "files": [ + "biome_resolver_wasm_bg.wasm", + "biome_resolver_wasm.js", + "biome_resolver_wasm.d.ts" + ], + "main": "biome_resolver_wasm.js", + "homepage": "https://biomejs.dev/", + "types": "biome_resolver_wasm.d.ts", + "keywords": [ + "resolver", + "module-resolution", + "wasm" + ], + "publishConfig": { + "provenance": true + } +} diff --git a/packages/@biomejs/wasm-resolver-web/.gitignore b/packages/@biomejs/wasm-resolver-web/.gitignore new file mode 100644 index 000000000000..decb62af82ae --- /dev/null +++ b/packages/@biomejs/wasm-resolver-web/.gitignore @@ -0,0 +1,4 @@ +biome_resolver_wasm.d.ts +biome_resolver_wasm.js +biome_resolver_wasm_bg.wasm +biome_resolver_wasm_bg.wasm.d.ts diff --git a/packages/@biomejs/wasm-resolver-web/package.json b/packages/@biomejs/wasm-resolver-web/package.json new file mode 100644 index 000000000000..df89f45db3d4 --- /dev/null +++ b/packages/@biomejs/wasm-resolver-web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@biomejs/wasm-resolver-web", + "type": "module", + "collaborators": [ + "Biome Developers and Contributors" + ], + "description": "WebAssembly bindings to the Biome resolver API (web target)", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/biomejs/biome" + }, + "files": [ + "biome_resolver_wasm_bg.wasm", + "biome_resolver_wasm.js", + "biome_resolver_wasm.d.ts" + ], + "main": "biome_resolver_wasm.js", + "homepage": "https://biomejs.dev/", + "types": "biome_resolver_wasm.d.ts", + "sideEffects": [ + "./snippets/*" + ], + "keywords": [ + "resolver", + "module-resolution", + "wasm" + ], + "publishConfig": { + "provenance": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bec57245e613..04a1242c51eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,10 +158,32 @@ importers: specifier: '*' version: link:../biome + packages/@biomejs/resolver: + devDependencies: + '@biomejs/wasm-resolver-nodejs': + specifier: workspace:* + version: link:../wasm-resolver-nodejs + '@biomejs/wasm-resolver-web': + specifier: workspace:* + version: link:../wasm-resolver-web + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.3.1 + version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.2) + vitest: + specifier: 4.0.18 + version: 4.0.18(@types/node@24.10.13)(happy-dom@20.7.0)(jiti@2.6.1)(yaml@2.8.2) + packages/@biomejs/wasm-bundler: {} packages/@biomejs/wasm-nodejs: {} + packages/@biomejs/wasm-resolver-nodejs: {} + + packages/@biomejs/wasm-resolver-web: {} + packages/@biomejs/wasm-web: {} packages/aria-data: