diff --git a/CHANGELOG.md b/CHANGELOG.md index 641d62fbed93..1b288a7e82e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 3e825ad836d5..f0118488b0ea 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -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:` (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:` 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. diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 15e371b99ce9..18786b39935c 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -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::(runner)); + jsc_vm.transpiler.resolver.refresh_runtime_plugin_misses = true; } #[cfg(windows)] diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 0ca028ac11f4..8bf71011004c 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -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, @@ -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, @@ -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(), @@ -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 + } } }; @@ -4063,7 +4082,6 @@ impl<'a> Resolver<'a> { if self.probe_target_extensions( resolved_dir_info, - dirname_fd, package_json, base, extension_order, @@ -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, @@ -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, @@ -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. @@ -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. @@ -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) } @@ -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>, 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; @@ -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. diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 2c53f9fe6df3..ebc41089fb30 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -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) {