Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/runtime/module-resolution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ Bun also supports [Node.js-style subpath imports](https://nodejs.org/api/package

<Accordion title="Low-level details of CommonJS interop in Bun">

Bun's JavaScript runtime has native support for CommonJS. When Bun's JavaScript transpiler detects usages of `module.exports`, it treats the file as CommonJS. The module loader then wraps the transpiled module in a function shaped like this:
Bun's JavaScript runtime has native support for CommonJS. A `.cjs` or `.cts` file, or a `.js` or `.ts` file under a package.json with `"type": "commonjs"`, is CommonJS. A `.mjs` or `.mts` file, or a `.js` or `.ts` file under `"type": "module"`, is an ES module, and `module` and `exports` are not defined there, as in Node.js. For any other file, Bun's JavaScript transpiler reads the contents: when it detects usages of `module.exports`, it treats the file as CommonJS. The module loader then wraps the transpiled module in a function shaped like this:

```js
(function (module, exports, require) {
Expand Down
6 changes: 4 additions & 2 deletions src/ast/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2629,7 +2629,9 @@ impl EqlKindT for StrictEql {
/// blanket-impl'd for every `P<...>` instantiation below.
pub trait EqlParser {
fn arena(&self) -> &Bump;
fn module_ref(&self) -> Ref;
/// Whether `r#ref` is the CommonJS `module` binding, or the unbound global
/// `module` in a file that does not declare that binding.
fn is_module_ref(&self, r#ref: Ref) -> bool;
}
// `impl EqlParser for P<...>` lives in `bun_js_parser` (next to `P`).

Expand Down Expand Up @@ -2760,7 +2762,7 @@ impl Data {
// always re-ordered to the right side.
if matches!(right, Data::ERequireMain) {
if let Some(id) = left.as_e_identifier() {
if id.ref_.eql(p.module_ref()) {
if p.is_module_ref(id.ref_) {
return Equality::RequireMainAndModule;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2864,7 +2864,7 @@ impl<'a> Transpiler<'a> {
remove_cjs_module_wrapper: false,
dont_bundle_twice: false,
allow_commonjs: false,
module_type: options::ModuleType::Unknown,
module_type: resolve_result.module_type,
runtime_transpiler_cache: None,
keep_json_and_toml_as_one_statement: false,
allow_bytecode_cache: false,
Expand Down
11 changes: 9 additions & 2 deletions src/js_parser/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,15 @@ impl<'a, const IS_TS: bool, const SCAN: bool> bun_ast::expr::EqlParser
self.arena
}
#[inline]
fn module_ref(&self) -> Ref {
self.module_ref
fn is_module_ref(&self, r#ref: Ref) -> bool {
if r#ref.eql(self.module_ref) {
return true;
}
if !r#ref.is_symbol() {
return false;
}
let symbol = &self.symbols[r#ref.inner_index() as usize];
symbol.kind == bun_ast::symbol::Kind::Unbound && symbol.original_name.slice() == b"module"
}
}

Expand Down
36 changes: 31 additions & 5 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3344,10 +3344,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.generated
.ensure_unused_capacity(generated_symbols_count as usize * 3);

self.exports_ref =
self.declare_common_js_symbol(js_ast::symbol::Kind::Hoisted, b"exports")?;
self.module_ref =
self.declare_common_js_symbol(js_ast::symbol::Kind::Hoisted, b"module")?;
if self.is_file_considered_to_have_esm_exports {
// An ES module has no `exports` or `module` binding: a source
// reference to either name is an unbound global, as in Node. The
// symbols still back generated code (the ESM namespace object,
// `export =`, the HMR wrapper), so they live on `generated` only.
self.exports_ref = self.new_symbol(js_ast::symbol::Kind::Hoisted, b"exports");
self.module_ref = self.new_symbol(js_ast::symbol::Kind::Hoisted, b"module");
let (exports_ref, module_ref) = (self.exports_ref, self.module_ref);
VecExt::append(&mut self.module_scope_mut().generated, exports_ref);
VecExt::append(&mut self.module_scope_mut().generated, module_ref);
Comment on lines +3347 to +3356

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) Users running a .js/.ts file under "type": "module" that uses module.exports now get a bare ReferenceError: module is not defined; the base ran the file as CommonJS. src/js_parser/p.rs:3347 leaves module/exports unbound when the hint is ESM but logs nothing, so nothing says which package.json or extension made the file ESM. Node appends "in ES module scope" plus a note naming the package.json and suggesting .cjs; esbuild warns at build time. Fix: when options.module_type == Esm and the module scope resolves module or exports as Unbound, emit a note with the reason and the .cjs remedy on every path (runtime, bun build, plugin), or decorate the runtime ReferenceError as Node does.

Why this was flagged

A project created by bun init gets "type": "module" (src/runtime/cli/init_command.rs:664). It has util.js containing module.exports = { f } and an entry that requires it. On the base branch module was declared by declare_common_js_symbol, uses_module_ref at src/js_parser/parse/parse_entry.rs:1875 made exports_kind Cjs and the file ran under the CommonJS wrapper. After the change module_type_hint returns Esm for .js under that package.json (src/runtime/jsc_hooks.rs:3928-3934), src/js_parser/p.rs:3347-3356 creates module only as a generated symbol, the source reference becomes Unbound, parse_entry.rs:1900 picks Esm, and evaluation throws ReferenceError: module is not defined with no note. Node for the same file prints ReferenceError: module is not defined in ES module scope followed by a line saying the file is treated as an ES module because of the .js extension and the package.json "type": "module", and to rename it to .cjs. Bun emits nothing on any path: no parser note is added where the symbol is left unbound, and no runtime decoration exists.

Verification: Triggered whenever a .js/.ts file under a package.json with "type": "module" assigns module.exports. src/js_parser/p.rs:3347-3356 creates module_ref via new_symbol and appends it to generated only, so a source module resolves to an unbound global and JSC throws its generic ReferenceError. On the base, parse_entry.rs:1864-1877 ran the file as CommonJS. No Bun-side augmentation exists.

} else {
self.exports_ref =
self.declare_common_js_symbol(js_ast::symbol::Kind::Hoisted, b"exports")?;
self.module_ref =
self.declare_common_js_symbol(js_ast::symbol::Kind::Hoisted, b"module")?;
}

self.require_ref =
self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"require")?;
Expand Down Expand Up @@ -5976,7 +5988,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
if self.options.repl_mode {
return None;
}
if self.has_es_module_syntax && self.commonjs_named_exports.count() == 0 {
if (self.has_es_module_syntax || self.is_file_considered_to_have_esm_exports)

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) A .ts/.mts file under "type": "module" that uses TypeScript export = is still CommonJS-wrapped, but its top-level this is now rewritten to undefined; on the base it stayed module.exports. src/js_parser/visit/visit_stmt.rs:166 records a module_ref use so src/js_parser/parse/parse_entry.rs:1875 picks Cjs, yet src/js_parser/p.rs:5991 now substitutes this whenever is_file_considered_to_have_esm_exports is set, regardless of the final verdict. Fix: only substitute this when the file's final exports kind is ESM (or when uses_module_ref is false), and add an export = plus top-level this case to the module-type tests.

Why this was flagged

Trigger: a .ts file in a "type": "module" package (or any .mts) containing export = {...} and a top-level this.foo = ... or this read, run with bun run or statically imported. src/js_parser/p.rs:3245-3247 sets is_file_considered_to_have_esm_exports from options.module_type == Esm. src/js_parser/visit/visit_stmt.rs:163-166 emits module.exports = value and records a module_ref use, so src/js_parser/parse/parse_entry.rs:1875-1879 chooses Cjs with the BunCommonjs wrapper (src/js_parser/p.rs:9097), whose first argument is exports. Base: value_for_this only substituted when has_es_module_syntax was set, so this in this CJS-wrapped file was exports. Now src/js_parser/p.rs:5991 substitutes it with undefined because the hint alone flips the condition, so this.foo = 1 throws TypeError and reads give undefined. The dismissal called the population unrealistic; Bun users do not run tsc's module check, and export = is the documented TS form for CommonJS-shaped config modules. Remedy: gate the substitution on the final ESM verdict.

Verification: Triggered when a .mts file, or a .ts file under package.json "type": "module", contains TypeScript export = ... and a top-level this. src/js_parser/parse/parse_entry.rs:1875 picks ExportsKind::Cjs, but src/js_parser/p.rs:5991-6000 turns top-level this into null; on the base it became the exports identifier. No test in the PR covers export = in an ESM-typed file.

&& self.commonjs_named_exports.count() == 0
{
// In an ES6 module, "this" is supposed to be undefined. Instead of
// doing this at runtime using "fn.call(undefined)", we do it at
// compile time using expression substitution here.
Expand Down Expand Up @@ -6031,6 +6045,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
}

/// `require.main === module` becomes `import.meta.main`, so the `module`
/// operand (the CommonJS binding, or the unbound global in an ES module)
/// is no longer used.
pub(crate) fn ignore_usage_of_require_main_module(
&mut self,
module_operand: &js_ast::ExprData,
) {
if let js_ast::ExprData::EIdentifier(id) = module_operand {
self.ignore_usage(id.ref_);
}
}

pub(crate) fn ignore_usage_of_runtime_require(&mut self) {
if self.options.features.auto_polyfill_require {
debug_assert!(!self.runtime_imports.__require.is_empty());
Expand Down
21 changes: 13 additions & 8 deletions src/js_parser/parse/parse_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,14 @@ impl<'a> Options<'a> {
hasher.update(b"udfcf=0");
}

// The hint decides the output of the same source (`exports`/`module`
// bindings, strict mode).
hasher.update(match self.module_type {
options::ModuleType::Unknown => b"mt=?",
options::ModuleType::Cjs => b"mt=c",
options::ModuleType::Esm => b"mt=e",
});

self.features.hash_for_runtime_transpiler(hasher);
}

Expand Down Expand Up @@ -1539,17 +1547,14 @@ impl<'a> Parser<'a> {
}
// This is a workaround for packages which have broken ESM checks
// If they never actually assign to exports.foo, only check for it
// and the package specifies type "module"
// and the package uses ESM syntax
// We should just say
// You're ESM and lying about it.
if p.options.module_type == options::ModuleType::Esm
|| p.has_es_module_syntax
{
if needs_decl_count == export_names_len {
force_esm = true;
break 'break_optimize;
}
// (A file whose module type is already ESM never binds
// `exports`, so it never reaches this block.)
if p.has_es_module_syntax && needs_decl_count == export_names_len {
force_esm = true;
break 'break_optimize;
}

if needs_decl_count > 0 || p.has_top_level_function_merged_with_var {
Expand Down
8 changes: 4 additions & 4 deletions src/js_parser/visit/visit_binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ impl BinaryExpressionVisitor {
match data_eql::<false, TYPESCRIPT, SCAN_ONLY>(&e_.left.data, &e_.right.data, p) {
Equality::RequireMainAndModule => {
p.ignore_usage_of_runtime_require();
p.ignore_usage(p.module_ref);
p.ignore_usage_of_require_main_module(&e_.left.data);
return p.value_for_import_meta_main(false, v.loc);
}
Equality::Equal => return p.new_expr(E::Boolean { value: true }, v.loc),
Expand Down Expand Up @@ -284,7 +284,7 @@ impl BinaryExpressionVisitor {
Op::Code::BinStrictEq => {
match data_eql::<true, TYPESCRIPT, SCAN_ONLY>(&e_.left.data, &e_.right.data, p) {
Equality::RequireMainAndModule => {
p.ignore_usage(p.module_ref);
p.ignore_usage_of_require_main_module(&e_.left.data);
p.ignore_usage_of_runtime_require();
return p.value_for_import_meta_main(false, v.loc);
}
Expand All @@ -307,7 +307,7 @@ impl BinaryExpressionVisitor {
Op::Code::BinLooseNe => {
match data_eql::<false, TYPESCRIPT, SCAN_ONLY>(&e_.left.data, &e_.right.data, p) {
Equality::RequireMainAndModule => {
p.ignore_usage(p.module_ref);
p.ignore_usage_of_require_main_module(&e_.left.data);
p.ignore_usage_of_runtime_require();
return p.value_for_import_meta_main(true, v.loc);
}
Expand All @@ -334,7 +334,7 @@ impl BinaryExpressionVisitor {
Op::Code::BinStrictNe => {
match data_eql::<true, TYPESCRIPT, SCAN_ONLY>(&e_.left.data, &e_.right.data, p) {
Equality::RequireMainAndModule => {
p.ignore_usage(p.module_ref);
p.ignore_usage_of_require_main_module(&e_.left.data);
p.ignore_usage_of_runtime_require();
return p.value_for_import_meta_main(true, v.loc);
}
Expand Down
5 changes: 4 additions & 1 deletion src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ bun_core::declare_scope!(cache, visible);
/// the cache-HIT path reinstates #33904 for them.
/// Version 34: An import that a plugin `onResolve` answers is printed as it is written.
/// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double.
const EXPECTED_VERSION: u32 = 35;
/// Version 36: The module type hint (extension, package.json "type") is part of the
/// features hash, and `.jsx`/`.tsx` loaded by a static import no longer take the hint
/// from package.json.
const EXPECTED_VERSION: u32 = 36;

/// Source files smaller than this are not written to / read from the on-disk
/// transpiler cache. Originally 50 KiB, which excluded almost every file in a
Expand Down
11 changes: 6 additions & 5 deletions src/jsc/RuntimeTranspilerStore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ impl RuntimeTranspilerStore {
path: &Fs::Path<'_>,
referrer: String,
loader: Loader,
module_type: ModuleType,
package_json: Option<&PackageJSON>,
module_loader: JSValue,
) -> *mut c_void {
Expand Down Expand Up @@ -365,6 +366,7 @@ impl RuntimeTranspilerStore {
ticket: None,
log: bun_ast::Log::init(),
loader,
module_type,
promise: StrongOptional::create(JSValue::from_cell(promise), global_object),
module_loader: if module_loader.is_empty() {
StrongOptional::empty()
Expand Down Expand Up @@ -416,6 +418,9 @@ pub struct TranspilerJob {
pub(crate) non_threadsafe_input_specifier: bun_core::String,
pub(crate) non_threadsafe_referrer: bun_core::String,
pub(crate) loader: Loader,
/// The ESM-or-CommonJS hint from the extension or package.json `"type"`,
/// decided by the caller with the same rule as the synchronous loader.
pub(crate) module_type: ModuleType,
pub(crate) promise: StrongOptional,
/// The `JSModuleLoader` that is fetching, when it is not the global object's (a
/// `Bun.ModuleGraph`'s): handed back with the result. Empty otherwise.
Expand Down Expand Up @@ -805,11 +810,7 @@ impl TranspilerJob {
&& strings::eql_long(vm_main, path.text, false)
});

let module_type: ModuleType = match this_tag {
ResolvedSourceTag::PackageJsonTypeCommonjs => ModuleType::Cjs,
ResolvedSourceTag::PackageJsonTypeModule => ModuleType::Esm,
_ => ModuleType::Unknown,
};
let module_type = self.module_type;
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

let mut parse_options = ParseOptions {
arena: &arena,
Expand Down
5 changes: 4 additions & 1 deletion src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1552,7 +1552,10 @@ impl<'a> Resolver<'a> {
primary: Fs::Path::init_with_namespace(import_path, b"node"),
secondary: None,
},
module_type: options::ModuleType::Esm,
// Embedded code has no extension or package.json.
// Its contents decide: the react-refresh fallback
// is a CommonJS bundle.
module_type: options::ModuleType::Unknown,
primary_side_effects_data: SideEffects::NoSideEffectsPureData,
flags: ResultFlags::default(),
..Default::default()
Expand Down
Loading