Repository navigation
Conversation
could_be_plugin skipped any specifier with no file extension and no namespace colon, so a bare specifier like host-package/subpath never reached a runtime plugin's onResolve hook and resolution failed with "Cannot find module". Let bare specifiers through the pre-filter. Extension-less relative and absolute paths stay excluded. Fixes #40397
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review. WalkthroughThe resolver now tracks nested ChangesPlugin resolution
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The resolver changes and regression coverage present no concrete merge-blocking risk. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
I worked on #40397 in parallel and stopped when I found this PR. My branch is
Two tests in |
…ries A resolved key that has neither a registered namespace nor an absolute path is a builtin name or a data: key. No file exists for it, so the file namespace has nothing to load and the old extension heuristic is not needed. This also keeps bare builtin names out of the file namespace under bun test once could_be_plugin accepts them (#40398). The <cwd>/[eval] and <cwd>/[stdin] keys of bun -e and bun - are absolute by shape but have no file either. Stock bun ran the file namespace filters for them only when the cwd contained a dot.
|
Heads-up from #40465, which changes the same area. Two interactions:
|
…onresolve-bare-specifier
The `could_be_plugin` pre-filter let a specifier reach the runtime
onResolve hook only with a `.ext` or a `namespace:` prefix. A bare name
(`pkg`, `pkg/sub`, `@scope/pkg`) or an extension-less relative path
(`./other`) never reached a filter that matches it.
`resolve_maybe_needs_trailing_slash` now also runs the hook for such a
specifier when user code imports it. Four cases keep the old
pre-filter, because each one broke code that works today:
- no referrer: the loader resolves each module key once more
- a builtin name (`fs`, `ws`), which a static import never shows to
the hook
- inside an onResolve callback, where `require("pkg")` would call the
hook again without end
- inside `require.resolve(id, { paths })`, whose paths are resolver
state
The linker and the onLoad pre-filter are unchanged. A hook call at
link time runs outside the caller's try/catch, and its result goes
into the transpiler cache.
Results: for a newly hooked specifier, a `path` equal to the specifier
claims nothing, so a no-op hook stays transparent. A relative or bare
`path` for such a specifier, and an absolute `path` whose file name
has no extension for any specifier, now go through the resolver from
the same importer, without hooks, without auto-install and without a
directory cache bust. When the resolver finds nothing, or the path
cannot fit a path buffer, the string is the module key, as before.
This replaces the first version of this PR, which widened
`could_be_plugin` for all three call sites. The
`extensionlessPackage` test row comes from #42939.
Co-authored-by: Peter Steinberger <steipete@gmail.com>
|
I reworked this PR. The first version widened
The new version does not touch How to reproduce the original problem: Checked: The second fix in #42939 (a file that |
|
Updated 2:15 AM PT - Sep 17th, 2026
❌ @robobun, your commit debb291 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 40398That installs a local version of the PR into your bun-40398 --bun |
There was a problem hiding this comment.
Beyond the inline findings, I also checked two spots in this push and they hold up: the on_resolve_depth bracket in src/jsc/JSGlobalObject.rs decrements before the ?, so a throwing onResolve callback does not leave the recursion guard stuck; and the redirected / specifier_utf8 reassignment in resolve_maybe_needs_trailing_slash keeps the borrowed UTF-8 view inside the lifetime of redirected, which is declared in the enclosing scope.
Extended reasoning...
The bug hunt on this push hit its finding cap, so approval is not on the table and the inline comments already signal that a human should look. This note only records the two additional concerns I traced from the diff and ruled out — the re-entrancy counter's error-path balance in src/jsc/JSGlobalObject.rs and the bun_core::String::to_utf8() borrow lifetime across the plugin-redirect swap in src/jsc/VirtualMachine.rs — so the author does not have to re-derive them. It is informational and not a correctness guarantee for the rest of the change.
…cifier
A no-op hook (args => ({ path: args.path })) returned the specifier
verbatim as the module key for a specifier that passed the pre-filter,
so require("dotted.pkg") and require("./config.local/index") failed
with ENOENT. Such a result now goes through the resolver from the
importer. A miss keeps the verbatim key, so a file-namespace onLoad
keyed on the unchanged specifier still serves it.
|
Ready for review. All review threads are resolved. On the latest CI run (debb291) the only test that is red on every retry is |
Admit bare aliases at runtime using the guards from oven-sh#44593 and @robobun's oven-sh#40398. Keep onLoad filtering and nested resolution policy. Use the resolver's existing cache-miss retry for files created by hooks. Preserve the original extensionless and created-target regressions.
Carry caller intent across the Rust/C++ plugin boundary and consume the synchronous dynamic-import marker before callbacks can reenter resolution. Retain static, require, require.resolve and runtime resolver distinctions. Use oven-sh#44593's bare-alias guards adapted from @robobun's oven-sh#40398. Preserve original assertions and add static-import and nested-resolution coverage.
Problem
onResolvenever runs for a specifier without a file extension.await import("host-package/subpath")fails withCannot find module 'host-package/subpath'even when a filter matches (Runtime Bun.plugin onResolve does not run for dependencies of dynamically imported modules #40397, Runtime plugin onResolve never fires for bare or virtual specifiers (onLoad works) #40579). A catch-all filter never seesimport "./other"(onResolve plugin callback seems to be sporadically called #12261).could_be_pluginpre-filter (src/bundler/transpiler.rs:88). It admits only a.extor anamespace:prefix.Fix
resolve_maybe_needs_trailing_slashalso runs the hook for a bare or relative specifier that user code imports. Four cases keep the old pre-filter: no referrer, a builtin name, inside anonResolvecallback, insiderequire.resolve(id, { paths }).pathequal to the specifier claims nothing for such a specifier, and goes through the resolver from the importer for any other. A relative or barepathfor such a specifier does the same. The resolver runs without hooks and without auto-install. If nothing is found, the string stays the module key.test/js/bun/plugin/plugins.test.ts(stock bun fails 6 tests), on Linux and Windows. Also the plugin, resolve, mock-module, isolation and hot suites.Background
Bun.plugin()registers a runtime plugin.onResolvemaps a specifier to a path.onLoadsupplies the source for a path.resolve_maybe_needs_trailing_slash(every resolve at run time).import()once more, with no referrer.Notes
Why each rule exists. Each one comes from a case that works on stock bun and failed with a wider pre-filter. I found them with probes of real plugin shapes against a stock release build.
bun test,Bun__runVirtualModuleruns before the builtin lookup.require("ws")reached a catch-allonLoad({ filter: /.*/ })withpath: "ws":ENOENT: no such file or directory, open 'ws'.require("optional-dep")insidetry/catch, a hook that throws for a missing name made the whole module fail to load. The hook result also went into the on-disk transpiler cache, so a later run with no plugin loaded the redirected module. A hook that loads a module at that time panics (import_record_index < import_records.len()). Stock has all three for a specifier with an extension.import()once more. A hookmy-pkgtomy-pkg/distran again on its own result:Cannot find module 'my-pkg/dist/dist'.fsnever reaches the hook, because the transpiler resolves it.require("fs")did reach it, so a hook could claim a builtin for one import kind only.const x = require("helper-pkg")inside a catch-all callback called the hook again:RangeError: Maximum call stack size exceeded.require.resolve(id, { paths })pathsis resolver state (custom_dir_paths). A nested resolve inside the callback used and cleared it. Debug builds stop atdebug_assert!(custom_dir_paths.is_none())(BunObject.rs:1246).pathclaims nothingonResolve({ filter: /.*/ }, args => ({ path: args.path }))brokeimport pkg from "dep-pkg"(ENOENT reading "dep-pkg") andrequire("..")("path" is invalid in onResolve plugin). For a specifier the hook already saw (dotted.pkg,./config.local/index) stock bun has the sameENOENT. Such an unchanged result now goes through the resolver from the importer, and a miss keeps the verbatim key so a file-namespaceonLoadkeyed on it still serves it.require(path.join(__dirname, "lib/foo"))under the no-op hook:ENOENT reading "/abs/lib/foo". An absolute result ends the resolve, so the resolver never added.js.import()found it from cwd through the second resolve. A static import andrequire()failed:ENOENT reading "./services/__mocks__/api".build.module("my-shim"),mock.module) or a path that only a file-namespaceonLoadserves. Without the fallback these stopped working. With auto-install, the resolver asked the registry formy-shim._resolvebusts the directory cache and retries after a miss. For a result that names a virtual module, everyrequire()re-read the directory: 4 s for 2000 calls in a 300-file directory (stock: 3 ms), and one cache slot lost for each call.require.resolve("/" + "a".repeat(4092))with no plugin). A result goes to the resolver only when importer, result and an extension fit inMAX_PATH_BYTES. A longer result stays the module key.Absolute result with no extension. For every specifier, an absolute
pathwhose file name has no extension (/src/store) now goes through the resolver too, with the same fallback. The loader already does this forimport()through its second resolve, and the linker path does it for a static import.require()failed withENOENT reading "/src/store". Without this, a hook that maps@/storetopath.join(root, "src/store")works forimport()only.What a plugin author can see
Bun.resolveSync(args.path, ...)) now also decides export conditions for bare packages. The callback args carry nokind(namespaceproperty is undefined inOnLoadArgsof Bun plugin #3894), so such a hook cannot pickrequireconditions. That was already true for every specifier with a dot.pathequal to the specifier ends the hook chain, as every result does. Later hooks do not run for that import, and then normal resolution continues.Cannot find modulewithout one).require.resolve(id, { paths })and builtin names do not reach the hook.Cost. The new check runs only when a plugin is registered. Measured on the stock release build (1.4.3-canary, c6b7fcb, Linux x64, best of 7): 5000 modules, 10,000 static imports, one registered filter that never matches. With
.jsin each import (the hook runs today, twice for each import): 263 ms. With no extension (the hook is skipped today): 236 ms. With no plugin: 124 ms and 135 ms. So one hook call costs about 2 microseconds. After this PR an extension-less import calls the hook once, not twice, because the linker does not call it.Relation to #42939. #42939 made the same pre-filter change as the first version of this PR. Its second fix (a file that
onResolvecreates is not found:Cannot find module '<path>' from '') is a separate resolver bug and is not part of this PR. It relates to #40585, #40587 and #40279. TheextensionlessPackagetest row comes from #42939, with a co-author credit on the commit.Earlier work. c230fe1 (
farm/b3b5d814/plugin-onresolve-bare-specifiers, offered in this thread) removes the pre-filter at both sites and resolves a relative result from the importer. This PR takes the second idea, with the fallback and without auto-install, and keeps the linker as it is for the reasons in the table.Known and not changed here
require()in a file that is transpiled afterBun.plugin()fails whenonResolveanswers with a custom namespace, for a specifier with an extension:Cannot find module 'host:host-package/other.mod'. The linker rewrites the specifier, andrequire()cannot resolvens:path(Runtime plugin: a literal require() fails when onResolve answers with a custom namespace and the file is transpiled after Bun.plugin() #43025)./my.app/lib/foo) passescould_be_plugin. plugin: run onResolve/onLoad for imports whose query string contains a dot #37702 and plugin: match onLoad filters for extensions starting with a digit #36592 change that function. This PR does not touch it.could_be_pluginfrom the onLoad site. The two PRs touch different files.Tests (all fixtures use
--no-install, so a bare name that no plugin claims does not reach the npm registry when a test fails)extensionlessPackage,noExtensionResultRequire), "onResolve sees scoped, bare and extension-less relative specifiers", "a relative or bare onResolve result for a bare specifier resolves from the importer", "an onResolve callback can require and resolve modules itself".requireDottedBare,requireDottedRelativeDirectory).test/js/bun/plugin/,test/js/bun/resolve/(resolve, resolve-error, resolve-bad-parent, import-meta-resolve, import-meta, require, resolve-ts, import-query, build-error),test/js/bun/test/mock/mock-module.test.ts,test/cli/test/isolation.test.ts,test/cli/run/preload-test.test.js,test/js/node/module/node-module-module.test.js,test/js/node/v8/capture-stack-trace.test.js,test/bundler/bundler_plugin.test.ts,test/cli/hot/hot.test.ts,test/regression/issue/22199.test.ts,test/regression/issue/12548.test.ts.[human-review] gate passed · iteration 1 · 3 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 2 passed · 0 rejected · iteration 1
evidence per changed file