Skip to content

plugin: match onLoad filters for extensions starting with a digit - #36592

Open
robobun wants to merge 3 commits into
mainfrom
farm/2f086a40/plugin-onload-numeric-ext
Open

robobun wants to merge 3 commits into
mainfrom
farm/2f086a40/plugin-onload-numeric-ext

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4609.

Repro

// preload.ts
Bun.plugin({
  name: "t",
  setup(b) {
    b.onLoad({ filter: /\.1$/ }, () => ({ exports: { v: "1" }, loader: "object" }));
    b.onLoad({ filter: /\.custom$/ }, () => ({ exports: { v: "c" }, loader: "object" }));
  },
});
// index.ts
import * as a from "./ok.yaml.1";
import * as c from "./ok.custom";
console.log({ a, c });

bun --preload ./preload.ts index.ts fires the .custom filter but never fires the .1 filter; ok.yaml.1 falls through to the built-in file loader.

Cause

PluginRunner::could_be_plugin is the cheap pre-filter that decides whether to run plugin regex filters at all. It looked at the byte immediately after the final . and only returned true for ASCII letters (or non-ASCII bytes). For an absolute resolved path like /app/foo.1 or /app/video.3gp that byte is a digit, so the function returned false and run_on_load_plugins was skipped entirely.

The comment on the check says its only purpose is to rule out ./, ../, .. (and their Windows \\ forms). Those cases produce either an empty extension or one that starts with a path separator.

Fix

Accept any non-empty extension that does not start with / or \\. This keeps the intended fast-path rejection of relative-navigation specifiers while letting numeric (.1, .h2, .3gp) and other legal extensions through to the regex filters.

Verification

New test in test/js/bun/plugin/plugins.test.ts covers .1, .txt.2, .3gp (via both import and require) alongside a letter-extension control. Fails on main with the digit-extension values coming back undefined, passes with this change. Full plugins.test.ts (36 tests) and bundler_plugin.test.ts (52 tests) pass.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts
bun test v1.4.0 (c02e8bda9)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [1742.07ms]
(pass) require > beep:boop returns 42 [12.16ms]
(pass) require > object module works [9.89ms]
(pass) module > throws with require() [14.96ms]
(pass) module > async module works with async import [27.19ms]
(pass) module > sync module module works with require() [4.92ms]
(pass) module > sync module module works with require.resolve() [3.89ms]
(pass) module > sync module module works with import [6.38ms]
(pass) module > modules are overridable [99.40ms]
(pass) dynamic import > SSRs `<h1>Hello world!</h1>` with Svelte [10.92ms]
(pass) dynamic import > beep:boop returns 42 [5.92ms]
(pass) dynamic import > async:onLoad retur
... (truncated)

release without fix: 4 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [51.35ms]
(pass) require > beep:boop returns 42 [0.35ms]
(pass) require > object module works [0.22ms]
(pass) module > throws with require() [0.26ms]
(pass) module > async module works with async import [6.43ms]
(pass) module > sync module module works with require() [0.26ms]
(pass) module > sync module module works with require.resolve() [0.14ms]
(pass) module > sync module module works with import [0.20ms]
(pass) module > modules are overridable [0.63ms]
(pass) dynamic import > SSRs `<h1>Hello world!</h1>` with Svelte [0.26ms]
(pass) dynamic import > beep:boop returns 42 [0.14ms]
(pass) dynamic import > async:onLoad returns 42 [3.79ms]
(pass) dynamic import > async object loader returns 42 [1.75ms]
(pass) import statement > SSRs `<h1>Hello world!</h1>` with Svelte [5.85ms]
(pass) erro
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts
bun test v1.4.0 (c02e8bda9)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [1585.16ms]
(pass) require > beep:boop returns 42 [17.32ms]
(pass) require > object module works [14.22ms]
(pass) module > throws with require() [21.24ms]
(pass) module > async module works with async import [39.47ms]
(pass) module > sync module module works with require() [8.03ms]
(pass) module > sync module module works with require.resolve() [6.39ms]
(pass) module > sync module module works with import [11.48ms]
(pass) module > modules are overridable [33.49ms]
(pass) dynamic import > SSRs `<h1>Hello world!</h1>` with Svelte [113.91ms]
(pass) dynamic import > beep:boop returns 42 [13.25ms]
(pass) dynamic import > async:onLoad r
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1630ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compiling�[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
�[1m�[92m   Compiling�[0m bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
�[1m�[92m   Compiling�[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/se
... (truncated)
diff hotspot
src/bundler/transpiler.rs          | 10 ++-------
 test/js/bun/plugin/plugins.test.ts | 46 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 48 insertions(+), 8 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                reads  edits  tests
src/bundler/transpiler.rs               2      3      0
test/js/bun/plugin/plugins.test.ts      3      1      0

could_be_plugin is a cheap pre-filter that decides whether a specifier
is worth running plugin regex filters against. It looked at the byte
after the final '.' and only accepted ASCII letters (or non-ASCII),
so absolute paths like /app/foo.1 or /app/video.3gp returned false and
the onLoad/onResolve regex was never consulted. The module then fell
through to the built-in file loader.

The only goal of the check was to reject './', '../', '..' (and their
Windows backslash forms). Those produce an empty extension or one that
starts with a path separator, so accept anything else.

Fixes #4609
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dcee50a3-82c9-4049-b0ce-77897820b69e

📥 Commits

Reviewing files that changed from the base of the PR and between df49a6e and c02e8bd.

📒 Files selected for processing (2)
  • src/bundler/transpiler.rs
  • test/js/bun/plugin/plugins.test.ts

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

@github-actions github-actions Bot added the claude label Aug 1, 2026

@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 inline note on extract_namespace is a pre-existing off-by-one adjacent to this change, not something this PR introduced — worth a follow-up but not a blocker here.

Extended reasoning...

Overview

This PR loosens the PluginRunner::could_be_plugin pre-filter in src/bundler/transpiler.rs so that file extensions beginning with a digit (.1, .3gp, .h2) are allowed through to the plugin regex filters. Previously the check only accepted extensions whose first byte was an ASCII letter or >127, so absolute resolved paths like /app/foo.1 returned false and run_on_load_plugins was skipped entirely. The fix accepts any non-empty extension whose first byte is not a path separator, preserving the original intent of cheaply rejecting ./, ../, .., .\\ while letting everything else reach the real regex match.

Security risks

None. This is a pure byte-classification change in a pre-filter; the widening only causes more specifiers to be tested against user-registered onLoad regex filters, which is the intended behavior. No new inputs reach parsing, allocation, or filesystem code that weren't already reachable via letter-extension paths.

Level of scrutiny

Low-to-moderate. The function is a cheap fast-path guard whose false positives are harmless (they just fall through to the regex filter). I traced all four call sites (ModuleLoader.rs:603, VirtualMachine.rs:4246, jsc_hooks.rs:5139, linker.rs:482) and confirmed they all use this only as a gate before regex matching / extract_namespace. I walked the relative-navigation cases the comment says it must reject — ./, ../, .., .\\, ..\\foo — and each still produces an empty extension or one starting with //\\, so they still fall through to the !is_absolute && has_colon check unchanged.

Other factors

The new test in plugins.test.ts follows harness conventions (tempDir, bunEnv/bunExe, concurrent pipe drain, stdout asserted before exitCode, issue URL comment, it.concurrent), covers three digit-extension variants plus a letter-extension control, and exercises both import and computed require. The PR description reports it fails on main and passes with the fix, and that the full plugins/bundler_plugin suites pass. The one inline finding is an off-by-one in the adjacent extract_namespace drive-letter guard — pre-existing (git-blame attributes it to an earlier commit) and correctly flagged as such; it slightly widens in reach with this change but was already reachable for letter-extension paths on A:/Z: drives.

Comment thread src/bundler/transpiler.rs
The strict inequalities excluded a/z/A/Z so a resolved path like
Z:\share\file.js had 'Z' mis-parsed as a plugin namespace on Windows.
Use the canonical bun_paths::resolve_path::is_drive_letter helper
(inclusive bounds) instead of the hand-rolled range.
Comment thread src/bundler/transpiler.rs Outdated
@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:33 PM PT - Jul 31st, 2026

✅ @robobun, your commit c02e8bda96973c400f9cf55ead2bc2f4125c953d passed in Build #86540! 🎉


🧪   To try this PR locally:

bunx bun-pr 36592

That installs a local version of the PR into your bun-36592 executable, so you can run:

bun-36592 --bun

@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 widened pre-filter is strictly safer (false positives just run the regex filters; the ./ ../ ..\ cases are still rejected via the separator/empty check), and the extract_namespace follow-up now uses the shared inclusive is_drive_letter helper as suggested.

What was reviewed:

  • Traced ./, ../, .., .\, and trailing-. through the new could_be_plugin — all still fall through to the absolute/colon fallback as before.
  • Checked all four call sites (linker.rs, VirtualMachine.rs, ModuleLoader.rs, jsc_hooks.rs) — none rely on the narrower old semantics; true only means "run the regex filters".
  • Confirmed bun_paths::resolve_path::is_drive_letter(u8) is the inclusive a..=z | A..=Z check.
  • Test follows harness conventions (tempDir, concurrent pipe drain, stdout-before-exitCode) and covers import + require with a letter-extension control.
Extended reasoning...

Overview

Two-line semantic change in src/bundler/transpiler.rs plus a subprocess test in test/js/bun/plugin/plugins.test.ts. PluginRunner::could_be_plugin previously required the byte after the final . to be an ASCII letter or non-ASCII, which silently excluded digit-leading extensions (.1, .3gp) from ever reaching runtime plugin onLoad filters. The new check accepts any non-empty extension whose first byte is not a path separator, which is exactly what the original comment said the check was for. The second hunk swaps a hand-rolled strict-inequality drive-letter range in extract_namespace for the shared bun_paths::resolve_path::is_drive_letter helper — the follow-up to my earlier inline comment on this PR, now resolved.

Security risks

None. This is a pure byte-level pre-filter over module specifiers. Widening it only causes more specifiers to be tested against user-registered plugin filter regexes; the actual match decision is still made by those regexes. No new untrusted-input parsing, allocation, or filesystem access.

Level of scrutiny

Low-to-moderate. could_be_plugin is a hot-path fast-reject, so the concern is behavioral regression rather than safety. I hand-traced the specifiers the old comment named — ./, ../, .., and Windows \\ variants — and each still yields either an empty extension or one starting with //\\, so they still fall through to the !is_absolute && has_colon branch and return false exactly as before. Everything newly returning true (digits, other ASCII punctuation) is harmless: the caller just runs the plugin regex filters, which either match (correct) or don't (same outcome as before, one regex test slower). The four call sites were checked and none depend on the narrower old behavior.

Other factors

Both prior review threads (the drive-letter off-by-one and the comment-cop note) are resolved in the current diff. The test is well-formed per REVIEW.md: issue URL comment, tempDir/bunEnv/bunExe, concurrent stdout/stderr/exited drain, exact-value toEqual on parsed JSON before the exit-code assertion, it.concurrent, and it exercises both the ESM (await import) and CJS (require with a computed specifier to defeat static rewriting) paths alongside a letter-extension control that would have passed on main. The PR description states it fails on main and passes with the fix, and that the full plugins.test.ts and bundler_plugin.test.ts suites pass.

@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 extract_namespace off-by-one is now fixed via is_drive_letter, and the redundant inline comment was dropped.

What was reviewed:

  • could_be_plugin widening: traced .., ./, ../, .\, lone ., and trailing-dot inputs through the new check — all still fall through to the !is_absolute && has_colon path as before.
  • Confirmed is_drive_letter_t uses inclusive bounds, so A:/Z: now correctly return an empty namespace on Windows.
  • Checked all could_be_plugin callers (ModuleLoader, VirtualMachine, jsc_hooks, linker) — widening only means more specifiers reach the regex filter loop, which is the intended behavior; no path skips normal resolution on filter miss.
Extended reasoning...

Overview

Two-line change to PluginRunner::could_be_plugin in src/bundler/transpiler.rs that relaxes the extension pre-filter from "starts with an ASCII letter or non-ASCII byte" to "non-empty and does not start with a path separator". This lets file extensions beginning with a digit (.1, .3gp) reach onLoad plugin filters. A follow-up commit also replaced the adjacent hand-rolled drive-letter range in extract_namespace with the shared bun_paths::resolve_path::is_drive_letter helper (fixing an off-by-one that excluded a/z/A/Z). One new subprocess test in test/js/bun/plugin/plugins.test.ts.

Security risks

None. This is a pure byte-level pre-filter that only decides whether to run user-registered plugin regex filters; it does not touch auth, crypto, network, or filesystem writes. Widening the pre-filter cannot bypass anything — the actual plugin filter regex still has to match.

Level of scrutiny

Low-to-moderate. The change is a strict widening of a fast-path guard whose only documented purpose is to reject ./, ../, and absolute paths cheaply. I walked every relative-navigation form (., .., ./, ../, .\, ..\) through the new predicate and they all still return false. Newly-accepted inputs (digit/symbol-leading extensions) simply proceed to the regex filter loop; on no match, callers fall through to normal resolution exactly as before, so there is no behavior change for specifiers without a matching plugin.

Other factors

The test follows harness conventions closely (tempDir, bunEnv/bunExe, concurrent pipe drain, stdout asserted before exit code, issue-URL comment, it.concurrent, letter-extension control case, both import and require paths). Both prior review comments (mine on the drive-letter bounds, comment-cop on the inline comment) were addressed in follow-up commits and the threads are resolved. The is_drive_letter helper was verified to use inclusive <= bounds.

@robobun

robobun commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #40465. It routes every resolved absolute path to the file namespace onLoad filters in Bun__runVirtualModule, with no extension check. On that branch the #4609 repro (onLoad({ filter: /\.1$/ }) for ok.yaml.1) already returns the plugin value. Once it lands, the onLoad test in this PR passes on main without the could_be_plugin change. The could_be_plugin change still matters for the two onResolve sites (src/jsc/VirtualMachine.rs, src/bundler/linker.rs), so the test here would need an onResolve assertion to keep a fail-before.

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.

Bun plugin can't load files with number in the file extension

2 participants