diff --git a/docs/runtime/plugins.mdx b/docs/runtime/plugins.mdx index 55e7ec5c5596..0d3779791445 100644 --- a/docs/runtime/plugins.mdx +++ b/docs/runtime/plugins.mdx @@ -177,7 +177,9 @@ The second argument to `onResolve()` is a callback that runs for each module imp The callback receives the _path_ to the matching module and can return a _new path_ for it. Bun reads the contents of the _new path_ and parses it as a module. -At runtime, the callback runs once for each `import` or `require()` as it executes. A _new path_ without a `namespace` is resolved from the importing module like any other import, without running `onResolve()` callbacks on it: it can be relative, leave out its extension, or name a package. If nothing is found there, the _new path_ is used as it is when an [`onLoad()`](#onload) callback matches it, so it does not have to exist on disk. +At runtime, the callback runs once for each `import` or `require()` as it executes. Bare package names and package subpaths, such as `"my-alias"` and `"@scope/pkg/subpath"`, can be redirected to files without adding an extension to the specifier. A _new path_ without a `namespace` is resolved from the importing module like any other import, without running `onResolve()` callbacks on it: it can be relative, leave out its extension, or name a package. If nothing is found there, the _new path_ is used as it is when an [`onLoad()`](#onload) callback matches it, so it does not have to exist on disk. + +Bare names keep normal resolution for builtins, imports made inside an `onResolve` callback, and `require.resolve()` calls with custom search paths. A bare name such as `"my-virtual.js"` could also be a package in the registry, so `onResolve()` callbacks run on it once more. If one of them returns a path, the name is the plugin's own and is never [auto-installed](/runtime/auto-install). diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 764473d5e348..eafb2121f429 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -728,8 +728,11 @@ impl JSGlobalObject { ) -> JsResult> { crate::mark_binding(); let ns = (namespace_.length() > 0).then_some(namespace_); + self.bun_vm().as_mut().on_resolve_depth += 1; let result = - crate::from_js_host_call(self, || Bun__runOnResolvePlugins(self, ns, path, source))?; + crate::from_js_host_call(self, || Bun__runOnResolvePlugins(self, ns, path, source)); + self.bun_vm().as_mut().on_resolve_depth -= 1; + let result = result?; if result.is_undefined_or_null() { return Ok(None); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 4b4a661820f3..199047146d25 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -216,6 +216,8 @@ pub struct VirtualMachine { /// (`exit_tears_down_napi_envs`). The list is never walked again, so a hook /// pushed after this (a finalizer deferred from the final collection) would only leak. pub(crate) has_run_cleanup_hooks: bool, + /// Number of active runtime onResolve calls. + pub(crate) on_resolve_depth: u32, pub is_main_thread: bool, pub exit_handler: ExitHandler, @@ -7659,8 +7661,27 @@ fn run_on_resolve( importer: &bun_core::String, ) -> JsResult>> { let specifier = specifier.to_utf8(); - let Some((namespace, path)) = ModuleLoader::plugin_namespace_and_path(&specifier) else { - return Ok(None); + let (namespace, path) = match ModuleLoader::plugin_namespace_and_path(&specifier) { + Some(parts) => parts, + None => { + let vm = global.bun_vm(); + if specifier.is_empty() + || !bun_resolver::is_package_path(&specifier) + || importer.length() == 0 + || vm.on_resolve_depth != 0 + || vm.transpiler.resolver.custom_dir_paths.is_some() + || ModuleLoader::HardcodedModule::Alias::get( + &specifier, + bun_ast::Target::Bun, + Default::default(), + ) + .is_some() + { + return Ok(None); + } + // Only onResolve admits bare names; onLoad keeps its builtin-safe pre-filter. + (&b""[..], specifier.slice()) + } }; // The importer's key ends in the query it was imported with. let importer = importer.to_utf8(); diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 8d1a4963235f..589738d2038d 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -791,14 +791,21 @@ it.concurrent("onResolve can redirect a specifier to a real file in the file nam using dir = tempDir("plugin-onresolve-file-namespace", { "real.js": `export const value = "redirected";`, "entry.js": ` + import { writeFileSync } from "node:fs"; import { join } from "node:path"; const target = join(import.meta.dir, "real.js"); + const createdTarget = join(import.meta.dir, "created.js"); Bun.plugin({ name: "redirect-to-file", setup(build) { build.onResolve({ filter: /^implicit\\.mod$/ }, () => ({ path: target })); + build.onResolve({ filter: /^extensionless-package$/ }, () => ({ path: target })); + build.onResolve({ filter: /^created-package$/ }, () => { + writeFileSync(createdTarget, 'export const value = "created";'); + return { path: createdTarget }; + }); build.onResolve({ filter: /^explicit\\.mod$/ }, () => ({ path: target, namespace: "file" })); build.onResolve({ filter: /^empty-namespace\\.mod$/ }, () => ({ path: target, namespace: "" })); build.onResolve({ filter: /^custom\\.mod$/ }, () => ({ path: "inner", namespace: "custom" })); @@ -820,6 +827,8 @@ it.concurrent("onResolve can redirect a specifier to a real file in the file nam console.log( JSON.stringify({ dynamicImport: await attempt(async () => (await import("implicit.mod")).value), + extensionlessPackage: await attempt(async () => (await import("extensionless-package")).value), + createdPackage: await attempt(async () => (await import("created-package")).value), explicitFileNamespace: await attempt(async () => (await import("explicit.mod")).value), emptyNamespace: await attempt(async () => (await import("empty-namespace.mod")).value), customNamespace: await attempt(async () => (await import("custom.mod")).value), @@ -844,6 +853,8 @@ it.concurrent("onResolve can redirect a specifier to a real file in the file nam // The fixture catches its own failures, so empty stdout means it crashed. expect(stdout.trim() ? JSON.parse(stdout) : { crashed: stderr }).toEqual({ dynamicImport: "redirected", + extensionlessPackage: "redirected", + createdPackage: "created", explicitFileNamespace: "redirected", emptyNamespace: "redirected", // A non-file namespace still round-trips through onLoad as "namespace:path".