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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,4 @@
- Match Node 24.21 package metadata validation during import and require, preserve lazy scope/condition checks and ignored fields, reject unreadable selected metadata, and retain CommonJS parent paths in resolution diagnostics. Adapts [oven-sh/bun#33890](https://github.com/oven-sh/bun/pull/33890) and [oven-sh/bun#35711](https://github.com/oven-sh/bun/pull/35711). Thanks @robobun and @cirospaciari!

- Preserve Node's deferred JSON diagnostics for both package maps and accept JSON-encoded maps in string fields.
- Refresh missing runtime files after `Bun.plugin` hook registration so delegated resolution sees newly created package-import targets.
2 changes: 2 additions & 0 deletions docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ Unreadable selected package metadata also throws `ERR_INVALID_PACKAGE_CONFIG`, r

`registerHooks()` supports synchronous resolve/load chaining, source transformation, and deregistration. `nextResolve` honors parent URLs and custom conditions, and hooks preserve query/fragment module identities. Dynamic-import attributes, including attributes returned by resolve hooks, reach the matching load hook.

Runtime `Bun.plugin()` hooks retry missing native resolutions with the filesystem refresh policy used by `registerHooks()`. Delegated resolution can load package-import targets created after their directory was cached, including files created inside an `onResolve` callback that returns `undefined`.

Hooks receive absolute URLs for Bun's built-in package replacements, including `ws`, `undici`, the fetch polyfills, `abort-controller`, and `utf-8-validate`. When the requested package is installed, resolve and load hooks observe its resolved `file:` URL. Calling `nextLoad()` still serves Bun's native replacement; a load hook can short-circuit with its own source. If there is no resolvable package file, the hook URL uses `bun-builtin:<canonical-name>` (for example, `bun-builtin:ws`) with format `"builtin"`. Bare `bun` uses `bun-builtin:bun`. The `ffi` alias follows the same installed-file rule and otherwise uses `bun-builtin:ffi`, which delegates to `bun:ffi`. These URLs can be parsed with `new URL()`. Node builtins retain `node:` URLs, including `node:sys` and exposed `node:internal/...` modules. Direct imports of the internal `bun:`, `builtin:`, and `macro:` namespaces retain their existing native loading path.

Opaque `Bun.plugin()` identifiers with namespaces that are not valid URL schemes use `bun-virtual:<percent-encoded-native-identifier>` with format `"builtin"`. For example, `my_plugin:entry` becomes `bun-virtual:my_plugin%3Aentry`. Hooks observe this URL consistently, while plugin callbacks receive their original native identifiers. These synthetic URLs remain loadable after hooks deregister. Programs that never register hooks keep their existing resolution and replacement behavior.
Expand Down
1 change: 1 addition & 0 deletions src/jsc/virtual_machine_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ pub fn on_did_append_plugin(jsc_vm: &mut VirtualMachine, global: &JSGlobalObject
global_object: bun_ptr::BackRef::new(global),
});
jsc_vm.transpiler.linker.plugin_runner = Some(std::ptr::from_mut::<dyn PluginResolver>(runner));
jsc_vm.transpiler.resolver.refresh_runtime_plugin_misses = true;
}

#[cfg(windows)]
Expand Down
120 changes: 90 additions & 30 deletions src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ pub struct Resolver<'a> {
pub generation: Generation,
/// Hook-created dependency directories outlive deregistration.
pub runtime_mutable_directories: bool,
/// Plugin-created files outlive hooks, but successful lookups keep their cache policy.
pub refresh_runtime_plugin_misses: bool,
/// Node package lookup for hook URLs must ignore even already-cached tsconfig aliases.
pub ignore_tsconfig_paths: bool,

Expand Down Expand Up @@ -632,6 +634,7 @@ impl<'a> Resolver<'a> {
caches: CacheSet::init(),
generation: from.generation,
runtime_mutable_directories: false,
refresh_runtime_plugin_misses: false,
ignore_tsconfig_paths: false,
package_manager: from.package_manager,
on_wake_package_manager: from.on_wake_package_manager,
Expand Down Expand Up @@ -935,6 +938,7 @@ impl<'a> Resolver<'a> {
watcher: None,
generation: 0,
runtime_mutable_directories: false,
refresh_runtime_plugin_misses: false,
ignore_tsconfig_paths: false,
package_manager: None,
on_wake_package_manager: Default::default(),
Expand Down Expand Up @@ -1468,7 +1472,22 @@ impl<'a> Resolver<'a> {
}
ResultUnion::NotFound => {
let _ = self.flush_debug_logs(FlushMode::Fail);
ResultUnion::NotFound
if self.refresh_runtime_plugin_misses
&& !self.runtime_mutable_directories
&& self
.node_module_error
.as_ref()
.is_none_or(|error| !error.is_fatal())
{
self.retry_runtime_plugin_miss(
source_dir_normalized,
import_path,
kind,
global_cache,
)
} else {
ResultUnion::NotFound
}
}
};

Expand Down Expand Up @@ -4063,7 +4082,6 @@ impl<'a> Resolver<'a> {

if self.probe_target_extensions(
resolved_dir_info,
dirname_fd,
package_json,
base,
extension_order,
Expand All @@ -4085,7 +4103,6 @@ impl<'a> Resolver<'a> {
if ends_with_star
&& self.probe_target_extensions(
resolved_dir_info,
dirname_fd,
package_json,
base,
extension_order,
Expand Down Expand Up @@ -4211,7 +4228,6 @@ impl<'a> Resolver<'a> {
fn probe_target_extensions(
&mut self,
resolved_dir_info: DirInfoRef,
dirname_fd: FD,
package_json: &PackageJSON,
base: &[u8],
extension_order: options::ExtOrder,
Expand All @@ -4223,12 +4239,13 @@ impl<'a> Resolver<'a> {
if is_wildcard && bun_paths::extension(base).is_empty() {
let buf = bufs!(load_as_file);
buf[..base.len()].copy_from_slice(base);
for ext in self.opts.ext_order_slice(extension_order).iter() {
let ext: &[u8] = ext;
for i in 0..self.opts.ext_order_slice(extension_order).len() {
let ext = bun_ptr::RawSlice::new(&*self.opts.ext_order_slice(extension_order)[i]);
let ext: &[u8] = &ext;
let file_name = &mut buf[0..base.len() + ext.len()];
file_name[base.len()..].copy_from_slice(ext);
if let Some(ext_query) =
resolved_dir_info.get_entry(self.generation, &file_name[..])
if let Some((Some(ext_query), dirname_fd)) =
self.lookup_directory_entry(resolved_dir_info, file_name)
{
// SAFETY: rfs points at the process-global RealFS; the lazy-stat
// rewrite inside `kind()` is serialized on the per-entry mutex.
Expand Down Expand Up @@ -4268,8 +4285,8 @@ impl<'a> Resolver<'a> {
for &replacement in ts_exts.iter() {
let file_name = &mut buf[0..segment.len() + replacement.len()];
file_name[segment.len()..].copy_from_slice(replacement);
if let Some(ts_query) =
resolved_dir_info.get_entry(self.generation, &file_name[..])
if let Some((Some(ts_query), dirname_fd)) =
self.lookup_directory_entry(resolved_dir_info, file_name)
{
// SAFETY: rfs points at the process-global RealFS; the lazy-stat
// rewrite inside `kind()` is serialized on the per-entry mutex.
Expand Down Expand Up @@ -5731,26 +5748,13 @@ impl<'a> Resolver<'a> {
base[0..b"index".len()].copy_from_slice(b"index");
base[b"index".len()..].copy_from_slice(ext);

// Lookup + listing fd in one critical section (see `DirInfo::get_entry`
// for the rewrite this guards against); the fd gate matches
// `DirInfo::get_file_descriptor`.
let looked_up = {
let realfs = &mut Fs::FileSystem::instance().fs;
let _entries_lock = realfs.entries_mutex.lock_guard();
dir_info
.get_entries_ref_locked(self.generation)
.map(|entries| {
(
entries.get(&base[..]),
if FeatureFlags::STORE_FILE_DESCRIPTORS {
entries.fd
} else {
FD::INVALID
},
)
})
};
let looked_up = self.lookup_directory_entry(dir_info, base);
if let Some((Some(lookup), dirname_fd)) = looked_up {
let dirname_fd = if FeatureFlags::STORE_FILE_DESCRIPTORS {
dirname_fd
} else {
FD::INVALID
};
// SAFETY: rfs points at the process-global RealFS; the lazy-stat
// rewrite inside `kind()` is serialized on the per-entry mutex.
if unsafe { lookup.entry().kind(rfs, self.store_fd) }
Expand Down Expand Up @@ -6172,6 +6176,57 @@ impl<'a> Resolver<'a> {
dec_ret!(MatchStatus::NotFound);
}

#[cold]
fn retry_runtime_plugin_miss(
&mut self,
source_dir: &[u8],
import_path: &'static [u8],
kind: ast::ImportKind,
global_cache: GlobalCache,
) -> ResultUnion {
// Recheck plugin-created files only after the cached resolution misses.
self.runtime_mutable_directories = true;
let mut resolver = scopeguard::guard(self, |resolver| {
resolver.runtime_mutable_directories = false;
});
resolver.resolve_and_auto_install(source_dir, import_path, kind, global_cache)
}

#[inline]
fn lookup_directory_entry(
&mut self,
dir_info: DirInfoRef,
name: &[u8],
) -> Option<(Option<crate::fs::EntryLookup<'static>>, FD)> {
let generation = self.generation;
// Entry and listing fd must come from the same locked directory snapshot.
let lookup = || {
let rfs = &mut Fs::FileSystem::instance().fs;
let _entries_lock = rfs.entries_mutex.lock_guard();
dir_info
.get_entries_ref_locked(generation)
.map(|entries| (entries.get(name), entries.fd))
};
let mut result = lookup();
if result.as_ref().is_none_or(|(entry, _)| entry.is_none())
&& self.refresh_created_file_in_directory(dir_info.abs_path, name)
{
result = lookup();
}
result
}

fn refresh_created_file_in_directory(&mut self, dir: &[u8], name: &[u8]) -> bool {
if !self.runtime_mutable_directories {
return false;
}
let mut buffer = bun_paths::path_buffer_pool::get();
let Some(path) = self.fs_ref().abs_buf_checked(&[dir, name], &mut buffer) else {
return false;
};
self.refresh_created_file(path)
}

fn refresh_created_file(&mut self, path: &[u8]) -> bool {
if !self.runtime_mutable_directories {
return false;
Expand Down Expand Up @@ -6343,7 +6398,12 @@ impl<'a> Resolver<'a> {
let buffer = &mut tail[0..segment.len() + ext_to_replace.len()];
buffer[segment.len()..].copy_from_slice(ext_to_replace);

let (ts_query, ts_dirname_fd) = dir_entry.get().lookup(&buffer[..]);
let (mut ts_query, mut ts_dirname_fd) = dir_entry.get().lookup(&buffer[..]);
if ts_query.is_none()
&& self.refresh_created_file_in_directory(dir_path, buffer)
{
(ts_query, ts_dirname_fd) = dir_entry.get().lookup(&buffer[..]);
}
if let Some(query) = ts_query {
// SAFETY: rfs points at the process-global RealFS; the lazy-stat
// rewrite inside `kind()` is serialized on the per-entry mutex.
Expand Down
150 changes: 150 additions & 0 deletions test/js/bun/plugin/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,156 @@ declare global {
var asyncret: any;
}

async function expectPluginFixtureOutput(dir: string, expectedStdout: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "main.mjs"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: expectedStdout, stderr: "", exitCode: 0 });
}

it.concurrent("delegated resolution refreshes a newly created directory index", async () => {
using dir = tempDir("plugin-delegated-index", {
"generated/seed.cjs": 'exports.value = "seed";',
"entry.cjs": "exports.read = name => require(name).value;",
"main.mjs": `
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
assert.equal(require("./generated/seed.cjs").value, "seed");
const entry = require("./entry.cjs");
Bun.plugin({
name: "late-directory-member",
setup(builder) {
builder.onLoad({ filter: /^never$/ }, () => { throw new Error("unexpected onLoad"); });
},
});
writeFileSync(join(import.meta.dir, "generated/index.js"), 'exports.value = "created";');
console.log(entry.read("./generated"));
`,
});
await expectPluginFixtureOutput(String(dir), "created\n");
});

it.each(["imports", "main", "wildcard"])("delegated resolution refreshes late %s extension candidates", async shape => {
const directory = shape === "main" ? "node_modules/late-package" : "generated";
const target = `${directory}/value.${shape === "wildcard" ? "js" : "ts"}`;
const request = shape === "main" ? "late-package" : shape === "imports" ? "#late" : "#wild/value";
using dir = tempDir("plugin-delegated-extension", {
"package.json": JSON.stringify({
imports: { "#late": "./generated/value.js", "#wild/*": "./generated/*" },
}),
[`${directory}/package.json`]: JSON.stringify({ name: "late-package", main: "./value.js" }),
[`${directory}/seed.cjs`]: 'exports.value = "seed";',
"entry.cjs": "exports.read = name => require(name).value;",
"main.mjs": `
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
assert.equal(require(${JSON.stringify("./" + directory + "/seed.cjs")}).value, "seed");
const entry = require("./entry.cjs");
Bun.plugin({
name: "late-extension",
setup(builder) {
builder.onLoad({ filter: /^never$/ }, () => { throw new Error("unexpected onLoad"); });
},
});
writeFileSync(join(import.meta.dir, ${JSON.stringify(target)}), 'exports.value = "created";');
console.log(entry.read(${JSON.stringify(request)}));
`,
});
await expectPluginFixtureOutput(String(dir), "created\n");
});

for (const mode of ["require", "import"] as const) {
it.concurrent(`delegated static ${mode} finds a file in a previously missing directory`, async () => {
using dir = tempDir("plugin-delegated-missing-directory", {
"package.json": JSON.stringify({
imports: { "#late": "./generated/late.cjs", "#missing": "./generated/missing.cjs" },
}),
"entry.cjs": 'module.exports = require("#late");',
"entry.mjs": 'export { value } from "#late";',
"main.mjs": `
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
assert.throws(() => require.resolve("./generated/late.cjs"), { code: "MODULE_NOT_FOUND" });
let calls = 0;
Bun.plugin({
name: "delegated-missing-directory",
setup(builder) {
builder.onResolve({ filter: /^#late$/ }, () => {
calls++;
mkdirSync(join(import.meta.dir, "generated"), { recursive: true });
writeFileSync(join(import.meta.dir, "generated/late.cjs"), 'exports.value = "late";');
});
},
});
const loaded = ${mode === "require" ? 'require("./entry.cjs")' : 'await import("./entry.mjs")'};
assert.equal(loaded.value, "late");
assert.ok(calls > 0);
assert.throws(() => require("#missing"), { code: "MODULE_NOT_FOUND" });
console.log("late");
`,
});
await expectPluginFixtureOutput(String(dir), "late\n");
});

for (const creation of ["onResolve", "after-entry", "onLoad-only"] as const) {
it.concurrent(`delegated ${mode} finds a package import created ${creation}`, async () => {
using dir = tempDir("plugin-delegated-created-file", {
"package.json": JSON.stringify({
imports: { "#selected/*": "./broad.cjs", "#selected/*.js": "./specific.cjs" },
}),
"broad.cjs": 'exports.value = "broad";',
"entry.cjs": "exports.read = name => require(name).value;",
"entry.mjs": "export const read = async name => (await import(name)).value;",
"main.mjs": `
import assert from "node:assert/strict";
import { existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createRequire } from "node:module";
const target = join(import.meta.dir, "specific.cjs");
const writeTarget = () => writeFileSync(target, 'exports.value = "specific";');
let calls = 0;
Bun.plugin({
name: "delegated-created-file",
setup(builder) {
if (${JSON.stringify(creation)} === "onLoad-only") {
builder.onLoad({ filter: /never-matches/ }, () => { throw new Error("unexpected onLoad"); });
} else {
builder.onResolve({ filter: /^#selected\\// }, () => {
calls++;
if (${JSON.stringify(creation)} === "onResolve") writeTarget();
assert.equal(existsSync(target), true);
return undefined;
});
}
},
});
const loaded = ${mode === "require" ? 'createRequire(import.meta.url)("./entry.cjs")' : 'await import("./entry.mjs")'};
assert.equal(existsSync(target), false);
if (${JSON.stringify(creation)} !== "onResolve") writeTarget();
assert.equal(await loaded.read("#selected/leaf.js"), "specific");
assert.equal(calls, ${creation === "onLoad-only" ? 0 : 1});
console.log("specific");
`,
});
await expectPluginFixtureOutput(String(dir), "specific\n");
});
}
}

plugin({
name: "url text file loader",
setup(builder) {
Expand Down
Loading