diff --git a/src/ast.zig b/src/ast.zig index 9cafcfa7fe..2513498aab 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -345,6 +345,18 @@ pub const ExportsKind = enum { pub fn jsonStringify(self: @This(), writer: anytype) !void { return try writer.write(@tagName(self)); } + + pub fn toModuleType(self: @This()) bun.options.ModuleType { + return switch (self) { + .none => .unknown, + .cjs => .cjs, + + .esm_with_dynamic_fallback, + .esm_with_dynamic_fallback_from_cjs, + .esm, + => .esm, + }; + } }; pub const DeclaredSymbol = struct { diff --git a/src/ast/maybe.zig b/src/ast/maybe.zig index 1c461b3099..8942a02432 100644 --- a/src/ast/maybe.zig +++ b/src/ast/maybe.zig @@ -411,7 +411,7 @@ pub fn AstMaybe( } // Inline import.meta properties for Bake - if (p.options.framework != null) { + if (p.options.framework != null or (p.options.bundle and p.options.output_format == .cjs)) { if (strings.eqlComptime(name, "dir") or strings.eqlComptime(name, "dirname")) { // Inline import.meta.dir return p.newExpr(E.String.init(p.source.path.name.dir), name_loc); diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 59daa3f21b..8b5e8aba8f 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -1324,6 +1324,7 @@ pub const LinkerContext = struct { .minify_whitespace = c.options.minify_whitespace, .minify_syntax = c.options.minify_syntax, + .input_module_type = ast.exports_kind.toModuleType(), .module_type = c.options.output_format, .print_dce_annotations = c.options.emit_dce_annotations, .has_run_symbol_renamer = true, diff --git a/src/js_printer.zig b/src/js_printer.zig index b02bf0d7f6..ebe08d3d59 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -414,6 +414,9 @@ pub const Options = struct { require_or_import_meta_for_source_callback: RequireOrImportMeta.Callback = .{}, + /// The module type of the importing file (after linking), used to determine interop helper behavior. + /// Controls whether __toESM uses Node ESM semantics (isNodeMode=1 for .esm) or respects __esModule markers. + input_module_type: options.ModuleType = .unknown, module_type: options.Format = .esm, // /// Used for cross-module inlining of import items when bundling @@ -1711,7 +1714,7 @@ fn NewPrinter( } if (wrap_with_to_esm) { - if (module_type.isESM()) { + if (p.options.input_module_type == .esm) { p.print(","); p.printSpace(); p.print("1"); diff --git a/test/bundler/bundler_cjs.test.ts b/test/bundler/bundler_cjs.test.ts new file mode 100644 index 0000000000..1579d1dad4 --- /dev/null +++ b/test/bundler/bundler_cjs.test.ts @@ -0,0 +1,494 @@ +import { describe } from "bun:test"; +import { itBundled } from "./expectBundled"; + +// Tests for CommonJS <> ESM interop, specifically the __toESM helper behavior. +// +// The key insight from the code change: +// - `input_module_type` is set based on the AST's exports_kind (whether the importing +// file uses ESM syntax like import/export or CJS syntax like require/module.exports) +// - When a file uses ESM syntax (import/export), isNodeMode = 1 +// - When a file uses CJS syntax (require), __toESM is not used at all +// +// This means: +// - Any file using `import` will always get isNodeMode=1, which IGNORES __esModule +// and always wraps the CJS module as the default export +// - This matches Node.js ESM behavior where importing CJS from .mjs always wraps +// the entire exports object as the default +// +// The __esModule marker is only respected in non-bundled scenarios or when using +// actual CommonJS require() syntax. + +describe("bundler", () => { + // ============================================================================ + // Tests with ESM syntax (import statements) + // These all use isNodeMode=1, which IGNORES __esModule + // ============================================================================ + + // Test 1: import with __esModule marker - IGNORED + itBundled("cjs/__toESM_import_syntax_with_esModule", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.__esModule = true; + exports.default = { value: 'default export' }; + exports.named = 'named export'; + `, + }, + run: { + // With import syntax, isNodeMode=1, so __esModule is IGNORED + // The entire CJS exports object is wrapped as default + stdout: '{"__esModule":true,"default":{"value":"default export"},"named":"named export"}', + }, + }); + + // Test 2: import WITHOUT __esModule marker + itBundled("cjs/__toESM_import_syntax_without_esModule", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.foo = 'foo'; + exports.bar = 'bar'; + `, + }, + run: { + // Same behavior - entire module wrapped as default + stdout: '{"foo":"foo","bar":"bar"}', + }, + }); + + // Test 3: import with module.exports = function + itBundled("cjs/__toESM_import_syntax_function", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(lib.name + ':' + lib()); + `, + "/lib.cjs": /* js */ ` + module.exports = function myFunc() { return 'result'; }; + `, + }, + run: { + stdout: "myFunc:result", + }, + }); + + // Test 4: import with module.exports = primitive + itBundled("cjs/__toESM_import_syntax_primitive", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(lib); + `, + "/lib.cjs": /* js */ ` + module.exports = 42; + `, + }, + run: { + stdout: "42", + }, + }); + + // Test 5: import with named + default + itBundled("cjs/__toESM_import_syntax_named_and_default", { + files: { + "/entry.js": /* js */ ` + import lib, { foo } from './lib.cjs'; + console.log(JSON.stringify({ default: lib, named: foo })); + `, + "/lib.cjs": /* js */ ` + exports.foo = 'foo value'; + exports.bar = 'bar value'; + `, + }, + run: { + stdout: '{"default":{"foo":"foo value","bar":"bar value"},"named":"foo value"}', + }, + }); + + // Test 6: Namespace import (import *) + itBundled("cjs/__toESM_import_syntax_namespace", { + files: { + "/entry.js": /* js */ ` + import * as lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.foo = 'foo'; + exports.bar = 'bar'; + `, + }, + run: { + // Namespace import only gets the CJS exports as-is, no default wrapper + stdout: '{"foo":"foo","bar":"bar"}', + }, + }); + + // ============================================================================ + // Tests with different targets + // Target doesn't affect isNodeMode - it's based on syntax + // ============================================================================ + + // Test 7: target=node + itBundled("cjs/__toESM_target_node", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.x = 1; + exports.y = 2; + `, + }, + target: "node", + run: { + stdout: '{"x":1,"y":2}', + }, + }); + + // Test 8: target=browser + itBundled("cjs/__toESM_target_browser", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.x = 1; + exports.y = 2; + `, + }, + target: "browser", + run: { + stdout: '{"x":1,"y":2}', + }, + }); + + // Test 9: target=bun + itBundled("cjs/__toESM_target_bun", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.x = 1; + exports.y = 2; + `, + }, + target: "bun", + run: { + stdout: '{"x":1,"y":2}', + }, + }); + + // ============================================================================ + // Tests with different output formats + // Output format doesn't affect isNodeMode either + // ============================================================================ + + // Test 10: format=esm + itBundled("cjs/__toESM_format_esm", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.__esModule = true; + exports.default = 'the default'; + exports.other = 'other'; + `, + }, + format: "esm", + run: { + // __esModule ignored because we're using import syntax + stdout: '{"__esModule":true,"default":"the default","other":"other"}', + }, + }); + + // Test 11: format=cjs with import syntax + itBundled("cjs/__toESM_format_cjs_with_import", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.__esModule = true; + exports.default = 'the default'; + exports.other = 'other'; + `, + }, + format: "cjs", + run: { + // Still ignores __esModule because entry uses import syntax + stdout: '{"__esModule":true,"default":"the default","other":"other"}', + }, + }); + + // ============================================================================ + // Tests for .mjs files re-exporting from .cjs + // ============================================================================ + + // Test 12: .mjs re-exporting default from CJS + itBundled("cjs/__toESM_mjs_reexport", { + files: { + "/entry.js": /* js */ ` + import lib from './wrapper.mjs'; + console.log(JSON.stringify(lib)); + `, + "/wrapper.mjs": /* js */ ` + export { default } from './lib.cjs'; + `, + "/lib.cjs": /* js */ ` + exports.foo = 'foo'; + exports.bar = 'bar'; + `, + }, + run: { + stdout: '{"foo":"foo","bar":"bar"}', + }, + }); + + // Test 13: .mjs re-exporting with __esModule (still ignored) + itBundled("cjs/__toESM_mjs_reexport_with_esModule", { + files: { + "/entry.js": /* js */ ` + import lib from './wrapper.mjs'; + console.log(JSON.stringify(lib)); + `, + "/wrapper.mjs": /* js */ ` + export { default } from './lib.cjs'; + `, + "/lib.cjs": /* js */ ` + exports.__esModule = true; + exports.default = { value: 'from cjs' }; + exports.other = 'other'; + `, + }, + run: { + // __esModule ignored - entire module wrapped as default + stdout: '{"__esModule":true,"default":{"value":"from cjs"},"other":"other"}', + }, + }); + + // Test 14: Deep re-export chain + itBundled("cjs/__toESM_deep_reexport_chain", { + files: { + "/entry.js": /* js */ ` + import lib from './layer1.mjs'; + console.log(JSON.stringify(lib)); + `, + "/layer1.mjs": /* js */ ` + export { default } from './layer2.mjs'; + `, + "/layer2.mjs": /* js */ ` + export { default } from './lib.cjs'; + `, + "/lib.cjs": /* js */ ` + exports.deep = 'value'; + `, + }, + run: { + stdout: '{"deep":"value"}', + }, + }); + + // Test 15: Re-export with rename + itBundled("cjs/__toESM_reexport_with_rename", { + files: { + "/entry.js": /* js */ ` + import { myDefault } from './wrapper.mjs'; + console.log(JSON.stringify(myDefault)); + `, + "/wrapper.mjs": /* js */ ` + export { default as myDefault } from './lib.cjs'; + `, + "/lib.cjs": /* js */ ` + exports.x = 1; + `, + }, + run: { + stdout: '{"x":1}', + }, + }); + + // ============================================================================ + // Edge cases + // ============================================================================ + + // Test 16: CJS with a property named "default" but no __esModule + itBundled("cjs/__toESM_default_prop_no_esModule", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.default = 'I am a prop named default'; + exports.other = 'other'; + `, + }, + run: { + // Entire module wrapped, including the .default property + stdout: '{"default":"I am a prop named default","other":"other"}', + }, + }); + + // Test 17: Mixed import styles + itBundled("cjs/__toESM_mixed_import_styles", { + files: { + "/entry.js": /* js */ ` + import defaultExport from './lib.cjs'; + import { foo } from './lib.cjs'; + import * as namespace from './lib.cjs'; + console.log(JSON.stringify({ + default: defaultExport, + named: foo, + namespace: namespace + })); + `, + "/lib.cjs": /* js */ ` + exports.foo = 'foo'; + exports.bar = 'bar'; + `, + }, + run: { + stdout: + '{"default":{"foo":"foo","bar":"bar"},"named":"foo","namespace":{"default":{"foo":"foo","bar":"bar"},"foo":"foo","bar":"bar"}}', + }, + }); + + // Test 18: __esModule with non-true value + itBundled("cjs/__toESM_esModule_non_true", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.__esModule = 'truthy'; + exports.default = { value: 'default' }; + exports.other = 'other'; + `, + }, + run: { + // Even if __esModule were respected, only `true` would work + // But it's ignored anyway due to import syntax + stdout: '{"__esModule":"truthy","default":{"value":"default"},"other":"other"}', + }, + }); + + // Test 19: __esModule = false + itBundled("cjs/__toESM_esModule_false", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.__esModule = false; + exports.default = { value: 'ignored' }; + exports.foo = 'foo'; + `, + }, + run: { + // Entire module wrapped as default (since we use import syntax) + stdout: '{"__esModule":false,"default":{"value":"ignored"},"foo":"foo"}', + }, + }); + + // Test 20: module.exports with __esModule + itBundled("cjs/__toESM_module_exports_with_esModule", { + files: { + "/entry.js": /* js */ ` + import lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + module.exports = { + __esModule: true, + default: { value: 'nested' }, + other: 'prop' + }; + `, + }, + run: { + // __esModule is in the object but ignored due to import syntax + stdout: '{"__esModule":true,"default":{"value":"nested"},"other":"prop"}', + }, + }); + + // Test 21: Input=ESM, output=CJS, importing CJS with __esModule and named imports + // This test covers the specific fix for printing __toESM when output format is CJS + // and input uses ESM syntax to import both default and named exports from CJS with __esModule + itBundled("cjs/__toESM_input_esm_output_cjs_wrapper_print", { + files: { + "/entry.js": /* js */ ` + import lib, { named } from "./lib.cjs"; + console.log(JSON.stringify({ default: lib, named })); + `, + "/lib.cjs": /* js */ ` + exports.__esModule = true; + exports.default = { value: "default" }; + exports.named = "named export"; + `, + }, + format: "cjs", + run: { + // With the fix: ignores __esModule, wraps entire module as default + // So default gets the whole exports object, named gets the named property + stdout: + '{"default":{"__esModule":true,"default":{"value":"default"},"named":"named export"},"named":"named export"}', + }, + }); + + // Test 22: Star import with __esModule + itBundled("cjs/__toESM_star_import_with_esModule", { + files: { + "/entry.js": /* js */ ` + import * as lib from './lib.cjs'; + console.log(JSON.stringify(lib)); + `, + "/lib.cjs": /* js */ ` + exports.__esModule = true; + exports.default = 'default'; + exports.named = 'named'; + `, + }, + run: { + // Star import gets the exports as-is, no wrapper + stdout: '{"named":"named","default":"default","__esModule":true}', + }, + }); + + // Test 23: Practical example - importing lodash-like library + itBundled("cjs/__toESM_practical_lodash_style", { + files: { + "/entry.js": /* js */ ` + import _ from './lodash.cjs'; + import { map } from './lodash.cjs'; + console.log(JSON.stringify({ + hasMap: typeof _.map === 'function', + same: _.map === map + })); + `, + "/lodash.cjs": /* js */ ` + exports.map = function(arr, fn) { return arr.map(fn); }; + exports.filter = function(arr, fn) { return arr.filter(fn); }; + `, + }, + run: { + // Default gets entire module, named import gets specific function + // Both reference the same function + stdout: '{"hasMap":true,"same":true}', + }, + }); +}); diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts index 55fa25fb20..827081a0e8 100644 --- a/test/bundler/bundler_npm.test.ts +++ b/test/bundler/bundler_npm.test.ts @@ -67,7 +67,7 @@ describe("bundler", () => { }, }, expectExactFilesize: { - "out/entry.js": 221726, + "out/entry.js": 221720, }, run: { stdout: "
This is an example.
",