Skip to content

bundler: resolve in-memory entry points when an onResolve plugin declines them - #38600

Closed
robobun wants to merge 1 commit into
mainfrom
farm/4b0292a0/files-entry-plugin-nomatch
Closed

robobun wants to merge 1 commit into
mainfrom
farm/4b0292a0/files-entry-plugin-nomatch

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.build({ entrypoints: ["/v/page.html"], files: { "/v/page.html": ..., "/v/app.js": ... } }) builds, but adding any onResolve plugin whose filter matches the entry point and whose callback returns undefined makes the same build fail with ModuleNotFound resolving "/v/page.html" (entry point). Same for .js entry points; the loader is irrelevant.
  • enqueue_entry_points_normal (src/bundler/bundle_v2.rs) hands an entry point to the plugins before it consults self.file_map. When the plugin declines, on_resolve's NoMatch arm for ImportKind::EntryPointBuild (previously bundle_v2.rs:4655) called resolve_entry_point directly, i.e. disk resolution, and never looked at the files map. The equivalent NoMatch fallback for imports, run_resolver, does check the map first, so only entry points were affected.
  • The files option (feat(bundler): add files option for in-memory bundling #25852) added its lookup to the no-plugin entry path, run_resolver, and resolve_import_records, but not to this arm, which the earlier virtual-entry-point plugin support (fix: support virtual entrypoints in onResolve()  #22144) had added.

Fix

  • New BundleV2::enqueue_entry_point_from_file_map: looks the entry point up in the files map and, on a hit, enqueues it through enqueue_entry_item with the requested target. It replaces the inline lookup in enqueue_entry_points_normal and is called from the NoMatch arm before the disk fallback.
  • Both entry paths now resolve identically: plugins, then the files map, then disk. That is already the order used for imports (plugin, then run_resolver's map lookup, then the resolver), and it is what the no-plugin path did for entry points. A plugin that declines is therefore a no-op, which is the contract onResolve has for every other specifier.
  • A map hit goes through the same enqueue_entry_item call as the no-plugin path (same target, same output naming), so the outputs are byte-identical with and without the declining plugin; a miss still falls through to the unchanged disk path and its existing error.
  • The dev server and bake production paths dispatch entry points to plugins too, but never set file_map, so the helper is a no-op there.
  • Verified with test/bundler/bundler_files.test.ts (new describe block): JS and HTML virtual entry points with a catch-all declining plugin, an asynchronously declining plugin, byte-for-byte equality with a plugin-free build, a build mixing a virtual and an on-disk entry point, and an entry point missing from both the map and disk still reporting ModuleNotFound. 6 of the 7 new tests fail on the released binary (USE_SYSTEM_BUN=1), all 30 tests in the file pass with bun bd test.
  • Also ran bundler_plugin.test.ts, bundler_plugin_chain.test.ts and regression/issue/bundler-plugin-onresolve-entrypoint.test.ts with the debug build: no failures.

Background

  • files option / FileMap: Bun.build({ files }) supplies in-memory sources keyed by path. The bundler consults this map (FileMap::resolve) before touching the disk wherever it resolves a specifier; a hit yields a resolver result in the file namespace whose contents are later read from the map instead of the filesystem.
  • onResolve dispatch for entry points: when a registered onResolve filter matches an entry point, the bundler creates a Resolve request of kind EntryPointBuild and sends it to the plugin on the JS thread. The answer comes back to BundleV2::on_resolve as Success (plugin supplied a path), Err, or NoMatch (every callback returned nothing). NoMatch is supposed to behave as if no plugin existed.
  • enqueue_entry_item: registers a resolved entry point in the graph (path to source index map, input file, parse task) and appends it to graph.entry_points; both the no-plugin path and the NoMatch arm end up here.
Repro
const files = {
  "/v/page.html": `<!doctype html><html><body><script src="./app.js"></script></body></html>`,
  "/v/app.js": `console.log(1)`,
};
const r = await Bun.build({
  entrypoints: ["/v/page.html"],
  files,
  throw: false,
  plugins: [{ name: "p", setup(b) { b.onResolve({ filter: /.*/ }, () => undefined); } }],
});
console.log(r.success, r.logs.map(String));
// bun 1.4.0: false [ 'BuildMessage: ModuleNotFound resolving "/v/page.html" (entry point)' ]
// without `plugins`: true []

…ines them

Bun.build({ files }) dispatches every entry point matched by an onResolve
filter to the plugin before looking at the files map. When the plugin
returned nothing, on_resolve()'s NoMatch fallback for entry points went
straight to disk resolution, so a virtual entry point failed with
ModuleNotFound even though the same build succeeds without the plugin.

Move the files-map lookup into a helper shared by the no-plugin path and
the NoMatch fallback, so both resolve an entry point the same way: files
map first, then disk.
@coderabbitai

coderabbitai Bot commented Aug 14, 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: a7358b36-d5b8-4b18-bb0c-4b8a04a7b14d

📥 Commits

Reviewing files that changed from the base of the PR and between 5638c62 and e6e6d55.

📒 Files selected for processing (2)
  • src/bundler/bundle_v2.rs
  • test/bundler/bundler_files.test.ts

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is ready; CI needs the two macOS shards re-run.

Reproduced on bun 1.4.0 with the snippet in the PR description (files: entry point plus an onResolve plugin that returns undefined fails with ModuleNotFound resolving "/v/page.html" (entry point); the same build succeeds without the plugin). Both .html and .js virtual entry points reproduce it.

Tests: the new block in test/bundler/bundler_files.test.ts; 6 of the 7 new tests fail on the released binary and the whole file passes with the debug build.

CI (build 96269): every lane that ran passed (177 jobs, including the lanes that run bundler_files.test.ts on Linux, Windows and musl). The build is marked failed only because the two darwin 14 aarch64 - test-bun shards expired without ever starting; that agent pool is currently backlogged for every PR build, so retrying those two jobs once it catches up is all that is needed. The flaky-test annotations on the build (child_process IPC, node watch-mode, inspect-error-leak, next-pages, cluster, solc) all passed on retry and are unrelated to this change.

@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 — targeted fix that makes the onResolve NoMatch fallback for entry points consult the files map before disk, matching the no-plugin path and the import path.

Reviewed: the extracted enqueue_entry_point_from_file_map helper is byte-equivalent to the inline block it replaces in enqueue_entry_points_normal, and its new call in on_resolve's NoMatch arm passes the same specifier/target that enqueue_entry_point_on_resolve_plugin_if_needed originally dispatched. Checked that Err(_) => return matches the surrounding let Ok(..) else { return } convention in that callback, and that a map hit correctly skips the entry_point_original_names store (only needed when the specifier and resolved path differ). Tests cover JS/HTML entries, sync/async decline, byte-identical output vs. no-plugin, mixed disk+memory, and the preserved ModuleNotFound error.

Extended reasoning...

Overview

This PR fixes a bug where Bun.build({ entrypoints, files, plugins }) fails with ModuleNotFound for an in-memory entry point when an onResolve plugin's filter matches the entry point but the callback returns undefined (declines). The NoMatch arm in BundleV2::on_resolve for ImportKind::EntryPointBuild went straight to disk resolution (resolve_entry_point) without consulting self.file_map, unlike the no-plugin path in enqueue_entry_points_normal and the import fallback in run_resolver. The fix extracts the file-map lookup into enqueue_entry_point_from_file_map and calls it from both sites, so a declining plugin is a true no-op for entry points as it already is for imports.

Two files touched: ~20 net lines in src/bundler/bundle_v2.rs (helper extraction + one new call site) and ~120 lines of new tests in test/bundler/bundler_files.test.ts.

Security risks

None. This is bundler resolution ordering for the in-process Bun.build API. No new inputs are trusted, no filesystem writes, no auth/crypto/network. The files map is caller-supplied and was already consulted on every other resolution path; this just adds the one path that was missed.

Level of scrutiny

Low-to-moderate. The change is small, mechanical, and well-explained. I traced the data flow: enqueue_entry_point_on_resolve_plugin_if_needed sets specifier = entry_point, namespace = "file", original_target = target; the NoMatch arm reads exactly those back and passes them to the new helper, which does the identical file_map.resolve(arena, b"", entry_point) + enqueue_entry_item(&mut resolved, true, target) that the inline block previously did in enqueue_entry_points_normal. The helper returns Ok(false) when file_map is None or has no hit, so the disk fallback and its error reporting are unchanged. The Err(_) => return on the new call matches the existing let Ok(..) else { return } pattern used for both resolve_entry_point and enqueue_entry_item immediately below it in the same arm.

Other factors

  • Test coverage is thorough for a fix of this size: both JS and HTML entry points, sync and async decline, byte-for-byte equality with a plugin-free build (proving the declining plugin is a no-op), a mixed on-disk + in-memory build, and a negative test confirming ModuleNotFound still fires when the entry is in neither the map nor on disk. PR description states 6/7 new tests fail on the released binary and all pass with the debug build.
  • I confirmed the file-map hit does not need the entry_point_original_names store that the disk-fallback branch does — that store exists for when the resolved disk path differs from the requested specifier, which cannot happen for a map hit (the map is keyed by the specifier). The no-plugin file-map path never stored it either.
  • The refactor deduplicates rather than duplicates: one helper called from two sites, replacing an inline block at one and adding the missing lookup at the other, per the repo's "fix the whole class" guidance.

Jarred-Sumner pushed a commit that referenced this pull request Sep 3, 2026
…t --splitting (#41263)

### Problem
- Without `--splitting`, `bun build --outdir out ./a.ts ./b.ts` silently
writes only `out/b.js` when the files import each other. CSS entry
points lose a file the same way. A `--compile` executable can run
nothing.
- Cause: `compute_chunks` keys an entry chunk by the full `entry_bits`
of its file (`src/bundler/linker_context/computeChunks.rs:120`). Entry
points that reach each other get equal keys, so the second overwrites
the first.
- Same root: when entry `a` imports entry `b`, `out/a.js` can run `b`'s
imports too early.

### Fix
- Key each entry chunk and CSS entry by the entry point's own bit, as
code splitting does (#26089). `LinkerGraph::load` keeps one entry point
per file.
- Without code splitting, `find_imported_parts_in_js_order` starts its
walk at the chunk's own entry point. HTML scripts now run in page order.
- In cjs output, only the entry point of a chunk writes
`module.exports`, as in esbuild. This also fixes a re-export leak in
single-entry builds. A missing ESM wrapper is no longer called
(`__INVALID__REF__();`).
- Verified: 11 new tests in `test/bundler/`. 9 fail on the base.
Self-review: 3 concerns, all addressed.

### Background
- `entry_bits` is the set of entry points that reach a file.
- Without code splitting, Bun links all entry points in one pass
(esbuild links each alone). An entry chunk holds every file its entry
point reaches.
- A chunk prints files in the order of an import walk. It starts at the
smallest `distance_from_entry_point`, which is 0 for every entry point.

<details><summary>Notes</summary>

**Repro**

```ts
// a.ts
import { b } from "./b.ts"; export const a = "a"; console.log("a sees", b);
// b.ts
import { a } from "./a.ts"; export const b = "b"; console.log("b sees", a);
```

`bun build --outdir out ./a.ts ./b.ts` prints only `b.js (entry point)`.
With `--splitting`, both files are written. Two CSS files that `@import`
each other lose one output, with and without `--splitting`.

For `--compile`: `main.ts` does `import("./plugin.ts")` and `plugin.ts`
does `import("./main.ts")`. `bun build --compile ./main.ts ./plugin.ts
--outfile app` makes an executable whose main module is the chunk of
`plugin.ts`. The code of `main.ts` sits in an `init_main` wrapper that
nothing calls, so `./app` prints nothing and exits 0.

**Module order**

`a.ts` imports `c.ts`, then `b.ts`. `b.ts` imports `d.ts`. Both `a.ts`
and `b.ts` are entry points. Unbundled, `bun a.ts` prints `c d b a`. On
1.4.1, `out/a.js` prints `d b c a`: `b.ts` also has distance 0 and a
lower stable index, so the walk starts there. esbuild prints `c d b a`.
An HTML page with `<script src="./first.js">` and `<script
src="./second.js">` runs `second` first when `second.js` is also an
entry point.

With only the key change, the cycle's `out/a.js` would get the module
order of `out/b.js`. So the key change and the walk change stay
together.

**`module.exports` in cjs output**

Two writes to `module.exports` come from the linker, not from user code:

- Step 5 puts `module.exports = __toCommonJS(exports)` at the end of
part 0 of each entry point file (`doStep5.rs`).
- `export * from` a module that needs a run-time re-export prints
`__reExport(exports, mod, module.exports)` (`convertStmtsForChunk.rs`).

Both are per file, and the file of one entry point can print in the
chunk of another. The walk emits part 0 of a file when it enters the
file, before its imports. On 1.4.1 the other entry point's file came
first, so its writes came before the chunk's own assignment and did no
harm. With the walk change, they come last. For `index.ts` that imports
`utils.ts`, both entry points, `require("./out/index.js")` returned the
exports of `utils.ts`. In a cycle, 1.4.1 already got this wrong: its one
output, `b.js`, exported the names of `a.ts`.

Now both writes happen only when the file is the entry point of the
chunk (`Chunk::is_entry_point_file`). esbuild has the same rule
(`file.IsEntryPoint()`, in a link of one entry point). The part 0 write
is the last statement of that part, and the printer drops it in other
chunks.

The re-export rule also changes single-entry builds. On 1.4.1, a file
that the entry point imports, but does not re-export, still wrote its
`export * from "ext"` names onto `module.exports`. esbuild does not. A
re-export chain still works, because each file in it re-exports the next
file at run time, and the entry point file writes to `module.exports`
(checked with a chain of three files).

`importstar/ReExportStarEntryPointAndInnerFileExternal` expected the
leak. Before 2f7ff95, the harness threw for every cjs test, and this
test expected the value that esbuild prints. That commit set the current
value when cjs output first ran. The same commit added
`ReExportStarEntryPointAndInnerFile`, without externals, which expects
no top-level `b`. This PR restores the esbuild value.

**Two entry points that name one file**

An onResolve plugin can map two entry point names to one file. The
plugin path adds the file to the entry points twice. On 1.4.1, the
full-bits key merged the two chunks without `--splitting`, so the build
wrote `second-name.js`. With `--splitting`, it failed with "Multiple
files share the same output path". `LinkerGraph::load` now skips a file
that is already an entry point, as its loop for `import()` targets does.
Both modes write `second-name.js`. The name comes from
`entry_point_original_names`, which keeps the last name.

When a plugin declines an entry point and the default resolver finds a
file that is already an entry point, `enqueue_entry_item` returns `None`
and records no name. This PR does not change that path. #38600 works on
it.

**Output order**

The old non-split key started with a byte that was 0 for an HTML entry
point and 1 for the others. It sorted HTML entry chunks before JS entry
chunks. The own-bit key has no such byte. For `bun build ./index.html
./b.ts ./c.html`, the outputs are now listed as `index.html`, `b.js`,
`c.html` (with their chunks), in the order of the entry points. The
files and their contents do not change. Entry point 0 still sorts first,
so `--compile` picks the same main module.

**Tests**

New, and failing on the base commit:

- `edgecase/EntryPointsImportEachOther`,
`edgecase/EntryPointImportsEntryPointModuleOrder`,
`edgecase/CSSEntryPointsImportEachOther`,
`edgecase/EntryPointsImportEachOtherCommonJS`
- `html/script-that-is-also-an-entry-point`
- `compile/EntryPointsImportEachOther`
- `plugin/two entry points that resolve to one file (splitting: true)`
- `edgecase/EntryPointRequiredByEntryPointCommonJS`,
`edgecase/RequiredEntryPointWithoutWrapperCommonJS`

New, and passing on the base commit:
`edgecase/EntryPointImportsEntryPointCommonJS` and the `splitting:
false` plugin case. Both fail if either cjs write or the dedupe is
removed.

Changed: `importstar/ReExportStarEntryPointAndInnerFileExternal` (above)
and `compile/HelloWorldWithProcessVersionsBun`. A debug build inlines
`process.versions.bun` as `1.4.1-debug`. That test removed `-debug` only
from the expected value, so it failed on every debug build. The API
variant of the test removes it from both values, and now this one does
too.

**Checked by hand**

Against esbuild 0.25.1 (run output and `module.exports` compared): a
ring of three entry points in `esm`, `cjs`, and `iife`, a CommonJS
cycle, `export * from` another entry point, re-export chains of two and
three files in cjs, and a named import from a file that re-exports a
`node:` module. Also rings of 10 and 130 entry points (130 uses the heap
bit set), `Bun.build` with `metafile` and `sourcemap: "external"`, an
HTML entry point whose script is in a cycle of two JS entry points, and
`--no-bundle --format=cjs` (byte-identical output).

**Suites run with the debug build**

`bundler_edgecase`, `bundler_html`, `bundler_html_server`,
`bundler_plugin`, `bundler_cjs`, `bundler_compile`,
`bundler_compile_splitting`, `bundler_splitting`,
`esbuild/{default,importstar,importstar_ts,dce,splitting,css,metafile,loader,lower,ts,extra,packagejson}`,
`metafile`, `bundler_naming`, `html-import-manifest`, `bundler_cjs2esm`,
`bundler_regressions`, `bun-build-api`, `bundler_browser`,
`bundler_bun`, `cli`, `bundler_loader`, `bundler_files`,
`bundler_barrel`, `bundler_minify`, `regression/issue/5344`, and
`bake/{dev/production,dev-and-prod,dev/ssg-pages-router}`. An earlier
round also ran `bun-build-compile`, `compile-asset-bunfs`,
`native-plugin`, and
`regression/issue/{10004,10139,22317,25716,27431,32686}`.

Some tests time out at 5 s on the debug build. Each of them builds one
entry point, and each passes with a longer timeout.

**`__INVALID__REF__();` in cjs output**

A file that something `require()`s gets an ESM wrapper (`init_foo`).
When all of its top-level statements hoist, the parser gives it no
wrapper symbol (`needs_wrapper_ref`), and the printer prints the file
with no wrapper. The esm tail of an entry point checks for the symbol.
The cjs tail did not, so it printed `__INVALID__REF__();`, and loading
the output threw `ReferenceError: __INVALID__REF__ is not defined`. The
cjs tail now has the same check.

One entry point is enough: `entry.ts` has `export function load() {
return require("./c.ts"); }`, and `c.ts` requires `entry.ts`. With two
entry points, `a.ts` does `require("./b.ts")`, and `b.ts` has only
`export const` lines. 1.2.0, 1.3.0, 1.4.0, and 1.4.1 all print the bad
call.

#37843 has the same check, and it also adds the missing iife tail. `bun
build --format=iife` of a CommonJS entry point runs none of its code
(1.2.0 through main), because nothing calls `require_entry()`. This PR
does not change iife output.

**Related**

- #30544 made the same key change in the Zig source. It was closed
because the Rust port removed those files.
- #40601 changes where an entry point's module goes with `--splitting`.
It does not touch the non-split key.
- #41235 changes how `import.meta.main` prints when a build has several
entry points.
- #37843 adds the iife tail and has the same cjs check as this PR.
</details>
@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

#45408 covers this. A declined Bun.build entry point now takes the code of an entry point that no plugin matched, which reads the files option first. The tests of this PR are in #45408. This PR can close when #45408 merges.

@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #45408. It gives a declined Bun.build entry point the code of an entry point that no plugin matched, which reads the files option first, and it contains the tests of this PR.

@robobun robobun closed this Oct 15, 2026
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