Skip to content

Ask a runtime plugin's onResolve once: fix require() of a path a plugin moves into a namespace - #44474

Merged
dylan-conway merged 4 commits into
claude/module-key-resolved-oncefrom
claude/onresolve-once
Oct 2, 2026
Merged

dylan-conway merged 4 commits into
claude/module-key-resolved-oncefrom
claude/onresolve-once

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Stacked on #44473.

What does this PR do?

A runtime plugin's onResolve was asked twice about every specifier the transpiler could read: import and export ... from statements, and require("..."), module.require("...") and require.resolve("...") with a literal. The second time it was asked about its own answer.

What that broke, with a plugin from a --preload:

build.onResolve({ filter: /\.virtual$/ }, ({ path }) => ({ path: "from " + basename(path), namespace: "virtual" }));
build.onLoad({ filter: /.*/, namespace: "virtual" }, ({ path }) => ({ contents: "export const from = " + JSON.stringify(path), loader: "js" }));
require("./x.virtual");   // error: Cannot find package 'virtual:from x.virtual'
import "./x.virtual";     // works

Cause

Linker::link put every import record that is not a dynamic import through onResolve while the file was transpiled, and printed the answer into the code. When the code ran, the module loader or require() resolved what was printed, which asks onResolve again. Nothing else is resolved while transpiling at run time: link only maps the names of builtins.

Printing the namespace into the path (PRINT_NAMESPACE_IN_PATH, "used to prevent running resolve plugins multiple times for the same path") hid that for one case: an answer with a namespace, read back by the module loader's resolve hook, which returned any key in a namespace that has an onLoad handler as it was. require() has no such shortcut. An answer without a namespace was always asked about again.

Fix

onResolve is asked when the code runs, like for a specifier the transpiler cannot read, and not before. Removed:

  • the block in Linker::link, the PluginResolver trait and Linker::plugin_runner it went through, and PluginRunner::on_resolve, which was a second copy of plugin_runner_on_resolve_jsc and leaked a buffer for each answer
  • PRINT_NAMESPACE_IN_PATH and what the printer did for it
  • the shortcut in the resolve hook, with its FIXME: with the linker's call gone it would keep onResolve from an import written as "ns:...", which isolation.test.ts has a test of

VirtualMachine::plugin_runner was only ever asked whether it was there, so it is has_plugins: bool.

import() had the same fault from the other side: moduleLoaderImportModule resolved the specifier and handed the loader the answer, and requestImportModule resolves what it is handed. It hands over the specifier and the referrer now, as WebCore's does, so the loader's call is the only one. (With the shortcut still there, that would have kept onResolve from import("ns:...").)

What is printed for such an import changes, so the version of the transpiler cache goes from 33 to 34.

Every way to load or resolve

13 ways, to an ES module, a CommonJS module and a path that onResolve moves into a namespace, with a literal and with a specifier the parser cannot know, with the plugin from a --preload and from the file itself: 138 cases. onResolve sends a to b, b to c, c to d. Right is b, with one call. Linux x64, canary 7fe13e1b9.

canary #44473 this PR
wrong, of 138 53 36 12

This PR changes 24. Eight are import(), to ESM and to CommonJS: c with 2 calls before, b with 1 now. The other 16 are all a literal specifier with the plugin from a preload:

before this PR
import ... from, export ... from, to ESM and to CommonJS c, 2 calls b, 1 call
require() in CommonJS and in ESM, module.require(), to ESM and to CommonJS c, 2 calls b, 1 call
those three, into a namespace Cannot find package 'ns:from-x.virtual' loads
require.resolve(), to ESM and to CommonJS c, 2 calls b, 1 call
require.resolve(), into a namespace ns:from-from-x.virtual, 2 calls ns:from-x.virtual, 1 call

No other case changes. The 12 that are left are import.meta.resolve(), which does not ask onResolve at all. Not changed here. Nor is Bun.ModuleGraph's import(), which asks twice: it needs the key before the load starts, and the loader has no way to import by key.

import() of 24 kinds (relative, absolute, file: URL, query, hash, builtins, missing, JSON, text, CommonJS, from eval, new Function, a timer, node:vm, a data: URL): the value or the name, message, code, referrer and specifier of the error are what canary gives.

With answers that are not a whole absolute path (relative, without an extension, a directory, a package's name; 72 cases, in #44473) nothing differs between that PR and this one, and an import statement, require("...") and require.resolve("...") give what canary gives. The resolver's pass over the answer, which the second call used to be, is in #44473.

What else changes

before this PR
a require("...") in a branch or a function that does not run onResolve is asked when the file loads it is not asked
onResolve throws, and the require() is in a try the file does not load, nothing to catch caught
onResolve returns nothing, or a path or namespace that is not valid asked twice asked once, the same message
import "ns:thing" where the plugin has an onLoad for ns and no onResolve loads Cannot find package 'ns:thing'

The last row is the one thing that stops working. It worked through the shortcut only: import("ns:thing") and require("ns:thing") give that error on canary too. The documentation pairs an onResolve with the onLoad of a namespace.

How did you verify your code works?

18 tests in plugins.test.ts: one call for each of eight ways to load and for three things Bun loads itself (a later preload, Module.runMain(), a test file), five ways into a namespace, a require() that does not run, and a require() in a try.

the 18 tests
1.4.2, canary 16 fail
this PR, debug 18 pass

The two that pass without the fix are import and import() into a namespace. They are there for the comparison with require(), and because no test had a static import that onResolve moves into a namespace: taking out the shortcut alone broke that and every test passed.

preload-test.test.js, test/js/bun/plugin, test/js/node/module, test/js/bun/resolve, isolation.test.ts, worker.test.ts, hot.test.ts, bundler_compile.test.ts, mock-module.test.ts, transpiler-cache.test.ts, the dev server's plugins.test.ts and esm.test.ts: 877 pass, 0 fail. test/internal, before the merges of the base: 677 pass, 0 fail.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I also checked the Bun__onDidAppendPlugin ABI change for stale callers: the C thunk is generated from the Rust on_did_append_plugin signature (src/codegen/generate-host-exports.ts), and both callback(ctx) sites in src/jsc/bindings/BunPlugin.cpp match, so no call site is left passing the old second argument. The three plugin_runner.is_none()/is_some() to has_plugins swaps (hardcoded-alias fast path, could_be_plugin gate, concurrent-transpile gate) keep the original polarity, and the per-file reset in VirtualMachine.rs still clears the flag.

Extended reasoning...

The PR removes the transpile-time onResolve call path (Linker::link, PluginRunner.rs, PRINT_NAMESPACE_IN_PATH) and the resolve-hook namespace shortcut in ZigGlobalObject.cpp, collapsing VirtualMachine.plugin_runner to a bool; it touches no auth, crypto or injection surface but is a behavior change in module resolution for runtime plugins. Several confirmed inline findings (behavior regressions for onLoad-only namespaces, relative-path onResolve answers, stale transpiler cache entries, bare-name specifiers) already signal a human needs to review, so this body only records what else was checked and ruled out.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/bundler/transpiler.rs — Windows users with a catch-all onResolve now have static imports and literal require() of files on drive A:, Z:, a: or z: bypass their plugin, because the runtime path treats the drive letter as a namespace. extract_namespace at src/bundler/transpiler.rs:57-58 uses strict > and <, so A:\x.js yields namespace "A" and run_on_resolve_plugins is asked with namespace "A" instead of the file namespace. Fix: use inclusive letter ranges (or is_ascii_alphabetic) so every drive letter is excluded, matching the isASCIIAlpha test the deleted C++ shortcut used.

    Why this was flagged

    Trigger: on Windows, a Bun.plugin() onResolve with a file-namespace filter, and a specifier whose first byte is A, Z, a or z followed by : and a separator, for example import x from "A:\\proj\\x.js". All such specifiers now reach src/jsc/VirtualMachine.rs:5284-5291 because the link-time path was removed; could_be_plugin returns true (extension js), then extract_namespace at src/bundler/transpiler.rs:53-62 only returns b"" when specifier[0] > b'a' && specifier[0] < b'z' or the uppercase twin, which excludes the endpoints. The call at VirtualMachine.rs:5291 is then made with namespace "A" and specifier \x.js, so a plugin registered without a namespace never sees it and the import silently falls through to the filesystem resolver. Pre-existing for import() and require() with non-literal arguments, but every static import and literal require() is now routed through it. Remedy: specifier[0].is_ascii_alphabetic().

    Verification: Pre-existing. src/bundler/transpiler.rs:57-58 uses strict comparisons, so for A:\x.js extract_namespace returns b"A". At src/jsc/VirtualMachine.rs:5284-5297 namespace "A" is passed to plugin_runner_on_resolve_jsc, so a file-namespace onResolve is never invoked. The base's runtime block at base VirtualMachine.rs:5280-5288 made the identical misclassification; merging this PR neither introduces nor widens it.

Comment thread src/bundler/linker.rs Outdated
Comment thread src/js_printer/lib.rs
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread src/bundler/linker.rs
Comment thread src/jsc/VirtualMachine.rs
@robobun

robobun commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 3:16 PM PT - Oct 2nd, 2026

⏳ @dylan-conway, your commit 1e94b23 is still building in Build #122980, but has 2 failures so far (All Failures):

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no new issues

No new issues were found in this update; 5 findings from earlier reviews are still open above.

Still open from earlier reviews (5):

  • 🔴 src/bundler/linker.rs:431 — Users whose runtime plugin's onResolve returns a relative or extensionless file path (the documented example at docs/ru…
  • 🔴 src/js_printer/lib.rs:6499 — Users upgrading with an existing on-disk transpiler cache get Cannot find package 'virtual:from x.virtual' from static…
  • 🔴 src/jsc/bindings/ZigGlobalObject.cpp:3564 — Users whose plugin registers only an onLoad for a namespace and whose code has a static import "ns:thing" now get Canno…
  • Also unresolved: 2 minor or pre-existing.

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (1):

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment thread src/jsc/VirtualMachine.rs
@dylan-conway
dylan-conway merged commit 4d04cdb into claude/module-key-resolved-once Oct 2, 2026
9 of 10 checks passed
@dylan-conway
dylan-conway deleted the claude/onresolve-once branch October 2, 2026 22:18
@dylan-conway

Copy link
Copy Markdown
Member Author

This shows as merged because its commits are now in the branch of #44473, which was its base. Nothing has landed on main. #44473 holds both changes: each needs part of the other (taking the shortcut out of the resolve hook needs the rule for useImportMap; handing import()'s specifier to the loader needs the shortcut gone). The one thread left open here, a static import "ns:thing" with an onLoad and no onResolve, is in that description under "What else changes".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants