diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index c712a54b64ef..0e0ec9fde134 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2475,31 +2475,17 @@ pub(crate) mod __gated_printer { let module_type = self.options.module_type; if IS_BUN_PLATFORM { - // "bun" is not a real module. It's just globalThis.Bun. - // - // transform from: - // const foo = await import("bun") - // const bar = require("bun") - // - // transform to: - // const foo = await Promise.resolve(globalThis.Bun) - // const bar = globalThis.Bun - // - if record.tag == ImportRecordTag::Bun { - if record.kind == ImportKind::Dynamic { - self.print(b"Promise.resolve(globalThis.Bun)"); - if wrap { - self.print(b")"); - } - return; - } else if record.kind == ImportKind::Require || record.kind == ImportKind::Stmt - { - self.print(b"globalThis.Bun"); - if wrap { - self.print(b")"); - } - return; + // import("bun") is excluded: the loader returns the module namespace for it, + // and a specifier that escapes const inlining reaches the loader anyway, so + // the literal form has to resolve to that same namespace. + if record.tag == ImportRecordTag::Bun + && (record.kind == ImportKind::Require || record.kind == ImportKind::Stmt) + { + self.print(b"globalThis.Bun"); + if wrap { + self.print(b")"); } + return; } } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 3373c1402e99..c1547e752700 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,7 +51,9 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -const EXPECTED_VERSION: u32 = 25; +/// Version 26: a literal `import("bun")` is printed as a dynamic import instead of +/// `Promise.resolve(globalThis.Bun)`. +const EXPECTED_VERSION: u32 = 26; /// 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 diff --git a/test/bundler/bundler_bun.test.ts b/test/bundler/bundler_bun.test.ts index 674da11059bb..c7c82e4762a3 100644 --- a/test/bundler/bundler_bun.test.ts +++ b/test/bundler/bundler_bun.test.ts @@ -3,6 +3,41 @@ import { describe, expect } from "bun:test"; import { itBundled } from "./expectBundled"; describe("bundler", () => { + // import("bun") is left for the runtime, which resolves it to the "bun" module + // namespace (default = Bun plus a named export per property), the same thing a + // non-literal specifier gets. require("bun") is still inlined to globalThis.Bun, + // which is also what the runtime returns for a non-literal require(). + itBundled("bun/dynamic-import-bun-is-module-namespace", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + const ns = await import("bun"); + console.log(Object.prototype.toString.call(ns), ns.default === Bun, ns.serve === Bun.serve); + console.log(require("bun") === Bun); + `, + }, + run: { stdout: "[object Module] true true\ntrue" }, + onAfterBundle(api) { + api.expectFile("out.js").toContain('import("bun")'); + api.expectFile("out.js").not.toContain("Promise.resolve(globalThis.Bun)"); + }, + }); + // --minify-syntax inlines the const, so the import() reaches the printer as a literal. + itBundled("bun/dynamic-import-bun-inlined-const", { + target: "bun", + minifySyntax: true, + files: { + "/entry.ts": /* js */ ` + const specifier = "bun"; + const ns = await import(specifier); + console.log(Object.prototype.toString.call(ns), ns.default === Bun, ns.serve === Bun.serve); + `, + }, + run: { stdout: "[object Module] true true" }, + onAfterBundle(api) { + api.expectFile("out.js").toContain('import("bun")'); + }, + }); // https://github.com/oven-sh/bun/issues/18899 itBundled("bun/import-bun-format-cjs", { target: "bun", diff --git a/test/js/bun/resolve/builtin-esm-lazy-exports.test.ts b/test/js/bun/resolve/builtin-esm-lazy-exports.test.ts index d82c66e201f6..75d71c9347bc 100644 --- a/test/js/bun/resolve/builtin-esm-lazy-exports.test.ts +++ b/test/js/bun/resolve/builtin-esm-lazy-exports.test.ts @@ -43,9 +43,9 @@ const helper = ` // static entries without constructing them; for...in constructs all of them, so the entries below avoid it.) The // watched names are a sample of those entries plus the plain function (`write`, `createRequire`) an entry imports. // -// Note that a literal `import ... from "bun"` (and `import("bun")` / `require("bun")`) is rewritten by the -// transpiler into a read of globalThis.Bun and never loads the module; the module is what `export ... from "bun"` -// and a non-literal import() specifier go through. Imports of node:process and node:module always load the module. +// Note that a static `import ... from "bun"` and `require("bun")` are rewritten by the transpiler into a read of +// globalThis.Bun and never load the module; the module is what `export ... from "bun"` and import() go through. +// Imports of node:process and node:module always load the module. const WATCHED = ["$", "CryptoHasher", "Glob", "S3Client", "SQL", "TOML", "Transpiler", "secrets", "write"] as const; const PROCESS_WATCHED = ["allowedNodeEnvironmentFlags", "config", "release", "stderr", "stdin", "stdout", "versions"]; const MODULE_WATCHED = [ @@ -74,9 +74,6 @@ const nativeHelper = ` export function print(result) { console.log(JSON.stringify(result)); } - // import(specifier) with this really loads the module; a literal (or a const the transpiler can inline) would be - // rewritten to globalThis.Bun instead. - export const specifier = "bun"; `; const bunReexport = ` @@ -329,8 +326,8 @@ test.concurrent('"bun": re-exports construct the properties that get bound, not test.concurrent('"bun": import() namespace has the same export list, and every export is the Bun.* value', async () => { const result = await runEntry(` - import { constructed, print, specifier } from "./native-helper.mjs"; - const ns = await import(specifier); + import { constructed, print } from "./native-helper.mjs"; + const ns = await import("bun"); const afterImport = constructed(); // Neither [[OwnPropertyKeys]] of the namespace nor Object.keys of the Bun object reads any of the properties. const exportNames = Reflect.ownKeys(ns).filter(key => typeof key === "string").sort(); diff --git a/test/js/bun/resolve/import-meta.test.js b/test/js/bun/resolve/import-meta.test.js index b2a3cc54b06b..30c66bb6efc4 100644 --- a/test/js/bun/resolve/import-meta.test.js +++ b/test/js/bun/resolve/import-meta.test.js @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { isModuleResolveFilenameSlowPathEnabled } from "bun:internal-for-testing"; -import { expect, it, mock } from "bun:test"; -import { bunEnv, bunExe, ospath } from "harness"; +import { describe, expect, it, mock } from "bun:test"; +import { bunEnv, bunExe, ospath, tempDir } from "harness"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import Module from "node:module"; import { tmpdir } from "node:os"; @@ -224,8 +224,43 @@ it('require("bun") works', () => { expect(require("bun")).toBe(Bun); }); -it('import("bun") works', async () => { - expect(await import("bun")).toBe(Bun); +it('import("bun") returns the "bun" module namespace', async () => { + const ns = await import("bun"); + expect(ns.default).toBe(Bun); + expect(ns.serve).toBe(Bun.serve); + // Same module record as a specifier the transpiler cannot see through. + expect(ns).toBe(await import(eval("'bun'"))); +}); + +// Whether `import(specifier)` reaches the printer as a literal depends on const +// inlining, which only applies to a const declared before any other statement in +// its scope. What comes back must not depend on that. +describe.concurrent('import() of "bun" resolves to the same thing however the specifier is written', () => { + it.each([ + ["string literal", `const ns = await import("bun");`], + ["const declared first (inlined)", `const specifier = "bun"; const ns = await import(specifier);`], + [ + "const declared after an import (not inlined)", + `import "./empty.mjs"; const specifier = "bun"; const ns = await import(specifier);`, + ], + ["let", `let specifier = "bun"; const ns = await import(specifier);`], + ])("%s", async (_, source) => { + using dir = tempDir("import-bun", { + "empty.mjs": "", + "entry.mjs": `${source}\nconsole.log(Object.prototype.toString.call(ns), ns.default === Bun, ns.serve === Bun.serve);\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("[object Module] true true\n"); + expect(exitCode).toBe(0); + }); }); it("require.resolve with empty options object", () => { diff --git a/test/js/bun/test/mock/mock-module.test.ts b/test/js/bun/test/mock/mock-module.test.ts index ac7f43daed57..a5967e27702a 100644 --- a/test/js/bun/test/mock/mock-module.test.ts +++ b/test/js/bun/test/mock/mock-module.test.ts @@ -8,6 +8,7 @@ // - Write test for import {foo} from "./foo"; export {foo} import { expect, mock, spyOn, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; import { default as defaultValue, fn, iCallFn, rexported, rexportedAs, variable } from "./mock-module-fixture"; import * as spyFixture from "./spymodule-fixture"; @@ -166,3 +167,33 @@ test("mocking a builtin", async () => { const { readFile } = await import("node:fs/promises"); expect(await readFile("hello.txt", "utf8")).toBe("hello world"); }); + +// Runs in a child so the mock of "bun" does not outlive this test. A literal +// import("bun") used to be rewritten by the transpiler and skip the module +// registry, so only the non-literal form saw the mock. +test('mocking "bun" applies to a literal import("bun") like it does to a computed specifier', async () => { + using dir = tempDir("mock-bun-module", { + "mock-bun.test.ts": ` + import { expect, mock, test } from "bun:test"; + + test("literal and computed import() of bun agree", async () => { + mock.module("bun", () => ({ mocked: true })); + const literal = await import("bun"); + const computed = await import(eval("'bun'")); + expect(literal.mocked).toBe(true); + expect(literal).toBe(computed); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "mock-bun.test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass\n"); + expect(stderr).toContain(" 0 fail\n"); + expect(exitCode).toBe(0); +});