From 5c2ad88be55a3b882b55da6edf0afe69087b6302 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:10:06 +0000 Subject: [PATCH 1/2] fix: CLIPlugin dynamic import interop under Bun Bun honors the __esModule marker when dynamic-importing the compiled CJS, so the Node-interop double .default unwrap yields undefined and every build crashes with 'undefined is not a constructor'. --- .changeset/cli-plugin-import-interop.md | 5 +++++ packages/webpack-cli/src/webpack-cli.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-plugin-import-interop.md diff --git a/.changeset/cli-plugin-import-interop.md b/.changeset/cli-plugin-import-interop.md new file mode 100644 index 00000000000..8e08991ca1f --- /dev/null +++ b/.changeset/cli-plugin-import-interop.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +fix: `CLIPlugin` dynamic import interop under Bun diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 6bc1f4a077b..c21bb43cda2 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -3250,7 +3250,10 @@ class WebpackCLI { process.exit(2); } - const { default: CLIPlugin } = (await import("./plugins/cli-plugin.js")).default; + // Node's CJS interop nests the class under `.default.default`; Bun honors + // `__esModule` and unwraps once already, leaving the class one level up. + const cliPluginModule = (await import("./plugins/cli-plugin.js")).default; + const CLIPlugin = cliPluginModule.default ?? cliPluginModule; // `getArguments()` already returns a name-keyed map of exactly the argument // metadata `processArguments` consumes, so use it directly (cached) instead From 5727a555e5f891931eee9dbde35fcd319eefd9cf Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:23:05 +0000 Subject: [PATCH 2/2] test: cover CLIPlugin import interop unwrap Mocks the cli-plugin module to the shape Bun's __esModule-aware interop produces (the class itself) and asserts a build still applies CLIPlugin. --- .../cli-plugin-interop.test.js | 46 +++++++++++++++++++ test/api/cli-plugin-interop/mock-webpack.js | 14 ++++++ 2 files changed, 60 insertions(+) create mode 100644 test/api/cli-plugin-interop/cli-plugin-interop.test.js create mode 100644 test/api/cli-plugin-interop/mock-webpack.js diff --git a/test/api/cli-plugin-interop/cli-plugin-interop.test.js b/test/api/cli-plugin-interop/cli-plugin-interop.test.js new file mode 100644 index 00000000000..a4e526ae869 --- /dev/null +++ b/test/api/cli-plugin-interop/cli-plugin-interop.test.js @@ -0,0 +1,46 @@ +const path = require("node:path"); + +// Simulate Bun's ESM↔CJS interop: the dynamic import of cli-plugin resolves to +// the class itself instead of Node's `{ __esModule, default }` wrapper. Guards +// the interop-safe unwrap in `webpack-cli.ts` against reintroducing the +// Node-only double-`.default` unwrap (which crashes every build under Bun). +jest.mock( + "../../../packages/webpack-cli/lib/plugins/cli-plugin", + () => jest.requireActual("../../../packages/webpack-cli/lib/plugins/cli-plugin").default, +); + +const CLIPlugin = jest.requireActual( + "../../../packages/webpack-cli/lib/plugins/cli-plugin", +).default; + +describe("CLIPlugin import interop", () => { + it("applies CLIPlugin when the dynamic import resolves to the class itself", async () => { + process.env.WEBPACK_PACKAGE = path.resolve(__dirname, "./mock-webpack.js"); + + const WebpackCLI = require("../../../packages/webpack-cli/lib/webpack-cli").default; + + const exitSpy = jest.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + const errors = []; + const errorSpy = jest + .spyOn(console, "error") + .mockImplementation((message) => errors.push(`${message}`)); + + try { + await new WebpackCLI().run(["node", "webpack", "build"]); + } catch (error) { + throw new Error(`${error.message}\n${errors.join("\n")}`, { + cause: error, + }); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + delete process.env.WEBPACK_PACKAGE; + } + + const options = globalThis.__capturedWebpackOptions; + + expect(options.plugins[0]).toBeInstanceOf(CLIPlugin); + }); +}); diff --git a/test/api/cli-plugin-interop/mock-webpack.js b/test/api/cli-plugin-interop/mock-webpack.js new file mode 100644 index 00000000000..296f909e27c --- /dev/null +++ b/test/api/cli-plugin-interop/mock-webpack.js @@ -0,0 +1,14 @@ +// Minimal webpack stand-in (loaded via WEBPACK_PACKAGE): captures the config +// webpack-cli assembles so tests can assert on the applied plugins. +const realWebpack = require("webpack"); + +const webpack = (options) => { + globalThis.__capturedWebpackOptions = options; + + return { options }; +}; + +webpack.cli = realWebpack.cli; +webpack.version = realWebpack.version; + +module.exports = webpack;