Skip to content

Commit e7b71ed

Browse files
fix: fallback to interpret only when configuration can't be load by dynamic import (#4647)
1 parent abe64ab commit e7b71ed

18 files changed

Lines changed: 174 additions & 155 deletions

File tree

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type Compiler } from "webpack";
22
import { type CLIPluginOptions } from "../types.js";
33

4-
export class CLIPlugin {
4+
export default class CLIPlugin {
55
logger!: ReturnType<Compiler["getInfrastructureLogger"]>;
66

77
options: CLIPluginOptions;
@@ -149,5 +149,3 @@ export class CLIPlugin {
149149
this.setupHelpfulOutput(compiler);
150150
}
151151
}
152-
153-
module.exports = CLIPlugin;

packages/webpack-cli/src/types.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { type stringifyChunked } from "@discoveryjs/json-ext";
21
import { type Command, type CommandOptions, type Option, type ParseOptions } from "commander";
32
import { type prepare } from "rechoir";
43
import {
@@ -32,6 +31,7 @@ declare interface WebpackCallback {
3231
}
3332

3433
// TODO remove me in the next major release, we don't need extra interface
34+
// TODO also revisit all methods - remove unused or make private
3535
interface IWebpackCLI {
3636
colors: WebpackCLIColors;
3737
logger: WebpackCLILogger;
@@ -287,10 +287,6 @@ interface Rechoir {
287287
prepare: typeof prepare;
288288
}
289289

290-
interface JsonExt {
291-
stringifyChunked: typeof stringifyChunked;
292-
}
293-
294290
interface RechoirError extends Error {
295291
failures: RechoirError[];
296292
error: Error;
@@ -318,7 +314,6 @@ export {
318314
type IWebpackCLI,
319315
type ImportLoaderError,
320316
type Instantiable,
321-
type JsonExt,
322317
type LoadableWebpackConfiguration,
323318
type ModuleName,
324319
type PackageInstallOptions,

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

Lines changed: 102 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,11 @@ import {
1010
type WebpackError,
1111
default as webpack,
1212
} from "webpack";
13-
import type webpackMerge from "webpack-merge";
1413

15-
import { type CLIPlugin as CLIPluginClass } from "./plugins/cli-plugin.js";
1614
import {
1715
type Argument,
1816
type Argv,
1917
type BasicPrimitive,
20-
type CLIPluginOptions,
2118
type CallableWebpackConfiguration,
2219
type CommandAction,
2320
type DynamicImport,
@@ -26,7 +23,6 @@ import {
2623
type IWebpackCLI,
2724
type ImportLoaderError,
2825
type Instantiable,
29-
type JsonExt,
3026
type LoadableWebpackConfiguration,
3127
type ModuleName,
3228
type PackageInstallOptions,
@@ -73,6 +69,11 @@ const WEBPACK_DEV_SERVER_PACKAGE = WEBPACK_DEV_SERVER_PACKAGE_IS_CUSTOM
7369
: "webpack-dev-server";
7470

7571
const EXIT_SIGNALS = ["SIGINT", "SIGTERM"];
72+
const DEFAULT_CONFIGURATION_FILES = [
73+
"webpack.config",
74+
".webpack/webpack.config",
75+
".webpack/webpackfile",
76+
];
7677

7778
interface Information {
7879
Binaries?: string[];
@@ -83,6 +84,25 @@ interface Information {
8384
npmPackages?: string | string[];
8485
}
8586

87+
type LoadConfigOption = PotentialPromise<Configuration>;
88+
89+
class ConfigurationLoadingError extends Error {
90+
name = "ConfigurationLoadingError";
91+
92+
constructor(errors: [unknown, unknown]) {
93+
const message1 = errors[0] instanceof Error ? errors[0].message : String(errors[0]);
94+
const message2 = util.stripVTControlCharacters(
95+
errors[1] instanceof Error ? errors[1].message : String(errors[1]),
96+
);
97+
const message =
98+
`▶ ESM (\`import\`) failed:\n ${message1.split("\n").join("\n ")}\n\n▶ CJS (\`require\`) failed:\n ${message2.split("\n").join("\n ")}`.trim();
99+
100+
super(message);
101+
102+
this.stack = "";
103+
}
104+
}
105+
86106
class WebpackCLI implements IWebpackCLI {
87107
colors: WebpackCLIColors;
88108

@@ -348,7 +368,7 @@ class WebpackCLI implements IWebpackCLI {
348368
}
349369

350370
if (needInstall) {
351-
const { sync } = require("cross-spawn");
371+
const { sync } = await import("cross-spawn");
352372

353373
try {
354374
sync(packageManager, commandArguments, { stdio: "inherit" });
@@ -364,6 +384,7 @@ class WebpackCLI implements IWebpackCLI {
364384
process.exit(2);
365385
}
366386

387+
// TODO remove me in the next major release
367388
async tryRequireThenImport<T>(
368389
module: ModuleName,
369390
handleError = true,
@@ -539,7 +560,7 @@ class WebpackCLI implements IWebpackCLI {
539560

540561
defaultInformation.npmPackages = `{${defaultPackages.map((item) => `*${item}*`).join(",")}}`;
541562

542-
const envinfo = await this.tryRequireThenImport<typeof import("envinfo")>("envinfo", false);
563+
const envinfo = await import("envinfo");
543564

544565
let info = await envinfo.run(defaultInformation, envinfoConfig);
545566

@@ -1094,8 +1115,8 @@ class WebpackCLI implements IWebpackCLI {
10941115
return options;
10951116
}
10961117

1097-
async loadWebpack(handleError = true) {
1098-
return this.tryRequireThenImport<typeof webpack>(WEBPACK_PACKAGE, handleError);
1118+
async loadWebpack(): Promise<typeof webpack> {
1119+
return require(WEBPACK_PACKAGE);
10991120
}
11001121

11011122
async run(args: Parameters<WebpackCLICommand["parseOptions"]>[0], parseOptions: ParseOptions) {
@@ -1287,7 +1308,7 @@ class WebpackCLI implements IWebpackCLI {
12871308
};
12881309

12891310
// Register own exit
1290-
this.program.exitOverride(async (error) => {
1311+
this.program.exitOverride((error) => {
12911312
if (error.exitCode === 0) {
12921313
process.exit(0);
12931314
}
@@ -1325,10 +1346,10 @@ class WebpackCLI implements IWebpackCLI {
13251346
process.exit(2);
13261347
}
13271348

1328-
const levenshtein = require("fastest-levenshtein");
1349+
const { distance } = require("fastest-levenshtein");
13291350

13301351
for (const option of (command as WebpackCLICommand).options) {
1331-
if (!option.hidden && levenshtein.distance(name, option.long?.slice(2)) < 3) {
1352+
if (!option.hidden && distance(name, option.long?.slice(2) as string) < 3) {
13321353
this.logger.error(`Did you mean '--${option.name()}'?`);
13331354
}
13341355
}
@@ -1761,11 +1782,10 @@ class WebpackCLI implements IWebpackCLI {
17611782
} else {
17621783
this.logger.error(`Unknown command or entry '${operand}'`);
17631784

1764-
const levenshtein = require("fastest-levenshtein");
1785+
const { distance } = await import("fastest-levenshtein");
17651786

17661787
const found = knownCommands.find(
1767-
(commandOptions) =>
1768-
levenshtein.distance(operand, getCommandName(commandOptions.name)) < 3,
1788+
(commandOptions) => distance(operand, getCommandName(commandOptions.name)) < 3,
17691789
);
17701790

17711791
if (found) {
@@ -1789,30 +1809,41 @@ class WebpackCLI implements IWebpackCLI {
17891809
await this.program.parseAsync(args, parseOptions);
17901810
}
17911811

1792-
async loadConfig(options: Partial<WebpackDevServerOptions>) {
1793-
const disableInterpret =
1794-
typeof options.disableInterpret !== "undefined" && options.disableInterpret;
1812+
async #loadConfigurationFile(
1813+
configPath: string,
1814+
disableInterpret = false,
1815+
): Promise<LoadConfigOption | LoadConfigOption[] | undefined> {
1816+
let pkg: LoadConfigOption | LoadConfigOption[] | undefined;
17951817

1796-
const interpret = require("interpret");
1818+
let loadingError;
17971819

1798-
const loadConfigByPath = async (
1799-
configPath: string,
1800-
argv: Argv = {},
1801-
): Promise<{ options: Configuration | Configuration[]; path: string }> => {
1820+
try {
1821+
// eslint-disable-next-line no-eval
1822+
pkg = (await eval(`import("${pathToFileURL(configPath)}")`)).default;
1823+
} catch (err) {
1824+
if (this.isValidationError(err) || process.env?.WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG) {
1825+
throw err;
1826+
}
1827+
1828+
loadingError = err;
1829+
}
1830+
1831+
// Fallback logic when we can't use `import(...)`
1832+
if (loadingError) {
1833+
const { jsVariants, extensions } = await import("interpret");
18021834
const ext = path.extname(configPath).toLowerCase();
1803-
let interpreted = Object.keys(interpret.jsVariants).find((variant) => variant === ext);
1804-
// Fallback `.cts` to `.ts`
1805-
// TODO implement good `.mts` support after https://github.com/gulpjs/rechoir/issues/43
1806-
// For ESM and `.mts` you need to use: 'NODE_OPTIONS="--loader ts-node/esm" webpack-cli --config ./webpack.config.mts'
1835+
1836+
let interpreted = Object.keys(jsVariants).find((variant) => variant === ext);
1837+
18071838
if (!interpreted && ext.endsWith(".cts")) {
1808-
interpreted = interpret.jsVariants[".ts"];
1839+
interpreted = jsVariants[".ts"] as string;
18091840
}
18101841

18111842
if (interpreted && !disableInterpret) {
1812-
const rechoir: Rechoir = require("rechoir");
1843+
const rechoir: Rechoir = (await import("rechoir")).default;
18131844

18141845
try {
1815-
rechoir.prepare(interpret.extensions, configPath);
1846+
rechoir.prepare(extensions, configPath);
18161847
} catch (error) {
18171848
if ((error as RechoirError)?.failures) {
18181849
this.logger.error(`Unable load '${configPath}'`);
@@ -1823,52 +1854,59 @@ class WebpackCLI implements IWebpackCLI {
18231854
this.logger.error("Please install one of them");
18241855
process.exit(2);
18251856
}
1826-
18271857
this.logger.error(error);
18281858
process.exit(2);
18291859
}
18301860
}
18311861

1832-
let options: LoadableWebpackConfiguration | LoadableWebpackConfiguration[];
1862+
try {
1863+
pkg = require(configPath);
1864+
} catch (err) {
1865+
if (this.isValidationError(err)) {
1866+
throw err;
1867+
}
1868+
1869+
throw new ConfigurationLoadingError([loadingError, err]);
1870+
}
1871+
}
1872+
1873+
// To handle `babel`/`module.exports.default = {};`
1874+
if (pkg && typeof pkg === "object" && "default" in pkg) {
1875+
pkg = pkg.default as LoadConfigOption | LoadConfigOption[] | undefined;
1876+
}
18331877

1834-
type LoadConfigOption = PotentialPromise<Configuration>;
1878+
if (!pkg) {
1879+
this.logger.warn(
1880+
`Default export is missing or nullish at (from ${configPath}). Webpack will run with an empty configuration. Please double-check that this is what you want. If you want to run webpack with an empty config, \`export {}\`/\`module.exports = {};\` to remove this warning.`,
1881+
);
1882+
}
18351883

1836-
let moduleType: "unknown" | "commonjs" | "esm" = "unknown";
1884+
return pkg || {};
1885+
}
18371886

1838-
switch (ext) {
1839-
case ".cjs":
1840-
case ".cts":
1841-
moduleType = "commonjs";
1842-
break;
1843-
case ".mjs":
1844-
case ".mts":
1845-
moduleType = "esm";
1846-
break;
1847-
}
1887+
async loadConfig(options: Partial<WebpackDevServerOptions>) {
1888+
const disableInterpret =
1889+
typeof options.disableInterpret !== "undefined" && options.disableInterpret;
1890+
1891+
const loadConfigByPath = async (
1892+
configPath: string,
1893+
argv: Argv = {},
1894+
): Promise<{ options: Configuration | Configuration[]; path: string }> => {
1895+
let options: LoadableWebpackConfiguration | LoadableWebpackConfiguration[] | undefined;
18481896

18491897
try {
1850-
options = await this.tryRequireThenImport<LoadConfigOption | LoadConfigOption[]>(
1851-
configPath,
1852-
false,
1853-
moduleType,
1854-
);
1898+
options = await this.#loadConfigurationFile(configPath, disableInterpret);
18551899
} catch (error) {
1856-
this.logger.error(`Failed to load '${configPath}' config`);
1857-
1858-
if (this.isValidationError(error)) {
1859-
this.logger.error(error.message);
1900+
if (error instanceof ConfigurationLoadingError) {
1901+
this.logger.error(`Failed to load '${configPath}' config\n${error.message}`);
18601902
} else {
1903+
this.logger.error(`Failed to load '${configPath}' config`);
18611904
this.logger.error(error);
18621905
}
18631906

18641907
process.exit(2);
18651908
}
18661909

1867-
if (!options) {
1868-
this.logger.error(`Failed to load '${configPath}' config. Unable to find default export.`);
1869-
process.exit(2);
1870-
}
1871-
18721910
if (Array.isArray(options)) {
18731911
// reassign the value to assert type
18741912
const optionsArray: LoadableWebpackConfiguration[] = options;
@@ -1950,6 +1988,7 @@ class WebpackCLI implements IWebpackCLI {
19501988
}
19511989
}
19521990
} else {
1991+
const interpret = await import("interpret");
19531992
// Prioritize popular extensions first to avoid unnecessary fs calls
19541993
const extensions = new Set([
19551994
".js",
@@ -1962,7 +2001,7 @@ class WebpackCLI implements IWebpackCLI {
19622001
]);
19632002
// Order defines the priority, in decreasing order
19642003
const defaultConfigFiles = new Set(
1965-
["webpack.config", ".webpack/webpack.config", ".webpack/webpackfile"].flatMap((filename) =>
2004+
DEFAULT_CONFIGURATION_FILES.flatMap((filename) =>
19662005
[...extensions].map((ext) => path.resolve(filename + ext)),
19672006
),
19682007
);
@@ -2035,7 +2074,7 @@ class WebpackCLI implements IWebpackCLI {
20352074
),
20362075
);
20372076

2038-
const merge = await this.tryRequireThenImport<typeof webpackMerge>("webpack-merge");
2077+
const { merge } = await import("webpack-merge");
20392078
const loadedOptions = loadedConfigs.flatMap((config) => config.options);
20402079

20412080
if (loadedOptions.length > 0) {
@@ -2108,7 +2147,7 @@ class WebpackCLI implements IWebpackCLI {
21082147
}
21092148

21102149
if (options.merge) {
2111-
const merge = await this.tryRequireThenImport<typeof webpackMerge>("webpack-merge");
2150+
const { merge } = await import("webpack-merge");
21122151

21132152
// we can only merge when there are multiple configurations
21142153
// either by passing multiple configs by flags or passing a
@@ -2161,10 +2200,7 @@ class WebpackCLI implements IWebpackCLI {
21612200
process.exit(2);
21622201
}
21632202

2164-
const CLIPlugin =
2165-
await this.tryRequireThenImport<Instantiable<CLIPluginClass, [CLIPluginOptions]>>(
2166-
"./plugins/cli-plugin",
2167-
);
2203+
const CLIPlugin = (await import("./plugins/cli-plugin.js")).default;
21682204

21692205
const internalBuildConfig = (item: Configuration) => {
21702206
const originalWatchValue = item.watch;
@@ -2407,9 +2443,9 @@ class WebpackCLI implements IWebpackCLI {
24072443
let createStringifyChunked: typeof stringifyChunked;
24082444

24092445
if (options.json) {
2410-
const jsonExt = await this.tryRequireThenImport<JsonExt>("@discoveryjs/json-ext");
2446+
const { stringifyChunked } = await import("@discoveryjs/json-ext");
24112447

2412-
createStringifyChunked = jsonExt.stringifyChunked;
2448+
createStringifyChunked = stringifyChunked;
24132449
}
24142450

24152451
const callback: WebpackCallback = (error, stats): void => {

test/build/config-format/typescript-cjs-using-nodejs/typescript.test.mjs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@ describe("webpack cli", () => {
1313
__dirname,
1414
["-c", "./webpack.config.ts", "--disable-interpret"],
1515
{
16-
env: {
17-
NODE_NO_WARNINGS: 1,
18-
// Due nyc logic
19-
WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG: true,
20-
},
2116
// Fallback to `ts-node/esm` for old Node.js versions
2217
nodeOptions: major >= 24 ? [] : ["--require=ts-node/register"],
2318
},

0 commit comments

Comments
 (0)