Skip to content

bundler: error when a file bundled as CommonJS imports a module with top-level await - #46749

Open
robobun wants to merge 1 commit into
mainfrom
robobun/0788101d/cjs-import-of-tla-build-error
Open

robobun wants to merge 1 commit into
mainfrom
robobun/0788101d/cjs-import-of-tla-build-error

Conversation

@robobun

@robobun robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun build exits 0 with a bundle that does not parse: error: "await" can only be used inside an "async" function.
  • The output has await init_tla() inside __commonJS(function(exports, module) {. Trigger: a CommonJS file has an import of a module with top-level await.
  • Cause: should_remove_import_export_stmt (src/bundler/LinkerContext.rs:2252) takes the await from the async flag of the imported file. It ignores the wrapper of the importing file.

Fix

  • validate_commonjs_imports_of_async_modules reports each such import as a build error before code generation. Notes give the import chain to the await.
  • The check and the code generator share one decision function, import_stmt_lowering.
  • Verified: test/bundler/bundler_cjs.test.ts (38 new tests, 26 fail on main). Other suites: Notes.
  • Self-reviewed: 25 concerns raised, 22 addressed. Not taken: an error for export * (it runs today), a narrower async flag (separate change), a tracking issue.

Background

  • The bundler prints a CommonJS file inside __commonJS(function(exports, module) {...}), a plain function. A file that uses module or exports is CommonJS, also with import statements.
  • An imported ES module gets var init_x = __esm(...). A top-level await on its import chain makes that wrapper async.
  • esbuild prints the same await. No user report exists.
  • Weighed: a guard where the wrapper is printed. It reports after code generation, without a position.

Downsides

  • The whole build fails. Before, the files of other entry points were still written.
  • The error can name an await that tree shaking removed (Notes).
  • Release .text +3,072 B. With a top-level await: 20,000 record reads for 20,000 CommonJS files, 0 allocations. Otherwise: one bool test.
Notes

Repro

// tla.js
await 0;
export const t = 1;
// c.js
import { t } from "./tla.js";
module.exports = { t };
// entry.js
import c from "./c.js";
console.log(c.t);

bun build entry.js --outfile=out.js && bun out.js. On main (179664e) and on 1.4.3 the build exits 0 and the run fails at line 54 of out.js:

var require_c = __commonJS(function(exports, module) {
  await init_tla();
  module.exports = { t };
});

With this PR the build exits 1:

1 | import { t } from "./tla.js";
                      ^
error: This import is not allowed because "c.js" is bundled as CommonJS and the imported file "tla.js" contains a top-level await
    at c.js:1:19
1 | await 0;
    ^
note: The top-level await in tla.js is here:
   at tla.js:1:1
note: CommonJS code runs synchronously and cannot wait for a top-level await. Load "tla.js" with import() inside a function instead

For a chain of imports the headline says the transitive dependency "tla.js", and one note per import on the way comes first (The file mid.js imports the file tla.js here:). These are the two note texts of the require() error. Both errors now take them from top_level_await_note and import_toward_top_level_await_note.

Origin. No user reported this. It was found by a read of the code. I found no issue for an await inside __commonJS. The same symptom in an __esm wrapper was reported and fixed in 2024 (#12508, #13607).

The latent form. With --splitting, or with a second entry point, the file that does not parse can be one that loads later. The build exits 0, the program starts, and the SyntaxError comes when that file is first imported. entry.js with if (process.argv[2] === "rare") await import("./c.js") runs on main until the argument is rare.

When the error is reported. Exactly when the linker would print await init_x() inside a __commonJS wrapper:

  • the importing file is in the bundle and is printed inside a __commonJS wrapper,
  • the statement is an import, an export ... from or an export * as ns from in a part that tree shaking kept,
  • the imported file is in the bundle, has an __esm wrapper, and is marked async.

import_stmt_lowering holds the conditions on the imported file. should_remove_import_export_stmt (the code generator) and the new check both call it. A debug_assert! in generate_code_for_file_in_chunk_js fails when any code puts an awaited dependency into a __commonJS wrapper. With the check disabled in a local build, that assertion fired for the repro.

The check runs at the start of generate_chunks_in_parallel, on the bundle thread. bun build, Bun.build, --compile, HTML entry points and bun build --app go through it. The commands that only link (bun install --analyze, bun add --analyze) return before it: on the repro they exit 0 on main and with this PR. --format=internal_bake_dev has no __commonJS wrapper and is not checked.

Which files are CommonJS for the bundler. The parser decides (src/js_parser/parse/parse_entry.rs). A file is CommonJS when it has no export and references module or exports. That includes typeof module, module.hot and TypeScript export =. A direct eval counts as a reference to both. The file name does not matter: a .mjs file with typeof module is CommonJS too. The linker also puts a lifted exports.x = ... file back into a wrapper when an importer holds its default import as a value.

importing file node, source bun run, source esbuild 0.25.1 bundle bun build, main this PR
import and module.exports refuses refuses await in __commonJS, does not parse exit 0, does not parse build error
import and a direct eval runs runs await in __commonJS, does not parse exit 0, does not parse build error, with a note for the eval
.mjs, import and typeof module runs refuses (#45729) runs exit 0, does not parse build error

For the direct eval the extra note is: A direct eval makes a file without an "export" CommonJS. Add an "export" to "c.js", or call eval as (0, eval)(code). Two tests build the file with each of these two changes. The notes give no reason for the other files: at link time the flags for module and exports are also set for export =, for a direct eval and by the linker, so a reason read from them can be false.

Fixtures. 100 builds of small graphs, release builds of main (179664e, md5 105b11a1) and of this PR (c37de90, md5 883bc10c):

  • 35 of 35 builds with a linker await inside __commonJS on main now exit 1.
  • 0 other builds fail newly.
  • 52 of 52 builds that pass on both are byte-identical.
  • 13 of 13 builds that fail on main fail with the same first error and the same error count.

A second set of 159 builds: 47 of 47, 0, 96 of 96, 16 of 16.

Builds that keep working (each is a test): a lifted exports.x file that importers read by name, an unused import of a "sideEffects": false package (also when another file uses the package), a type-only import, a CommonJS file that is not in the bundle, export * without alias, a sync import beside a top-level-await module, --format=internal_bake_dev. --ignore-dce-annotations and treeShaking: false keep the package in the bundle, and the same sources then get the error.

Measurements. Release builds of main and of this PR, the md5 values above.

  • size -A: .text 82,164,617 to 82,167,689 (+3,072 B). Every other section is the same.
  • size_of::<LinkerContext>() 1160 to 1160, size_of::<RequireOrImportMeta>() 20 to 20.
  • In the release build import_stmt_lowering is inlined into its two callers (#[inline(always)]). With plain #[inline] it stayed a call in the code generator.
  • Build with no top-level await, 20,000 CommonJS files: the check is not entered (gdb breakpoint counters: 0 hits). Instructions (user mode, median of 10 interleaved runs): 1,969,884,550 to 1,970,379,718 (+0.025%). The main binary against a copy of itself: +0.024%.
  • Build with a top-level await in the entry, 20,000 CommonJS files that import a sync module: 20,003 loop steps and 20,000 import record reads. 40,003 and 40,000 at 40,000 files. Step 6 of scan_imports_and_exports reads 40,000 records on the 20,000 graph. Instructions: 1,986,936,760 to 1,987,809,308 (+0.044%, main against itself -0.089%).
  • Allocator calls inside the check (debug build md5 00cca598, gdb on __rust_alloc, __rust_realloc, __rust_alloc_zeroed): 0 on a build that passes (2,000 CommonJS files, top-level await in the entry), 19 on the repro.
  • The error path is linear. N CommonJS files import a file that has 2,000 imports before the one with the await: 9.09e9 instructions for 5,000 errors, 18.06e9 for 10,000, 35.99e9 for 20,000. The require() error on the same graphs: 9.01e9, 17.91e9, 35.72e9. The chain table is built once, for the first error.
  • With --sourcemap=inline: 40 of 40 bun build runs and 40 of 40 Bun.build runs report the error (debug + ASAN build).
  • perf, valgrind, heaptrack, strace and bloaty are not in the container. The counts are from gdb and a perf_event_open counter.

Same class, not in this PR.

Weighed and not built.

  • A guard where the __commonJS wrapper is printed (about 12 lines): it refuses the same builds, after every file is generated, with a file name and no position or chain.
  • await init_x() in the importers of the CommonJS file, above the call of its wrapper: require() of the file has no place to wait, and the imports of the file no longer run in source order.
  • A move of the async marking to after the wrapper choice: it changes all readers of the flag and overlaps bundler: compute top-level await as a fixed point #45949.

Related open PRs.

Self-review. 25 concerns. Changes that came from it: the chain is computed once per build and not per error, validate_tla shares the note text, the hint no longer names module.exports and no longer suggests an import() that a reader could await at the top level, the note for a direct eval, 12 more tests (option twins, chains, files that are CommonJS without module.exports, two entry points), no docs sentence, and the import() change moved to #46748. Not taken:

  • An error for export * without alias. It runs today. It is the question above.
  • A narrower async flag. It is a change to the writers of the flag.
  • A tracking issue. This PR and the list above are the record.

Tests. test/bundler/bundler_cjs.test.ts: 100 pass with the debug build of this branch. With a release build of main, the 26 tests that expect the error fail and the 12 tests that expect a build pass. The tests are in this file and not in test/bundler/esbuild/default.test.ts: two 40-build tests there time out under a debug build (#46028).

Suites. Release build of this branch: all 71 files of test/bundler/ and test/bundler/esbuild/, test/regression/issue/cyclic-imports-async-bundler.test.js and test/bake/dev-and-prod.test.ts: 3,178 pass, 2 fail. The 2 are in bundler_bytecode_portable.test.ts and fail the same way with the build of main. I cannot run Windows or macOS here.

…top-level await

An import statement in a file that is printed inside a __commonJS wrapper
is lowered to a call of the imported file's __esm wrapper. When that file
is async (a top-level await is on its chain of static imports), the call
was printed as `await init_x()` inside the wrapper, a plain function, and
the bundle did not parse.

The linker now reports that import as an error, after tree shaking and
before code generation. The check and the code generator take the
lowering of an import statement from one function.
@coderabbitai

coderabbitai Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 47 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 179664e and c37de90.

Files selected for processing: 4
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/generateChunksInParallel.rs
  • src/bundler/linker_context/generateCodeForFileInChunkJS.rs
  • test/bundler/bundler_cjs.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (179664e, release build) and on Bun 1.4.3:

  1. tla.js is await 0; export const t = 1;.
  2. c.js is import { t } from "./tla.js"; module.exports = { t };.
  3. entry.js is import c from "./c.js"; console.log(c.t);.
  4. bun build entry.js --outfile=out.js exits 0. bun out.js fails with error: "await" can only be used inside an "async" function at await init_tla(); inside the __commonJS wrapper of c.js.

With this PR the build exits 1 and reports the import in c.js.

PR: #46749

CI (build 132039, c37de90): the diff is green on every job that ran a test. 179 jobs passed. 7 test files failed once and passed on retry, and none of them is a bundler test. Two jobs cannot start: no agent takes darwin x64 - test-bun, in this build or in the other recent PR builds (29 checked), so the jobs expire and the build does not end. The darwin 27 aarch64 job (allowed to fail) lost its agent during git clone.

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:01 AM PT - Oct 24th, 2026

✅ @robobun, your commit c37de907a015724b935a671e0a04dcd3f3d38379 passed in Build #132039! 🎉


🧪   To try this PR locally:

bunx bun-pr 46749

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

bun-46749 --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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also diffed the import_stmt_lowering extraction against the old should_remove_import_export_stmt arm by arm: each variant emits the same statement with the same loc (external keeps record.range.loc, CJS keeps loc), WrapKind::None still falls through to removal, and the ? on the external arm only changes an expect into error propagation. I also checked the has_any_top_level_await_modules gate in generateChunksInParallel.rs: the validator is skipped when no module is async, which is when Init { is_async: true } cannot occur.

Extended reasoning...

The change refactors the import-statement lowering in src/bundler/LinkerContext.rs into a shared decision enum, adds a pre-codegen validator that fails the build when a __commonJS-wrapped file imports an async __esm module, and adds a debug assertion plus 38 tests. It touches no security-sensitive surface. The one confirmed inline finding (unaliased export * from still slips through) and the overall size of the linker change are why a human look remains worthwhile.

Comment on lines +2420 to +2432
// The statements that `convert_stmts_for_chunk` gives to
// `should_remove_import_export_stmt`. It prints `export * from` without an
// alias as a bare `init_x()`, so that statement needs no `await`.
let (namespace_ref, import_record_index) = match stmt.data {
bun_ast::StmtData::SImport(s) => (s.namespace_ref, s.import_record_index),
bun_ast::StmtData::SExportFrom(s) => {
(s.namespace_ref, s.import_record_index)
}
bun_ast::StmtData::SExportStar(s) if s.alias.is_some() => {
(s.namespace_ref, s.import_record_index)
}
_ => continue,
};

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.

🟣 pre-existing, not blocking: pre-existing: users whose CommonJS file has export * from a top-level-await module still get a bundle that silently reads exports before they are initialized, where every other import shape now gets the new build error. The validator at src/bundler/LinkerContext.rs:2428 only inspects SExportStar when s.alias.is_some(), and convertStmtsForChunk.rs:276-298 prints the un-aliased form as a bare init_x() with no await inside the __commonJS wrapper. Fix: treat an un-aliased export * (including the lifted module.exports = require(...) form at convertStmtsForChunk.rs:301) whose target is Init { is_async: true } as the same error, so every statement that calls an async __esm wrapper from a __commonJS body is rejected, not only the three awaited shapes. [also at: src/bundler/LinkerContext.rs:2422 - pre-existing: users whose __commonJS-wrapped file re-exports a top-level-await module with an unaliased export * still get a bundle that reads that module's exports before its await resolves, with no error.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Input: c.js is CommonJS for the bundler and contains export * from "./tla.js" where tla.js is await 0; export const t = 1;; entry does import c from "./c.js"; console.log(c.t). bun build runs validate_commonjs_imports_of_async_modules (src/bundler/LinkerContext.rs:2384); the match at LinkerContext.rs:2428 skips SExportStar without alias, so no error is reported. Code generation at src/bundler/linker_context/convertStmtsForChunk.rs:272-298 appends init_tla() via append_non_dependency (no await) inside the __commonJS function, then __reExport(exports, tla_exports). init_tla is an async __esm wrapper: t = 1 runs only after the await 0 microtask, so require_c() returns synchronously and c.t is undefined. The base branch produces the same output, so this is pre-existing; the PR's test cjs/tla_import_export_star (which only reads c.a) does not make the output correct. The lifted form at convertStmtsForChunk.rs:301-323 has the same exposure.

Verification: pre-existing; acknowledged in diff: the PR description says "Not taken: an error for export * (it runs today)". The validator's match at src/bundler/LinkerContext.rs:2428 is SExportStar(s) if s.alias.is_some(), so the un-aliased form falls to _ => continue. src/bundler/linker_context/convertStmtsForChunk.rs:272-298 emits a bare init_x() with no await, so init_x() returns a promise that nobody waits for.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the question that the PR body leaves for a maintainer (Notes, "Same class, not in this PR"). I did not add the error, because these builds run today. Facts for that decision, from release builds of main (179664e) and of this branch (c37de90):

  • c.js is export * from "./tla.js"; exports.a = 1; Object.keys(exports);. Both builds exit 0 and print a bare init_tla(); in the wrapper. A function declaration or a literal of tla.js is ready at once. A value that tla.js assigns after its await reads undefined, and 1 three microtasks later.
  • The names are there: the wrapper has __export(exports, { t: () => t }). The linker prints no __reExport for this file.
  • Only the bun build bundle runs that source. node and bun run treat a file with export as an ES module and throw ReferenceError: exports is not defined. esbuild 0.21.5 bundles it as an ES module, and the bundle throws a ReferenceError at exports.a = 1.
  • The lifted form is not exposed. module.exports = require("./tla.js") fails the build on main and here: This require call is not allowed because the transitive dependency "tla.js" contains a top-level await. The same holds when the require() target reaches tla.js through an export *.

If a maintainer wants the error, I add this statement to the check, with a hint of its own (the import() hint does not fit a re-export).

@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

#41601 changed at 9eda6aac4d6. It is an order-only change now. The dependency statements of a wrapper keep source order when none of them is for an async dependency, and a list with an async dependency is printed as on main. It no longer awaits an export *, no longer replaces __promiseAll, no longer changes the import() form, and no longer sets the top-level await flag.

So three lines in the body of this PR do not hold any more. #41601 does not make that statement wait, does not change await in a plain __esm wrapper, and does not fix __promiseAll is not defined.

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