Skip to content

Commit 97c5541

Browse files
fix: CLIPlugin dynamic import interop under Bun (#4799)
* 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'. * 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.
1 parent 40b631f commit 97c5541

4 files changed

Lines changed: 69 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack-cli": patch
3+
---
4+
5+
fix: `CLIPlugin` dynamic import interop under Bun

packages/webpack-cli/src/webpack-cli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3250,7 +3250,10 @@ class WebpackCLI {
32503250
process.exit(2);
32513251
}
32523252

3253-
const { default: CLIPlugin } = (await import("./plugins/cli-plugin.js")).default;
3253+
// Node's CJS interop nests the class under `.default.default`; Bun honors
3254+
// `__esModule` and unwraps once already, leaving the class one level up.
3255+
const cliPluginModule = (await import("./plugins/cli-plugin.js")).default;
3256+
const CLIPlugin = cliPluginModule.default ?? cliPluginModule;
32543257

32553258
// `getArguments()` already returns a name-keyed map of exactly the argument
32563259
// metadata `processArguments` consumes, so use it directly (cached) instead
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const path = require("node:path");
2+
3+
// Simulate Bun's ESM↔CJS interop: the dynamic import of cli-plugin resolves to
4+
// the class itself instead of Node's `{ __esModule, default }` wrapper. Guards
5+
// the interop-safe unwrap in `webpack-cli.ts` against reintroducing the
6+
// Node-only double-`.default` unwrap (which crashes every build under Bun).
7+
jest.mock(
8+
"../../../packages/webpack-cli/lib/plugins/cli-plugin",
9+
() => jest.requireActual("../../../packages/webpack-cli/lib/plugins/cli-plugin").default,
10+
);
11+
12+
const CLIPlugin = jest.requireActual(
13+
"../../../packages/webpack-cli/lib/plugins/cli-plugin",
14+
).default;
15+
16+
describe("CLIPlugin import interop", () => {
17+
it("applies CLIPlugin when the dynamic import resolves to the class itself", async () => {
18+
process.env.WEBPACK_PACKAGE = path.resolve(__dirname, "./mock-webpack.js");
19+
20+
const WebpackCLI = require("../../../packages/webpack-cli/lib/webpack-cli").default;
21+
22+
const exitSpy = jest.spyOn(process, "exit").mockImplementation((code) => {
23+
throw new Error(`process.exit(${code})`);
24+
});
25+
const errors = [];
26+
const errorSpy = jest
27+
.spyOn(console, "error")
28+
.mockImplementation((message) => errors.push(`${message}`));
29+
30+
try {
31+
await new WebpackCLI().run(["node", "webpack", "build"]);
32+
} catch (error) {
33+
throw new Error(`${error.message}\n${errors.join("\n")}`, {
34+
cause: error,
35+
});
36+
} finally {
37+
exitSpy.mockRestore();
38+
errorSpy.mockRestore();
39+
delete process.env.WEBPACK_PACKAGE;
40+
}
41+
42+
const options = globalThis.__capturedWebpackOptions;
43+
44+
expect(options.plugins[0]).toBeInstanceOf(CLIPlugin);
45+
});
46+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Minimal webpack stand-in (loaded via WEBPACK_PACKAGE): captures the config
2+
// webpack-cli assembles so tests can assert on the applied plugins.
3+
const realWebpack = require("webpack");
4+
5+
const webpack = (options) => {
6+
globalThis.__capturedWebpackOptions = options;
7+
8+
return { options };
9+
};
10+
11+
webpack.cli = realWebpack.cli;
12+
webpack.version = realWebpack.version;
13+
14+
module.exports = webpack;

0 commit comments

Comments
 (0)