Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a4306c6
module loader: evaluate ESM-imported CommonJS at its post-order slot
robobun Jul 26, 2026
41e8e9a
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
b7a8405
extract record_runtime_commonjs_export_name; trim comments
robobun Jul 26, 2026
d430450
address review: deref commonjs_export_names in ~SourceProvider; read …
robobun Jul 26, 2026
4c06fc0
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
669cb78
scope VMInquiry PropertySlot; keep runtime classifier off the bundler…
robobun Jul 26, 2026
9314f7b
detect module.exports = {X, Y}; fall back when reassigned; thread exp…
robobun Jul 26, 2026
132c838
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
1eb1590
match cjs-module-lexer's lexical exports scan; gate value_for_this co…
robobun Jul 26, 2026
4d7b541
avoid lint-flagged literal in mock/6874 fixtures
robobun Jul 26, 2026
a90c4e1
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
a94db37
address review nits: spell out exception check in readExport lambda; …
robobun Jul 26, 2026
f4a274a
follow __exportStar/Object.keys re-exports; fall back to eager eval f…
robobun Jul 27, 2026
f46e183
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 27, 2026
b1570d3
trim inline comments
robobun Jul 27, 2026
5448888
verify the forEach callback writes to exports before recording a re-e…
robobun Jul 27, 2026
08ada58
drop export*from; __exportStar/Object.keys().forEach deopt to eager i…
robobun Jul 27, 2026
3cb8171
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 27, 2026
800142d
gate the bare-exports-assign setter on the unwrap path
robobun Jul 27, 2026
ff50b38
address review nits: lexical module.exports match; own-only default p…
robobun Jul 27, 2026
fcca760
apply review: align gate predicate; assert putResult; strengthen two …
robobun Jul 27, 2026
ff14668
gate the bare-exports-assign setter on !commonjs_named_exports_deopti…
robobun Jul 27, 2026
837230c
deref commonjs_export_names in evaluateWithPotentiallyOverriddenCompile
robobun Jul 27, 2026
0cc7e3f
clarify why zero-names keeps the eager path (lodash UMD-via-alias)
robobun Jul 27, 2026
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
79 changes: 78 additions & 1 deletion src/js_parser/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use bun_core::feature_flags as FeatureFlags;
use crate::p::P;
use crate::parser::{self as js_parser, IdentifierOpts, RelocateVars, RelocateVarsMode};
use bun_ast::ast_result::CommonJSNamedExport;
use bun_ast::{self as js_ast, Binding, E, Expr, Flags, G, LocRef, S};
use bun_ast::{self as js_ast, Binding, E, Expr, Flags, G, LocRef, Ref, S};

// ── local EString shims ────────────────────────────────────────────────────
// E.rs currently carries two `impl EString` blocks (live + round-C draft) with
Expand Down Expand Up @@ -430,6 +430,31 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
p.has_commonjs_export_names = true;
}
}
} else if p.options.features.commonjs_at_runtime
&& !p.is_control_flow_dead
&& id.ref_.eql(p.exports_ref)
&& identifier_opts.assign_target() != js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
{
// Runtime (non-bundler) path: record `exports.<name>` so an ESM
// importer can resolve named bindings via static analysis. This
// mirrors Node's cjs-module-lexer; the expression itself is left
// untouched.
Comment thread
robobun marked this conversation as resolved.
Outdated
p.has_commonjs_export_names = true;
if !p.commonjs_named_exports.contains(name) {
p.commonjs_named_exports
.put(
name,
CommonJSNamedExport {
loc_ref: LocRef {
loc: name_loc,
ref_: Ref::NONE,
},
needs_decl: false,
},
)
.expect("unreachable");
}
}

// Handle references to namespaces or namespace members
Expand Down Expand Up @@ -580,6 +605,35 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
// EDot and EIndex are handled with structurally identical arms.
js_ast::ExprData::EDot(data) => {
if p.options.features.commonjs_at_runtime
&& !p.should_unwrap_common_js_to_esm()
&& !p.is_control_flow_dead
&& identifier_opts.assign_target() != js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
&& data.name == b"exports"
&& matches!(data.target.data, js_ast::ExprData::EIdentifier(inner) if inner.ref_.eql(p.module_ref))
{
// Runtime: record `module.exports.<name>` for the ESM-imports-CJS
// static export table. At runtime `commonjs_named_exports_deoptimized`
// starts true, so `module.exports` is not rewritten to
// `ESpecial::ModuleExports` and this is where we see the nested EDot.
Comment thread
robobun marked this conversation as resolved.
Outdated
p.has_commonjs_export_names = true;
if !p.commonjs_named_exports.contains(name) {
p.commonjs_named_exports
.put(
name,
CommonJSNamedExport {
loc_ref: LocRef {
loc: name_loc,
ref_: Ref::NONE,
},
needs_decl: false,
},
)
.expect("unreachable");
}
}
Comment thread
robobun marked this conversation as resolved.

Comment thread
robobun marked this conversation as resolved.
if matches!(p.ts_namespace.expr, js_ast::ExprData::EDot(ns_data) if data.as_ptr() == ns_data.as_ptr())
&& identifier_opts.assign_target() == js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
Expand Down Expand Up @@ -661,6 +715,29 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
p.has_commonjs_export_names = true;
}
}
} else if p.options.features.commonjs_at_runtime
&& !p.is_control_flow_dead
&& identifier_opts.assign_target() != js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
{
// Runtime (non-bundler) path: record `module.exports.<name>` for the
// ESM-imports-CJS static export table (see the `exports.<name>` arm
// above). No rewrite.
Comment thread
robobun marked this conversation as resolved.
Outdated
p.has_commonjs_export_names = true;
if !p.commonjs_named_exports.contains(name) {
p.commonjs_named_exports
.put(
name,
CommonJSNamedExport {
loc_ref: LocRef {
loc: name_loc,
ref_: Ref::NONE,
},
needs_decl: false,
},
)
.expect("unreachable");
}
}
}
E::Special::HotEnabled | E::Special::HotDisabled => {
Expand Down
3 changes: 3 additions & 0 deletions src/js_parser/parse/parse_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1507,6 +1507,9 @@ impl<'a> Parser<'a> {

if p.is_deoptimized_commonjs() {
exports_kind = js_ast::ExportsKind::Cjs;
if p.options.features.commonjs_at_runtime {
wrap_mode = WrapMode::BunCommonjs;
}
Comment thread
robobun marked this conversation as resolved.
Outdated
} else if p.esm_export_keyword.len > 0 || p.top_level_await_keyword.len > 0 {
exports_kind = js_ast::ExportsKind::Esm;
} else if uses_exports_ref || uses_module_ref || p.has_top_level_return || p.has_with_scope
Expand Down
61 changes: 61 additions & 0 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1983,6 +1983,67 @@
p.visit_expr(arg);
}

// Runtime (non-bundler) CJS static-export-name scan: record names assigned via
// `Object.defineProperty(exports|module.exports, "<name>", ...)` so an ESM
// importer can resolve them by name. This mirrors Node's cjs-module-lexer
// without the cross-file re-export following.
Comment thread
robobun marked this conversation as resolved.
Outdated
if p.options.features.commonjs_at_runtime
&& !p.options.features.unwrap_commonjs_to_esm
&& !p.is_control_flow_dead
{
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let exports_ref = p.exports_ref;
let module_ref = p.module_ref;
let is_exports_expr = |ex: &Expr| -> bool {
match &ex.data {
Data::EIdentifier(id) => id.ref_.eql(exports_ref),
Data::ESpecial(E::Special::ModuleExports) => true,
Data::EDot(d) => {
d.name == b"exports"
&& matches!(d.target.data, Data::EIdentifier(inner) if inner.ref_.eql(module_ref))
}
_ => false,
Comment thread
robobun marked this conversation as resolved.
Outdated
}
};
let is_object_define_property = matches!(
&e_.target.data,
Data::EDot(dot)
if dot.name == b"defineProperty"
&& matches!(
&dot.target.data,
Data::EIdentifier(id)
if p.symbols.as_slice()[id.ref_.inner_index() as usize].kind
== bun_ast::symbol::Kind::Unbound
&& p.symbols.as_slice()[id.ref_.inner_index() as usize]
.original_name
.slice()
== b"Object"
)
);
let args = e_.args.slice();
if is_object_define_property && args.len() >= 2 && is_exports_expr(&args[0]) {
if let Data::EString(name_str) = &args[1].data {
if !name_str.is_utf16 {
let name: &[u8] = &name_str.data;
p.has_commonjs_export_names = true;
if !name.is_empty() && !p.commonjs_named_exports.contains(name) {

Check warning on line 2028 in src/js_parser/visit/visit_expr.rs

View check run for this annotation

Claude / Claude Code Review

Embedded NUL in Object.defineProperty export name corrupts the NUL-joined encoding

The `Object.defineProperty(exports, "<name>", ...)` recorder accepts string literals containing embedded NUL bytes (e.g. `"a\0b"`), which then collide with the NUL-joined encoding in `join_commonjs_export_names` — `assignStaticExportNames` splits the single name into two, and the wrapper declares `export{$e0 as "a",$e1 as "b"}` with both bindings reading `undefined`. Add `&& !name.contains(&0)` to the guard so such names are skipped (Node's cjs-module-lexer would not detect them anyway).
Comment thread
robobun marked this conversation as resolved.
Outdated
p.commonjs_named_exports
.put(
name,
bun_ast::ast_result::CommonJSNamedExport {
loc_ref: bun_ast::LocRef {
loc: args[1].loc,
ref_: bun_ast::Ref::NONE,
},
needs_decl: false,
},
)
.expect("unreachable");
}
}
}
}
}

// Restore saved state.
p.options.ignore_dce_annotations = old_ce;
p.should_fold_typescript_constant_expressions =
Expand Down
6 changes: 6 additions & 0 deletions src/jsc/AsyncModule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,10 @@ impl AsyncModule {
// can `mem::take` instead of cloning.
let is_commonjs_module = self.parse_result.ast.has_commonjs_export_names
|| self.parse_result.ast.exports_kind == bun_ast::ExportsKind::Cjs;
let commonjs_export_names = crate::resolved_source::join_commonjs_export_names(
is_commonjs_module,
&self.parse_result.ast,
);
let input_fd = self.parse_result.input_fd;
let arena = *self.parse_result.ast.parts.allocator();
let parse_result = core::mem::replace(&mut self.parse_result, ParseResult::empty(arena));
Expand Down Expand Up @@ -1370,6 +1374,7 @@ impl AsyncModule {
}

resolved_source.is_commonjs_module = is_commonjs_module;
resolved_source.commonjs_export_names = BunString::clone_utf8(&commonjs_export_names);

return Ok(resolved_source);
}
Expand All @@ -1379,6 +1384,7 @@ impl AsyncModule {
specifier: BunString::init(specifier),
source_url: BunString::init(path.text),
is_commonjs_module,
commonjs_export_names: BunString::clone_utf8(&commonjs_export_names),
..Default::default()
})
}
Expand Down
39 changes: 39 additions & 0 deletions src/jsc/ResolvedSource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ pub struct ResolvedSource {
/// was used at build time. If empty, the origin is derived from source_url.
/// This is converted to a file:// URL on the C++ side.
pub bytecode_origin_path: BunString,
/// Statically detected CommonJS export names (`exports.x = ` / `module.exports.x = `),
/// NUL-joined. Only populated when `is_commonjs_module` is true; consumed and deref'd
/// by `createCommonJSModule` in C++. Left empty on paths that cannot supply the
/// names (transpiler-cache hits), which fall back to fetch-time evaluation.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub commonjs_export_names: BunString,
}

impl Default for ResolvedSource {
Expand All @@ -70,10 +75,43 @@ impl Default for ResolvedSource {
bytecode_cache_size: 0,
module_info: core::ptr::null_mut(),
bytecode_origin_path: BunString::empty(),
commonjs_export_names: BunString::empty(),
}
}
}

/// Join the transpiler's statically detected CommonJS export names into the
/// NUL-separated encoding that `createCommonJSModule` splits on the C++ side.
/// Kept as an owned `Vec<u8>` between the arena-backed `parse_result.ast`
/// read and the eventual `ResolvedSource` construction so fallible printing
/// in between cannot strand a `BunString` refcount; convert with
/// `BunString::clone_utf8` at the use site.
///
/// An empty return means "do not defer" (fall back to fetch-time evaluation so
/// runtime-enumerated named exports keep working). A single NUL byte means
/// "defer, zero named exports".
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn join_commonjs_export_names(
is_commonjs_module: bool,
ast: &bun_ast::ast_result::Ast,
) -> Vec<u8> {
if !is_commonjs_module {
return Vec::new();
}
let keys = ast.commonjs_named_exports.keys();
if keys.is_empty() {
return vec![0];
}
Comment thread
robobun marked this conversation as resolved.
let cap: usize = keys.iter().map(|k| k.as_ref().len() + 1).sum();
let mut joined: Vec<u8> = Vec::with_capacity(cap);
for (i, key) in keys.iter().enumerate() {
if i > 0 {
joined.push(0);
}
joined.extend_from_slice(key.as_ref());
}
joined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ──────────────────────────────────────────────────────────────────────────
// RAII owner for the +1 `BunString` refs inside a `ResolvedSource`.
//
Expand Down Expand Up @@ -142,5 +180,6 @@ impl Drop for OwnedResolvedSource {
self.0.specifier.deref();
self.0.source_url.deref();
self.0.bytecode_origin_path.deref();
self.0.commonjs_export_names.deref();
}
}
5 changes: 5 additions & 0 deletions src/jsc/RuntimeTranspilerStore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,10 @@ impl TranspilerJob {

let is_commonjs_module = parse_result.ast.has_commonjs_export_names
|| parse_result.ast.exports_kind == ExportsKind::Cjs;
let commonjs_export_names = crate::resolved_source::join_commonjs_export_names(
is_commonjs_module,
&parse_result.ast,
);
let mut module_info: Option<Box<analyze_transpiled_module::ModuleInfo>> =
if use_isolation_source_provider_cache
&& !is_commonjs_module
Expand Down Expand Up @@ -1181,6 +1185,7 @@ impl TranspilerJob {
bun_core::heap::into_raw(mi.into_deserialized()).cast()
})
.unwrap_or(ptr::null_mut()),
commonjs_export_names: String::clone_utf8(&commonjs_export_names),
tag: this_tag,
..Default::default()
});
Comment thread
robobun marked this conversation as resolved.
Expand Down
Loading
Loading