Skip to content

Run runtime plugin onLoad for a resolved file without an extension - #40465

Open
robobun wants to merge 9 commits into
mainfrom
farm/e19e682a/plugin-onload-no-extension
Open

robobun wants to merge 9 commits into
mainfrom
farm/e19e682a/plugin-onload-no-extension

Conversation

@robobun

@robobun robobun commented Aug 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes the onLoad half of #4609 and #37699.

Problem

  • A runtime Bun.plugin onLoad callback never runs for a resolved file without an extension. await import("./LICENSE") with a matching onLoad({ filter: /LICENSE$/ }) fails with error: Expected ";" but found "License". Bun parses the file as JavaScript. The same happens for require() and for a static import.
  • The cause is Bun__runVirtualModule (src/jsc/ModuleLoader.rs:245). It gates the resolved module key with could_be_plugin (src/bundler/transpiler.rs:88), a pre-filter written for raw import specifiers. It accepts only a .ext or a namespace: colon, so /dir/LICENSE never reaches the filters. An extension that starts with a digit (Bun plugin can't load files with number in the file extension #4609) or a query string with a dot (Runtime Bun.plugin onResolve/onLoad silently bypassed when import query string contains a dot followed by a non-letter (e.g. ?mtime=123.456) #37699) fails the same gate.
  • The function then calls extract_namespace on the key. For a POSIX path with a colon in a directory name (/dir/a:b/note.txt) it returns /dir/a as the namespace, so the file namespace filters never see that file either.

Fix

  • Route a resolved module key by shape, in two steps. If the prefix before the first colon is a namespace with registered onLoad callbacks, the key is a virtual module in that namespace. Otherwise, if the key is an absolute path, it is a file and goes to the file namespace filters as is. Any other key (node:fs, ws, data:) has no file to load and gets no callback.
  • This is correct because a resolved absolute path is a file on disk, with two exceptions that the code handles: a registered namespace that starts with / (/virtual:foo.txt), which only the registered set can tell apart from a file, and the in-memory <cwd>/[eval] and <cwd>/[stdin] entries of bun -e and bun -, which are skipped. moduleLoaderResolve (src/jsc/bindings/ZigGlobalObject.cpp:3411) already applies the registered namespace rule to resolved keys.
  • Under bun test the hook runs before the builtin lookup (so mock.module("fs", ...) can replace a builtin). A bare builtin name such as ws has no registered namespace and is not absolute, so it gets no callback. This no longer depends on could_be_plugin, which Run runtime plugin onResolve for bare and relative specifiers #40398 widens to accept bare names.
  • Verified: five new tests in test/js/bun/plugin/plugins.test.ts (four fail on stock bun, one guards the registered namespace rule). Also all of test/js/bun/plugin/, test/js/bun/test/mock/, test/bundler/bundler_plugin.test.ts, and test/cli/run/run-eval.test.ts.

Background

Notes
  • Repro: printf 'MIT License text' > LICENSE, then a script that registers onLoad({ filter: /LICENSE$/ }) and runs await import("./LICENSE"). Before: onLoad never logs and the import rejects with Expected ";" but found "License". After: onLoad receives the absolute path and the import evaluates to the file text.
  • Colon probe on stock bun: with onLoad({ filter: /\.txt$/ }) registered, import("./with:colon/note.txt") loads through the built-in text loader and the callback never runs. The first colon in the path was read as a namespace.
  • Sibling probes on this branch: the Bun plugin can't load files with number in the file extension #4609 repro (onLoad({ filter: /\.1$/ }) for ok.yaml.1) returns the plugin value, stock returns undefined. The Runtime Bun.plugin onResolve/onLoad silently bypassed when import query string contains a dot followed by a non-letter (e.g. ?mtime=123.456) #37699 onLoad case (import("/abs/plain.ts?v=123.456") with onLoad({ filter: /plain\.ts/ })) returns the plugin source, stock returns the disk source. Their onResolve halves still need the resolve-side changes in plugin: match onLoad filters for extensions starting with a digit #36592 and plugin: run onResolve/onLoad for imports whose query string contains a dot #37702. After this lands, the onLoad tests in those two PRs pass on main, so they need a rescope to onResolve.
  • [eval] and [stdin]: stock bun ran the file namespace filters for <cwd>/[eval] only when the cwd contained a dot (the text after the last dot looked like an extension). A plugin cannot decline a matched onLoad, and there is no file at that path. This PR never runs them for these entries. Test 4 pins that, in a directory whose name contains a dot.
  • Bun.build already runs onLoad for the LICENSE and colon files. The runtime was the outlier.
  • Test 1 covers dynamic import(), require(), and a static import in a later-loaded module, and asserts that args.path is the absolute path. The callback is synchronous because require() rejects a promise result (existing, tested behavior).
  • Test 2 (POSIX only, a colon is illegal in a Windows file name) covers a directory name with a colon, for a file with and without an extension.
  • Test 3 registers onLoad in the namespaces /virtual and a. The keys /virtual:foo.txt and a:boop read as an absolute path and as a drive path. The registered namespace wins. This passes on stock bun too and guards the rule.
  • Test 5 runs bun test --preload with the same plugin and a second onLoad({ filter: /^ws$/ }) in the file namespace. It asserts that the extension-less file goes through onLoad and that ws is still the builtin.
  • Windows: a key shaped like a drive path (a letter, a colon and a separator) is never read as a namespace, the same guard extract_namespace uses, but with every letter accepted. Z:\dir\file is an absolute path and reaches the file namespace. Before, extract_namespace returned Z for it (a pre-existing strict range check at src/bundler/transpiler.rs:78). The guard is Windows only and needs the separator, so a single letter namespace (a:boop) keeps working through require() on every platform. @:/id.txt with a registered @ namespace also reaches its group.
  • A relative key from an onResolve result without a namespace ({ path: "real.js" }) no longer reaches onLoad. That flow is already broken on main: the dynamic import yields an empty namespace, and a debug build of main asserts in getModuleNamespace on a later require() of the same key, before and after this change.
  • Earlier revisions: the first only widened the gate (is_absolute || could_be_plugin) and still called extract_namespace. The second sent every absolute path to the file namespace, which would have broken a registered namespace that starts with /. Review pointed at both, hence the registered namespace rule.

no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/plugin/plugins.test.ts

Bun__runVirtualModule skipped the onLoad filters whenever the resolved
module key failed the could_be_plugin pre-filter, which accepts only a
specifier with a .ext or a namespace colon. An absolute path such as
/dir/LICENSE never reached the file-namespace filters and was parsed as
JavaScript instead.

Every absolute path is a real file, so run the filters for it. Keep the
pre-filter for the other key forms so that bare builtin names stay out
of the file namespace under bun test, where plugins run before the
builtin lookup.
@coderabbitai

coderabbitai Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 03d4a04e-7e19-464a-8bb5-6ccd386d3dff

📥 Commits

Reviewing files that changed from the base of the PR and between adc354d and b49d9bd.

📒 Files selected for processing (5)
  • src/jsc/JSGlobalObject.rs
  • src/jsc/ModuleLoader.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunPlugin.cpp
  • test/js/bun/plugin/plugins.test.ts

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

The runtime now detects registered onLoad namespaces, distinguishes them from absolute paths and Windows drive paths, and excludes [eval] and [stdin] entries. Plugin tests cover extensionless files, colon-containing paths, builtins, imports, and require.

Changes

Runtime onLoad routing

Layer / File(s) Summary
Plugin namespace lookup bridge
src/jsc/JSGlobalObject.rs, src/jsc/bindings/BunPlugin.cpp
Rust exposes namespace lookup through the global object and calls the C++ plugin registry helper.
Virtual module and entry routing
src/jsc/ModuleLoader.rs, src/jsc/VirtualMachine.rs
Virtual module routing recognizes registered namespaces and absolute paths, excludes Windows drive paths, and identifies eval and stdin entries.
onLoad routing regression coverage
test/js/bun/plugin/plugins.test.ts
Tests cover extensionless files, colon-containing paths, path-like namespaces, eval and stdin entries, builtin modules, imports, require, subprocess output, and exit status.

Suggested reviewers: dylan-conway, jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly explains the problem, implementation, scope, edge cases, and verification results. It does not use the exact template headings, but it contains the required information and is …
Title check ✅ Passed The title clearly identifies the main change: running runtime plugin onLoad callbacks for resolved files without extensions.
Full details: Description check

Explanation

The description clearly explains the problem, implementation, scope, edge cases, and verification results. It does not use the exact template headings, but it contains the required information and is substantially complete.


Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced with bun entry.js where entry.js registers onLoad({ filter: /LICENSE$/ }) and runs await import("./LICENSE"). Stock bun 1.4.1 prints Expected ";" but found "License" and never calls onLoad. A second probe with onLoad({ filter: /\.txt$/ }) and import("./with:colon/note.txt") never calls onLoad either: the first colon in the path was read as a namespace. With this branch both files go through onLoad and the imports evaluate to the file text.

Tests: the five new cases in test/js/bun/plugin/plugins.test.ts pass with the debug build. Four fail on stock bun, the fifth guards the registered namespace rule. test/js/bun/plugin/, test/js/bun/test/mock/, test/bundler/bundler_plugin.test.ts, and test/cli/run/run-eval.test.ts pass.

CI: the new tests pass on every lane. The one red test, test/js/bun/test/parallel/test-http-should-accept-custom-certs-when-provided.ts (CERT_HAS_EXPIRED), fails on every lane and is unrelated to this diff. Its fixture certificate has expired on main. It is reported separately.

@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.

LGTM. The one-line gate change is narrowly scoped (only extension-less absolute paths newly reach run_on_load_plugins; every other specifier shape is unchanged), and the two subprocess tests cover the fix and the bun test builtin-screening concern. The inline note about extract_namespace's A:/Z: off-by-one is pre-existing and optional to fold in.

Extended reasoning...

Overview

This PR changes one guard in Bun__runVirtualModule (src/jsc/ModuleLoader.rs): the early-return that used to key solely on PluginRunner::could_be_plugin(specifier) now also lets any absolute path through. The rest is a 5-line comment and two new subprocess tests in test/js/bun/plugin/plugins.test.ts.

Behavioral delta

I traced the four specifier shapes through the old and new guard: absolute-with-extension (already proceeded via could_be_plugin's dot check → unchanged), absolute-without-extension (previously returned early, now proceeds → the fix), bare builtin like ws (not absolute, no dot/colon → still returns early), namespaced like node:fs (not absolute, has colon → still proceeds). Only the intended case changes. The function is already gated on plugin_runner.is_some(), so there is no cost when no plugin is registered, and files with extensions already ran the regex filters — so no new hot-path work for the common case. Confirmed bun_paths::is_absolute is the cfg-dispatched native check at src/paths/lib.rs:200.

Security risks

None. This routes more resolved-on-disk file paths to user-registered onLoad regex filters, which is the documented contract; it does not expose new data or bypass any check.

Level of scrutiny

Medium — module loading is load-bearing, but the diff is a single boolean disjunct with a clear truth-table, the PR description enumerates the other could_be_plugin call sites and leaves them alone, and both tests follow harness conventions (tempDir, bunEnv, concurrent pipe drain, assert output before exit code). The second test explicitly guards the bun test builtin-lookup ordering concern the comment raises.

Other factors

The one inline finding is a pre-existing off-by-one in extract_namespace (strict >/< excludes drives A/Z/a/z on Windows). It is not introduced by this PR — extension-bearing paths on those drives already hit it, and extension-less ones simply move from one no-op path to another — so it is a fold-in suggestion rather than a blocker.

Comment thread src/jsc/ModuleLoader.rs Outdated
An absolute path is a file on disk, so it no longer goes through
extract_namespace. That function read the first colon in a POSIX path
such as /dir/a:b/x.js, or an A: or Z: drive letter on Windows, as a
plugin namespace, and the file namespace filters never saw the path.
Non-absolute keys keep the could_be_plugin pre-filter.

Add a test for a directory name that contains a colon.
Comment thread src/jsc/ModuleLoader.rs Outdated
Comment thread src/jsc/ModuleLoader.rs Outdated
Comment thread src/jsc/ModuleLoader.rs Outdated
A module key such as /virtual:foo.txt is both a namespaced virtual
module and, by shape, an absolute path. Check the registered onLoad
namespaces first, with the same rule moduleLoaderResolve uses (a
single letter before the colon is a Windows drive). Only then treat an
absolute path as a file. Bun__onLoadPluginsHasNamespace exposes the
namespace lookup to Rust.
Comment thread src/jsc/ModuleLoader.rs Outdated
Comment thread src/jsc/ModuleLoader.rs Outdated
@robobun

robobun commented Aug 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

@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.

I reviewed this again after 4c5a79a/457090b and found no further issues — both of my earlier concerns (the A:/Z: drive-letter gap and the is_absolute false positives on /ns:path and @:/path keys) are resolved by the registered-namespace-first rule, and I confirmed it mirrors moduleLoaderResolve at ZigGlobalObject.cpp:3411-3422 (same colon == 1 && isASCIIAlpha drive skip, same onLoadPlugins.namespaces lookup). Given this reworks the routing in Bun__runVirtualModule — every resolved module key passes through here when a plugin is registered — a maintainer sign-off on the disambiguation rule would still be worthwhile.

What was reviewed:

  • registered_on_load_namespace bounds: &specifier[namespace.len()+1..] cannot over-slice (colon index < len); colon==0 and single-letter alpha prefixes short-circuit before the FFI call.
  • Bun__onLoadPluginsHasNamespace FFI: &BunString ↔ const BunString*, bool return; group("") returns &fileNamespace but the Rust side never passes empty.
  • Fallback path preserved for non-absolute keys (node:fs, ws, myns:foo) — could_be_plugin/extract_namespace still gate builtins under bun test; test 4 pins that.
  • Single-letter alpha namespaces (a:foo, a:/foo) traced on both platforms — the only behavior delta is a:/, z:/ on Windows now treated as drives (matching moduleLoaderResolve) instead of reaching namespace a/z via the pre-existing off-by-one.
Extended reasoning...

Overview

Fixes runtime Bun.plugin onLoad never firing for resolved files without an extension (and for POSIX paths containing a colon). The change reworks namespace routing in Bun__runVirtualModule (src/jsc/ModuleLoader.rs): first check whether the prefix before : is a registered onLoad namespace (new Bun__onLoadPluginsHasNamespace FFI in BunPlugin.cpp + has_on_load_namespace wrapper in JSGlobalObject.rs), then treat any absolute path as the file namespace, else fall through to the existing could_be_plugin/extract_namespace pre-filter. Four new subprocess tests in plugins.test.ts.

Security risks

None identified. The change only widens which resolved module keys reach user-registered onLoad filters; those filters are user code the user already opted into. No untrusted-input parsing beyond finding a : and checking is_ascii_alphabetic on one byte. The new FFI is a read-only predicate (group() != nullptr) with no allocation on the C++ side beyond toWTFString(ZeroCopy).

Level of scrutiny

High — Bun__runVirtualModule is on the module-load path for every import/require when any runtime plugin is registered, and the disambiguation between ns:path virtual keys and colon-containing filesystem paths is inherently heuristic. That said, the chosen rule is not new: it duplicates the logic already in moduleLoaderResolve (ZigGlobalObject.cpp:3411-3422), which I verified line-for-line (same colon == 1 && isASCIIAlpha(key[0]) drive-letter skip, same linear scan of onLoadPlugins.namespaces). The PR went through three revisions in response to my earlier reviews; the final shape addresses both the A:/Z: drive gap and the /ns:path / @:/path false-positive shapes I flagged.

Other factors

  • I traced slice bounds in registered_on_load_namespace: colon is a valid index into key, so &key[..colon] and &specifier[namespace.len()+1..] are always in-bounds; colon==0 is rejected so the namespace passed to group() is never empty (which would otherwise hit &fileNamespace and always return true).
  • The fallback branch is byte-identical to the pre-PR code, so ws, node:fs, and unregistered ns:path keys behave exactly as before — test 4 pins the ws case under bun test where plugins run before builtins.
  • The FFI signature matches (&JSGlobalObject/&BunString ↔ non-null pointers, bool return); String::from_bytes borrows the slice for the call duration only.
  • The one behavior delta I could find vs. pre-PR: on Windows, a single-letter alphabetic namespace whose path starts with a separator (e.g. a:/foo with namespace a registered) previously reached the a group via the strict-range off-by-one in extract_namespace, and now goes to the file namespace. This aligns with moduleLoaderResolve's rule and the PR description calls it out; it is obscure enough not to block.
  • Tests follow harness conventions (tempDir, bunEnv, concurrent subprocess drain, exit-code asserted last, skipIf(isWindows) for the colon-in-path case). robobun reports the four new tests pass on all lanes and three fail on stock bun.

Deferring rather than approving because this is a routing change in the module loader itself, and the registered-namespace-wins rule — while consistent with moduleLoaderResolve — is a design call a maintainer should ratify.

…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.
Comment thread src/jsc/ModuleLoader.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/ModuleLoader.rs
…separator

The single-letter check ran on every platform and without the separator
requirement, so a key such as a:boop from a registered namespace "a"
never reached its onLoad group once the extract_namespace fallback was
gone. Mirror the guard extract_namespace uses: a drive path is a letter,
a colon and a separator, on Windows only.

@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.

I re-reviewed at b49d9bd and didn't find any new bugs. The three earlier findings (the A/Z drive-letter dependency, the is_absolute false-positive on /ns:.../@:/... keys, and the un-gated single-letter drive check) are each addressed — traced registered_on_load_namespace against the full case matrix (a:boop on both platforms, C:\\..., /virtual:..., /dir/a:b/..., node:fs, @:/id) and it routes correctly. Because this rewires the module-loader onLoad gate (and intentionally changes behavior for [eval]/[stdin] and non-absolute onResolve results), a human sign-off would still be worthwhile.

Extended reasoning...

Overview

Rewrites how Bun__runVirtualModule decides whether a resolved module key reaches runtime onLoad plugins. Drops the could_be_plugin + extract_namespace pre-filter (designed for raw specifiers) in favor of: (1) if the prefix before the first colon is a registered onLoad namespace, route there; (2) else if the key is an absolute path (and not the [eval]/[stdin] entry), route to the file namespace; (3) else skip. Adds Bun__onLoadPluginsHasNamespace (C++, a group() lookup — no JS entry, no throw), a Rust wrapper, and extracts is_eval_or_stdin_entry into a shared helper on VirtualMachine. Five subprocess tests cover extension-less files, colon-in-path, path-like namespaces, eval/stdin, and bun test builtin routing.

Security risks

None identified. No untrusted input reaches new allocation or side effects; the new FFI is a read-only vector scan.

Level of scrutiny

High. This is the module-loader hook that runs for every import when a runtime plugin is registered. The PR went through three review rounds here, each surfacing a real routing regression that was then fixed; the current logic is sound against every case I traced, but the space of resolved-key shapes is subtle (Windows drives, colon-in-dirname, namespace names starting with / or a single letter, eval/stdin sentinels).

Other factors

  • All three of my prior inline findings are resolved: is_windows_drive is now cfg!(windows)-gated and requires a trailing separator, so a:boop reaches namespace a on every platform while C:\\file on Windows still goes to the file namespace and a registered C namespace cannot capture real drive paths.
  • group() returns &fileNamespace for an empty string, but colon == 0 returns None first, so ":foo" cannot match the file namespace via the registered-namespace path.
  • is_drive_letter uses inclusive bounds (src/paths/resolve_path.rs:800), so the pre-existing A/Z off-by-one in extract_namespace no longer matters here.
  • Acknowledged behavior changes beyond the headline fix: [eval]/[stdin] never reach file-namespace onLoad (previously did when cwd contained a dot), and a relative-path onResolve result without a namespace no longer reaches onLoad (already broken on main per the description). Both are defensible, but a maintainer should confirm intent.
  • CI's one failure (test-http-should-accept-custom-certs-when-provided.ts, expired fixture cert) is unrelated.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant