Skip to content

bundler, repl: keep an import's type when it becomes require() or import() - #46294

Open
robobun wants to merge 4 commits into
mainfrom
robobun/9d88fab7/cjs-external-require-type
Open

robobun wants to merge 4 commits into
mainfrom
robobun/9d88fab7/cjs-external-require-type

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • For target bun, cjs and iife output turn import db from "./my.db" with { type: "sqlite" } into require("./my.db"). The runtime then picks the loader by extension: error: Expected ";" but found "format". bun repl fails the same way.
  • The require() branch of print_require_or_import_expr (src/js_printer/lib.rs) and the REPL lowering (src/js_parser/repl_transforms.rs) never read record.loader.

Fix

  • The branch prints require(path, { type }), which the runtime already reads. The REPL prints import(path, { with: { type } }).
  • resolve_import_records no longer stores each bundled file's loader in record.loader. For text, md and sqlite, __toESM( closes without , 1.
  • Verified: test/bundler/bundler_bun.test.ts, bundler_cjs2esm.test.ts, test/js/bun/repl/repl.test.ts (11 cases fail on 1.4.3). Also splitting, loader, html, bake.
  • Self-reviewed: 26 concerns raised, 22 addressed, 4 need tracking issues (Notes).

Background

  • An import record is the parser's entry for one import. The printer prints its loader as the type attribute.
  • __toESM(value, 1) makes value the default import. For these types require() returns an ES module namespace, which has its own default.
  • Considered a printer check that skips split and redirected records: valid only while both stay ESM-only.

Downsides

  • ESM output loses the stray with { type: "jsx" } after a module.exports = require("ext") redirect.
  • The , 1 list is a stopgap: with "type": "module", an external ES module still gets the namespace as default.
  • Without a type: +4 instructions per printed require() (615 to 619), 415 of 415 outputs identical, .text +512 bytes.
Notes

Reach

  • There is no issue and no user report. The defect shows when you build the sqlite example of docs/bundler/executables.mdx with the --compile --bytecode command of the same page.
  • Trigger: target bun with cjs or iife output (--format=cjs, --format=iife, --bytecode, each also with --compile). The import stays external, and its type is not the loader that the file extension selects.
  • Only the non-embedded sqlite example is fixed. The embed: "true" example fails at a different site (bundler: use require() for embedded sqlite modules in cjs output #38173).
  • Workarounds on released versions: --format=esm, new Database("./my.db"), or a hand-written require(path, { type }).

Changes by file

  • src/js_printer/lib.rs: the external require() arm prints , { type: "<name>" } through print_type_attribute. The helper is out of line on purpose. With the three prints inline, the closing ) of every external require() was no longer inlined (615 to 660 instructions per call).
  • The two 22-arm loader matches in the printer (statement clause and module info) now call Loader::import_attribute_type.
  • src/ast/loader.rs: import_attribute_type has a round-trip unit test for every variant (it runs under Miri). require_returns_generated_es_module is a match with no wildcard, so a new loader must be classified.
  • src/bundler/bundle_v2.rs: the two import_record.loader = Some(import_record_loader) stores are gone. The local still feeds resolve_task.loader and is_html_entrypoint. Only the printer read the stored value.
  • src/js_parser/repl_transforms.rs: the import() gets { with: { type } } when the record has a loader. An import with no attribute prints as before.

The , 1 rule needs a maintainer decision

  • bundler: pick the __toESM interop from the importer's module type #41150 added , 1 (node mode) to __toESM(require(...)) for importers that are ES modules by type.
  • For text, md and sqlite, require() returns the namespace of a module that bun generates. Node mode makes that namespace the default import: TypeError: import_my.default.query is not a function.
  • This PR exempts those three types when the import has the type attribute. A test pins the runtime table behind the list.
  • bundler: keep exports.default as the default import of a bun: module in CommonJS output #41953 exempts bun: specifiers in the same if. The PR that merges second needs a rebase.
  • Still wrong in cjs output under "type": "module": an untyped .txt, .md or .sqlite import, and any external ES module. The default import is the namespace there.
  • One rule for target bun (no , 1 on an external require()) covers those cases. It also changes the default import of a CommonJS file with __esModule in such a project. This PR leaves that choice open.

Not in this PR

Related open PRs

Measurements (linux-x64, release builds with LTO of main ea6a2c7b7c and of this branch, built at the same path)

  • Output with no type: 0 of 415 files differ (sha256sum).
  • The set has 20,000 imports and 20,000 requires for targets bun and node, in esm, cjs and iife. It also has --no-bundle output and a generated 400-module app, with and without --splitting.
  • One typed import added: esm 0 lines change, cjs 1 line, iife 1 line.
  • Ten import forms through a module.exports = require("the-external") file, ESM output: 3 of 23 lines change. Each loses with { type: "jsx" }. __require("the-external") does not change.
  • One printed external require() with no type (gdb stepi): 615 to 619 instructions in the bundler. With __toESM: 1070 to 1074. In the runtime transpiler: 720 to 724.
  • REPL lowering of one import with no type: 3217 to 3224 instructions.
  • resolve_import_records: loader stores per bundled record 1 to 0 (objdump, 2 to 0 store sites).
  • Stripped binary: 107,271,904 bytes before and after. .text +512 bytes, .rodata unchanged. js_printer symbols -668 bytes, js_parser symbols +3,148 bytes.
  • ImportRecord stays 96 bytes. EXPECTED_VERSION of the transpiler cache is unchanged, and --no-bundle output is identical.
  • At run time the typed cjs bundle makes 3 more syscalls than the ESM bundle of the same input (gdb catch syscall).

Tests

  • 11 new cases fail on 1.4.3 and pass on a debug build of this branch.
  • Four more cases are pins that pass on both:
    • target node prints one argument
    • a split require() of an in-memory file prints one argument
    • a REPL import with no type gets no options
    • the runtime table behind the , 1 list
  • Bundler suites run on the debug build: bundler_bun, bundler_cjs2esm, bundler_files, bundler_splitting, bundler_loader, bundler_cjs, bundler_plugin, bundler_edgecase, bundler_html, bundler_html_server, html-import-manifest.
  • Also run: the esbuild loader, default, metafile, importstar and splitting suites, import-attributes, transpiler, repl, repl-transform, and bake dev bundle, html, esm and css.
  • edgecase/DeepImportDiamondDAG hits its 60 s limit on this debug build (95 s for 20,000 modules). Release builds of main and of this branch take the same time and give identical output.
  • --compile --format=cjs and --compile --bytecode of the docs example: run by hand, both print the row.

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

… output

For target bun, cjs and iife output turn an external import statement
into a require() call. The printer dropped the statement's `type`
attribute there, so the runtime picked the loader from the file
extension. The call now carries it as `require(path, { type })`, which
the runtime already reads.

ImportRecord.loader now holds only the loader that the attribute names.
resolve_import_records no longer stores the loader it resolves for a
bundled file there, and the `module.exports = require(x)` redirect
takes the inner record's loader with its path. ESM output no longer
prints a stray `with { type: "jsx" }` on an import that was redirected
to an external module.

For text, md and sqlite the `__toESM(` call closes without the node
mode flag: require() returns an ES module namespace for these types.
A typed import is rare. With the three print calls inline, the closing
parenthesis of every external require() stopped being inlined: 45 more
instructions per call for an import with no attribute.
…copy

- repl: the `await import()` that replaces a typed import statement now
  gets `{ with: { type } }`.
- bundler: the `module.exports = require(x)` redirect is back to main. With
  the resolved-loader stores gone the shim's record has no loader to leak.
- The types whose require() result is a generated ES module are a
  wildcard-free match on Loader, pinned against the runtime by a test.
- Comments under src/ are one line each.
The compile variants print the same cjs chunk as the bytecode case, and
the import-attributes file is too slow for a debug build to run whole.
@coderabbitai

coderabbitai Bot commented Oct 21, 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 9 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
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: ff044216-9a1e-483d-b522-d2ebf64e507d
📥 Commits

Reviewing files that changed from the base of the PR and between 71bc818 and ba4b1ec.

📒 Files selected for processing: 10
  • src/ast/import_record.rs
  • src/ast/loader.rs
  • src/bundler/bundle_v2.rs
  • src/js_parser/repl_transforms.rs
  • src/js_printer/lib.rs
  • test/bundler/bundler_bun.test.ts
  • test/bundler/bundler_cjs2esm.test.ts
  • test/bundler/bundler_files.test.ts
  • test/js/bun/repl/repl.test.ts
  • test/js/bun/transpiler/repl-transform.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 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:16 AM PT - Oct 21st, 2026

❌ @robobun, your commit ba4b1ec has 1 failures in Build #129900 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46294

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

bun-46294 --bun

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting for CI.

How I reproduced it, on 1.4.3-canary.1+367d939d9 and on main:

bun -e 'const {Database}=require("bun:sqlite"); const db=new Database("my.db"); db.run("create table t(x)"); db.run("insert into t values (1)"); db.close()'
printf 'import db from "./my.db" with { type: "sqlite" };\nconsole.log(db.query("select x from t").get());\n' > index.ts
bun build --target=bun --format=cjs ./index.ts --outfile out.cjs && bun out.cjs

The output has __toESM(require("./my.db")) and the run fails with error: Expected ";" but found "format" at my.db:1:8. --bytecode, --format=iife, --compile with either, and bun repl -e with the same import fail the same way. --format=esm prints { x: 1 }.

With this branch every one of those prints { x: 1 }. 11 new test cases fail on 1.4.3 and pass on a debug build of the branch.

@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 findings, I also checked the two deleted import_record.loader = Some(...) stores in src/bundler/bundle_v2.rs: the remaining readers of record.loader (the plugin onResolve result paths around lines 2723 and 2979, and the sqlite external check at 6748) all run before the deleted store sites or fall back to the extension-derived loader, so nothing downstream loses the resolved loader. The with { type } and FetchParameters rewrites in the printer are behavior-preserving for every loader variant per the round-trip unit test, and non-bun targets stay unchanged via the IS_BUN_PLATFORM gate.

Extended reasoning...

The change touches the JS printer's require() lowering, the bundler's import-record resolution, the REPL import transform, and the Loader enum, with new itBundled and REPL tests; it touches no security-sensitive surface. Three inline findings were posted (missing --compile coverage, duplicated object-building blocks in the REPL helper, and a pre-existing gap in the , 1 exemption), so a human look is already signalled; this note only records that the bundle_v2 store deletions and the printer match-table consolidation were checked and found behavior-preserving.

Comment on lines +94 to +95
for (const bytecode of [false, true]) {
itBundled(`bun/sqlite-type-attribute-cjs${bytecode ? "-bytecode" : ""}`, {

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.

🟡 (optional) Maintainers get no automated coverage of the --compile path this PR names as the user-visible trigger, so a standalone-executable regression would pass CI. The PR text says the defect shows when the docs sqlite example is built with --compile --bytecode and that this variant was only run by hand, while test/bundler/bundler_bun.test.ts:94 covers only format: "cjs" and bytecode without compile. Fix: add the compile variants alongside the existing cases (itBundled already accepts compile: true with bytecode), so cjs, iife, --bytecode and --compile --bytecode are all exercised against a runtime .db file.

Why this was flagged

The new cases at test/bundler/bundler_bun.test.ts:94-108 iterate bytecode in [false, true] with format: "cjs" and outdir: "/out", and the iife case at test/bundler/bundler_bun.test.ts:109 uses format: "iife"; none sets compile. Standalone executables load modules from the embedded /$bunfs graph through src/standalone_graph/StandaloneModuleGraph.rs, a different module-loading path from a cjs file on disk, and require("./my.db", { type: "sqlite" }) is resolved relative to that virtual root there. The PR description states the --compile --format=cjs and --compile --bytecode builds of the docs example were run by hand only. On the base branch this path also has no test; after the merge a future change that breaks the second require() argument inside a compiled executable would still pass the suite. itBundled supports compile: true (test/bundler/expectBundled.ts:193) together with bytecode, so the variant can be added without new harness code.

Verification: The PR description names --compile --bytecode as where the defect shows; the manual-run claim is not something the test suite enforces. The new cases in test/bundler/bundler_bun.test.ts lines 94-116 set format: "cjs", bytecode in [false,true] and format: "iife", but none sets compile. Any regression confined to the standalone-executable path would not be caught.

Comment on lines +729 to +773
let mut attributes = BumpVec::<G::Property>::with_capacity_in(1, bump);
attributes.push(G::Property {
key: Some(self.new_expr(
E::String {
data: b"type".into(),
..Default::default()
},
loc,
)),
value: Some(self.new_expr(
E::String {
data: loader.import_attribute_type().into(),
..Default::default()
},
loc,
)),
..Default::default()
});
let with = self.new_expr(
E::Object {
properties: G::PropertyList::from_bump_vec(attributes),
is_single_line: true,
..Default::default()
},
loc,
);
let mut options = BumpVec::<G::Property>::with_capacity_in(1, bump);
options.push(G::Property {
key: Some(self.new_expr(
E::String {
data: b"with".into(),
..Default::default()
},
loc,
)),
value: Some(with),
..Default::default()
});
self.new_expr(
E::Object {
properties: G::PropertyList::from_bump_vec(options),
is_single_line: true,
..Default::default()
},
loc,

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.

🟡 nit (optional): maintainers get two near-identical 20-line "build a one-property object" blocks in the new helper where one shared helper would do. In src/js_parser/repl_transforms.rs:729-773 the attributes block and the options block differ only in key string and value expr. Fix: extract a helper that takes (key, value, bump, loc) and returns the single-property E::Object, and call it twice (type → string, with → inner object), which also matches the existing repl_wrap_expr_in_value_object style in the same file.

Why this was flagged

Triggered whenever a maintainer reads or changes repl_import_type_options in src/js_parser/repl_transforms.rs:722-773; no runtime caller or input is involved. The function builds { type: "<name>" } at lines 729-753 and { with: ... } at lines 755-773 with the same BumpVec::with_capacity_in(1, bump) + G::Property push + E::Object construction, differing only in the key literal and the value expr. The base branch has no such helper, so there is no behavioral difference for users; this is a code-shape duplication within the diff only. No safeguard applies; it is a style nit under the review rule that a repeated multi-line block in one diff should become a named helper.

Verification: nit. Triggered only when a maintainer reads or edits the new helper; no runtime behavior is affected. repl_import_type_options at /home/claude/bun/src/js_parser/repl_transforms.rs:723-775 builds { type: "<name>" } at lines 729-754 and then repeats the identical shape at lines 755-774 for { with: <inner> }, differing only in the key literal and the value expr.

Comment thread src/js_printer/lib.rs
Comment on lines 2997 to 3006
if wrap_with_to_esm {
self.print_to_esm_suffix();
// `isNodeMode` would make a generated module's whole namespace the default import.
if type_attribute
.is_some_and(|loader| loader.require_returns_generated_es_module())
{
self.print(b")");
} else {
self.print_to_esm_suffix();
}
}

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 bundling for target bun in cjs/iife from a "type": "module" project still get a namespace object as the default import of an external .txt or .md file. The , 1 exemption at src/js_printer/lib.rs:2999 keys on the type attribute only, so import text from "./a.txt" with --external "*.txt" still prints __toESM(require("./a.txt"), 1) and text.trim() throws. Fix: decide isNodeMode from the loader the runtime will actually pick for the external path (the attribute, else the extension via the loader table), or drop , 1 for every external require() on target bun, covering typed and untyped text, md and sqlite alike. [also at: src/js_printer/lib.rs:3005 - pre-existing: users bundling for target bun with cjs/iife output and "type": "module" still get a namespace, not the string, as the default import of an external untyped .txt/.md file.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Input: a project with package.json "type": "module", built with target bun and format cjs or iife, containing import text from "./a.txt" (no attribute) where *.txt is external, run through bun build / Bun.build. The record has loader None, so at src/js_printer/lib.rs:2999 type_attribute.is_some_and(...) is false and src/js_printer/lib.rs:3004 prints , 1) because input_module_type is Esm (src/js_printer/lib.rs:3074). At run time Bun's require() of a .txt file returns a generated ES module namespace, and __toESM with isNodeMode=1 sets default to that whole namespace, so text is an object and text.trim() throws TypeError. The base branch fails the same way; the PR fixes only the sibling where a with { type } attribute is present, so the extension-selected loaders (.txt, .md, and any --loader .ext:sqlite) stay broken. A fix needs the loader the runtime will choose, which the linker can derive from the record path's extension for external records.

Verification: pre-existing; acknowledged in diff: the PR description says "Still wrong in cjs output under "type": "module": an untyped .txt, .md or .sqlite import ... This PR leaves that choice open". At src/js_printer/lib.rs:2999-3000 type_attribute is None for an untyped import, so print_to_esm_suffix() prints , 1); __toESM(mod, 1) in src/runtime.js:67-68 makes the namespace the default, so text.trim() throws TypeError.

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