Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9275f9a
node:module: implement findPackageJSON
eduardoaugustolb Aug 12, 2026
1b4a5f4
node:module: implement findPackageJSON on the resolver's directory cache
robobun Aug 12, 2026
bcdaf54
findPackageJSON: only report ModuleNotFound as ERR_MODULE_NOT_FOUND; …
robobun Aug 12, 2026
bfd1869
resolver: shorten the closest_existing_dir_info doc comment
robobun Aug 12, 2026
2663fe3
findPackageJSON: reject a base longer than the path buffer instead of…
robobun Aug 13, 2026
a8ec83e
findPackageJSON: throw ERR_INVALID_URL for a malformed file: URL
robobun Aug 13, 2026
c9a2d8e
findPackageJSON: reject URLs of any scheme other than file: with ERR_…
robobun Aug 13, 2026
6f67168
findPackageJSON: compare the URL scheme case-insensitively
robobun Aug 13, 2026
48c0708
findPackageJSON: report a package.json that exists even when it does …
robobun Aug 15, 2026
86f2ac8
findPackageJSON: find manifests in compiled executables; convert URLs…
robobun Aug 21, 2026
5483329
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 21, 2026
3fcb473
findPackageJSON: reject a symbol specifier like Node; drain the compi…
robobun Aug 21, 2026
5f5da3d
findPackageJSON: convert a URL object directly instead of sniffing it…
robobun Aug 21, 2026
07e0091
findPackageJSON: shorten the Location doc comment
robobun Aug 21, 2026
22037c2
findPackageJSON: adapt to bun_core::String owning its ref
robobun Aug 24, 2026
f88e3c1
findPackageJSON: shorten the file_url_to_path_from_js doc comment
robobun Aug 24, 2026
f83ac99
findPackageJSON: fix the base and specifier edge cases found in review
robobun Sep 1, 2026
46f3501
docs: findPackageJSON is no longer missing from node:module
robobun Sep 1, 2026
c1c4592
findPackageJSON: detect a directory base before the join drops a fore…
robobun Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi

### [`node:module`](https://nodejs.org/api/module.html)

🟡 Missing `Module#load()`, `registerHooks`, `findPackageJSON`, `stripTypeScriptTypes`, `getSourceMapsSupport`/`setSourceMapsSupport`. Overriding `require.cache`, `require.extensions` and `module._resolveFilename` is supported. `syncBuiltinESMExports`, `module._load`, `module._pathCache` and `module.register` are no-ops (we recommend [`Bun.plugin`](/runtime/plugins) instead). `findSourceMap` always returns `undefined`.
🟡 Missing `Module#load()`, `registerHooks`, `stripTypeScriptTypes`, `getSourceMapsSupport`/`setSourceMapsSupport`. Overriding `require.cache`, `require.extensions` and `module._resolveFilename` is supported. `syncBuiltinESMExports`, `module._load`, `module._pathCache` and `module.register` are no-ops (we recommend [`Bun.plugin`](/runtime/plugins) instead). `findSourceMap` always returns `undefined`.

### [`node:net`](https://nodejs.org/api/net.html)

Expand Down
196 changes: 194 additions & 2 deletions src/jsc/NodeModuleModule.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
use crate::resolve_message::esm_package_name;
use crate::{
self as jsc, JSArray, JSGlobalObject, JSValue, JsResult, StringJsc, Strong,
VirtualMachineRef as VirtualMachine,
self as jsc, CallFrame, JSArray, JSGlobalObject, JSValue, JsResult, StringJsc, Strong,
URLJsc as _, VirtualMachineRef as VirtualMachine,
};
use bstr::BStr;
use bun_ast::Loader;
use bun_bundler::options::DEFAULT_LOADERS;
use bun_core::{String as BunString, strings};
use bun_options_types::LoaderExt as _;
use bun_options_types::schema::api;
use bun_paths::resolve_path;
use core::ptr::NonNull;

// `bun.schema.api.Loader` — bindgen-emitted schema enum.
// Mirrored as a transparent `u8` because the schema enum is *open*
Expand Down Expand Up @@ -100,6 +104,194 @@ fn find_path_inner(
.ok())
}

// https://nodejs.org/api/module.html#modulefindpackagejsonspecifier-base
#[crate::host_fn(export = "NodeModuleModule__findPackageJSON")]
fn find_package_json(global: &JSGlobalObject, frame: &CallFrame) -> JsResult<JSValue> {
crate::mark_binding!();
if frame.arguments_count() == 0 {
return Err(global.throw_missing_arguments_value(&["specifier"]));
}
let specifier_value = frame.argument(0);
if specifier_value.is_symbol() {
return Err(global.throw_invalid_argument_type_value(
"specifier",
"string",
specifier_value,
));
}
let specifier = match Location::from_js(global, specifier_value) {
Ok(specifier) => specifier,
// Like Node, a value whose `toString()` throws is reported as the wrong type.
Err(jsc::JsError::Thrown) if global.clear_exception_except_termination() => {
return Err(global.throw_invalid_argument_type_value(
"specifier",
"string",
specifier_value,
));
}
Err(err) => return Err(err),
};
let base_value = frame.argument(1);
let base = if base_value.is_undefined() {
None
} else if base_value.is_string() || jsc::DOMURL::cast_(base_value, global.vm()).is_some() {
Some(Location::from_js(global, base_value)?)
} else {
return Err(global.throw_invalid_argument_type_value("base", "string", base_value));
Comment thread
robobun marked this conversation as resolved.
};

let is_url = matches!(specifier, Location::Url(_));
let specifier = specifier.into_path(global)?;
let specifier_utf8 = specifier.to_utf8();
let mut specifier = specifier_utf8.slice();
// `import()` ignores a query string (`./a.js?v=1`); a file URL already lost its own.
if !is_url {
if let Some(query) = strings::index_of_char_usize(specifier, b'?') {
specifier = &specifier[..query];
}
}
let base = base.map(|base| base.into_path(global)).transpose()?;
let base_utf8 = base.as_ref().map(BunString::to_utf8);

let top_level_dir = global.bun_vm().top_level_dir();
let mut base_buf = bun_paths::path_buffer_pool::get();
// `base` is the calling module (`__filename` / `import.meta.url`), so
// resolution starts in its directory; without one it starts in the cwd.
Comment thread
robobun marked this conversation as resolved.
let (source_dir, referrer): (&[u8], &[u8]) = match &base_utf8 {
Some(base) => {
// Like Node's `new URL(base)`: a path must be absolute.
if !bun_paths::is_absolute(base.slice()) {
let error = global
.err(jsc::ErrorCode::ERR_INVALID_URL, format_args!("Invalid URL"))
.to_js();
error.put(global, "input", base_value);
return Err(global.throw_value(error));
}
// A trailing separator (`dir/`, `file:///dir/`) names the directory itself.
// Checked before the join, which keeps only the platform's own separator.
Comment thread
robobun marked this conversation as resolved.
let is_directory = base
.slice()
.last()
.is_some_and(|&last| bun_paths::Platform::AUTO.is_separator(last));
let joined = resolve_path::join_abs_string_buf_checked::<bun_paths::platform::Auto>(
Comment thread
robobun marked this conversation as resolved.
top_level_dir,
&mut base_buf,
&[base.slice()],
);
// `None`: longer than any path the OS accepts.
let Some(base) = joined else {
return Err(global.throw_invalid_argument_value(b"base", base_value));
};
let source_dir = if is_directory {
base
} else {
bun_paths::dirname(base).unwrap_or(base)
};
(source_dir, base)
}
None => (top_level_dir, top_level_dir),
};

let mut log = bun_ast::Log::default();
// SAFETY: the per-thread VM outlives this synchronous call, and `log` is
// declared before the guard so it is still alive when the guard restores
// the resolver's previous log on drop.
let _restore_log = unsafe {
bun_resolver::Resolver::scoped_log(
core::ptr::addr_of_mut!((*global.bun_vm_ptr()).transpiler.resolver),
NonNull::from(&mut log),
)
};

let resolver = &mut global.bun_vm().as_mut().transpiler.resolver;
match resolver.find_package_json(source_dir, specifier) {
Ok(Some(package_dir)) => {
let dir = package_dir.abs_path;
let separator = match dir.last() {
Some(&last) if bun_paths::Platform::AUTO.is_separator(last) => "",
_ => bun_paths::SEP_STR,
};
BunString::create_format(format_args!("{}{}package.json", BStr::new(dir), separator))
.into_js(global)
}
Comment thread
robobun marked this conversation as resolved.
Ok(None) => Ok(JSValue::UNDEFINED),
Err(bun_resolver::Error::ModuleNotFound) => {
// Node names the package, or the path it looked for.
let mut path_buf = bun_paths::path_buffer_pool::get();
let (kind, name) =
if bun_paths::is_package_path(specifier) {
("package", esm_package_name(specifier))
} else {
let joined = resolve_path::join_abs_string_buf_checked::<
bun_paths::platform::Auto,
>(source_dir, &mut path_buf, &[specifier]);
("module", joined.unwrap_or(specifier))
};
Err(global
.err(
jsc::ErrorCode::ERR_MODULE_NOT_FOUND,
format_args!(
"Cannot find {} '{}' imported from {}",
kind,
BStr::new(name),
BStr::new(referrer)
),
)
.throw())
}
Err(err) => Err(global.throw(format_args!(
"{} while resolving '{}' from '{}'",
err.name(),
BStr::new(specifier),
BStr::new(referrer)
))),
}
}

/// A `specifier` or `base` argument: a path, or a `file:` URL to convert.
enum Location {
Url(JSValue),
/// Like Node, any value that is not a `URL` is used as `${value}`.
Text(BunString),
}

impl Location {
fn from_js(global: &JSGlobalObject, value: JSValue) -> JsResult<Self> {
if jsc::DOMURL::cast_(value, global.vm()).is_some() {
return Ok(Self::Url(value));
}
Ok(Self::Text(value.to_bun_string(global)?))
}

/// A URL is converted, and rejected, exactly as `Bun.fileURLToPath()` would.
fn into_path(self, global: &JSGlobalObject) -> JsResult<BunString> {
let url = match self {
Self::Url(url) => url,
Self::Text(text) => {
if !has_url_scheme(text.to_utf8().slice()) {
return Ok(text);
}
text.to_js(global)?
}
};
jsc::URL::file_url_to_path_from_js(url, global)
}
}

/// `file:`, `https://`, `node:` and the like. A scheme must be at least two
/// characters so that a Windows drive letter (`C:\`) is still a path.
Comment thread
robobun marked this conversation as resolved.
fn has_url_scheme(location: &[u8]) -> bool {
let Some(colon) = strings::index_of_char_usize(location, b':') else {
return false;
};
let scheme = &location[..colon];
scheme.len() >= 2
&& scheme[0].is_ascii_alphabetic()
&& scheme[1..]
.iter()
.all(|&c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))
}

pub fn stat(path: &[u8]) -> i32 {
// PERF: `exists_at_type`
// takes a `&ZStr`, so we copy into a NUL-terminated heap buffer here.
Expand Down
2 changes: 1 addition & 1 deletion src/jsc/ResolveMessage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ fn is_bare_esm_specifier(s: &[u8]) -> bool {

/// First path segment of a bare specifier ("@scope/name" keeps two),
/// matching Node's ERR_MODULE_NOT_FOUND "Cannot find package '<name>'".
fn esm_package_name(specifier: &[u8]) -> &[u8] {
pub(crate) fn esm_package_name(specifier: &[u8]) -> &[u8] {
let slash_after = |from: usize| {
bun_core::strings::index_of_char_usize(&specifier[from..], b'/')
.map_or(specifier.len(), |i| from + i)
Expand Down
8 changes: 8 additions & 0 deletions src/jsc/URL.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::{JSGlobalObject, JSValue, JsResult};
unsafe extern "C" {
safe fn URL__fromJS(value: JSValue, global: &JSGlobalObject) -> *mut URL;
safe fn URL__getHrefFromJS(value: JSValue, global: &JSGlobalObject) -> String;
safe fn Bun__fileURLToPath(global: &JSGlobalObject, value: JSValue) -> String;
}

/// JS-value entry points for [`URL`]; the rest of the API lives on
Expand All @@ -20,6 +21,13 @@ pub trait URLJsc {
crate::call_check_slow(global, || URL__getHrefFromJS(value, global))
}

/// `Bun.fileURLToPath()`: throws Node's errors for anything that is not a
/// plain `file:` URL, where `bun_url::path_from_file_url` converts blindly.
Comment thread
robobun marked this conversation as resolved.
#[track_caller]
fn file_url_to_path_from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<String> {
crate::call_check_slow(global, || Bun__fileURLToPath(global, value))
}

#[track_caller]
fn from_js(value: JSValue, global: &JSGlobalObject) -> JsResult<Option<Parsed>> {
crate::call_check_slow(global, || URL__fromJS(value, global))
Expand Down
32 changes: 28 additions & 4 deletions src/jsc/bindings/BunObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -836,11 +836,11 @@ JSC_DEFINE_HOST_FUNCTION(functionGenerateHeapSnapshot, (JSC::JSGlobalObject * gl
RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(jsonValue));
}

JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
// Node's `url.fileURLToPath()` rules. Returns a null string after throwing.
// Shared by `Bun.fileURLToPath()` and `node:module`'s `findPackageJSON()`.
Comment thread
robobun marked this conversation as resolved.
static WTF::String fileURLToPath(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, JSC::JSValue arg0)
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
JSValue arg0 = callFrame->argument(0);
WTF::URL url;

auto path = JSC::JSValue::encode(arg0);
Expand All @@ -849,6 +849,13 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
if (arg0.isString()) {
url = WTF::URL(arg0.toWTFString(globalObject));
RETURN_IF_EXCEPTION(scope, {});
if (!url.isValid()) [[unlikely]] {
// Like Node, the input goes on the error rather than into the message.
auto* error = createError(globalObject, ErrorCode::ERR_INVALID_URL, "Invalid URL"_s);
error->putDirect(vm, vm.propertyNames->input, arg0);
scope.throwException(globalObject, error);
return {};
}
} else {
Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "url"_s, "string"_s, arg0);
return {};
Expand Down Expand Up @@ -906,7 +913,24 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
}
#endif

return JSC::JSValue::encode(JSC::jsString(vm, fileSystemPath));
return fileSystemPath;
}

JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
auto path = fileURLToPath(globalObject, scope, callFrame->argument(0));
RETURN_IF_EXCEPTION(scope, {});
return JSC::JSValue::encode(JSC::jsString(vm, path));
}

extern "C" BunString Bun__fileURLToPath(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue value)
{
auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject));
auto path = fileURLToPath(globalObject, scope, JSC::JSValue::decode(value));
RETURN_IF_EXCEPTION(scope, { BunStringTag::Dead });
return Bun::toStringRef(path);
}

/* Source for BunObject.lut.h
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/modules/NodeModuleModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ using namespace JSC;
BUN_DECLARE_HOST_FUNCTION(Bun__JSSourceMap__find);

BUN_DECLARE_HOST_FUNCTION(Resolver__nodeModulePathsForJS);
BUN_DECLARE_HOST_FUNCTION(NodeModuleModule__findPackageJSON);
JSC_DECLARE_HOST_FUNCTION(jsFunctionFindPath);
JSC_DECLARE_HOST_FUNCTION(jsFunctionIsBuiltinModule);
JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeModuleCreateRequire);
Expand Down Expand Up @@ -961,6 +962,7 @@ builtinModules getBuiltinModulesObject PropertyCallback
constants getConstantsObject PropertyCallback
createRequire jsFunctionNodeModuleCreateRequire Function 1
enableCompileCache jsFunctionEnableCompileCache Function 1
findPackageJSON NodeModuleModule__findPackageJSON Function 1
findSourceMap Bun__JSSourceMap__find Function 1
flushCompileCache jsFunctionFlushCompileCache Function 0
getCompileCacheDir jsFunctionGetCompileCacheDir Function 0
Expand Down
9 changes: 9 additions & 0 deletions src/resolver/dir_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ impl DirInfo {
self.flags.contains(Flags::InsideNodeModules)
}

/// Is there a "package.json" file, parseable or not?
#[inline]
pub(crate) fn has_package_json_file(&self) -> bool {
self.flags.contains(Flags::HasPackageJsonFile)
}

/// Read-only view of `package_json`. The field stores `NonNull` to preserve
/// mut-provenance; callers that only read go through here.
#[inline]
Expand Down Expand Up @@ -377,6 +383,9 @@ bitflags::bitflags! {
/// This directory has a node_modules subdirectory
const HasNodeModules = 1 << 1;
const InsideNodeModules = 1 << 2;
/// This directory has a package.json file. Unlike `package_json`, this
/// is also set when the file could not be parsed.
Comment thread
robobun marked this conversation as resolved.
const HasPackageJsonFile = 1 << 3;
}
}
impl Flags {
Expand Down
Loading
Loading