Skip to content
Merged
4 changes: 1 addition & 3 deletions packages/webpack-cli/src/plugins/cli-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Compiler } from "webpack";
import { type CLIPluginOptions } from "../types.js";

export class CLIPlugin {
export default class CLIPlugin {
logger!: ReturnType<Compiler["getInfrastructureLogger"]>;

options: CLIPluginOptions;
Expand Down Expand Up @@ -149,5 +149,3 @@ export class CLIPlugin {
this.setupHelpfulOutput(compiler);
}
}

module.exports = CLIPlugin;
7 changes: 1 addition & 6 deletions packages/webpack-cli/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { type stringifyChunked } from "@discoveryjs/json-ext";
import { type Command, type CommandOptions, type Option, type ParseOptions } from "commander";
import { type prepare } from "rechoir";
import {
Expand Down Expand Up @@ -32,6 +31,7 @@ declare interface WebpackCallback {
}

// TODO remove me in the next major release, we don't need extra interface
// TODO also revisit all methods - remove unused or make private
interface IWebpackCLI {
colors: WebpackCLIColors;
logger: WebpackCLILogger;
Expand Down Expand Up @@ -287,10 +287,6 @@ interface Rechoir {
prepare: typeof prepare;
}

interface JsonExt {
stringifyChunked: typeof stringifyChunked;
}

interface RechoirError extends Error {
failures: RechoirError[];
error: Error;
Expand Down Expand Up @@ -318,7 +314,6 @@ export {
type IWebpackCLI,
type ImportLoaderError,
type Instantiable,
type JsonExt,
type LoadableWebpackConfiguration,
type ModuleName,
type PackageInstallOptions,
Expand Down
168 changes: 102 additions & 66 deletions packages/webpack-cli/src/webpack-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,11 @@ import {
type WebpackError,
default as webpack,
} from "webpack";
import type webpackMerge from "webpack-merge";

import { type CLIPlugin as CLIPluginClass } from "./plugins/cli-plugin.js";
import {
type Argument,
type Argv,
type BasicPrimitive,
type CLIPluginOptions,
type CallableWebpackConfiguration,
type CommandAction,
type DynamicImport,
Expand All @@ -26,7 +23,6 @@ import {
type IWebpackCLI,
type ImportLoaderError,
type Instantiable,
type JsonExt,
type LoadableWebpackConfiguration,
type ModuleName,
type PackageInstallOptions,
Expand Down Expand Up @@ -73,6 +69,11 @@ const WEBPACK_DEV_SERVER_PACKAGE = WEBPACK_DEV_SERVER_PACKAGE_IS_CUSTOM
: "webpack-dev-server";

const EXIT_SIGNALS = ["SIGINT", "SIGTERM"];
const DEFAULT_CONFIGURATION_FILES = [
"webpack.config",
".webpack/webpack.config",
".webpack/webpackfile",
];

interface Information {
Binaries?: string[];
Expand All @@ -83,6 +84,25 @@ interface Information {
npmPackages?: string | string[];
}

type LoadConfigOption = PotentialPromise<Configuration>;

class ConfigurationLoadingError extends Error {
name = "ConfigurationLoadingError";

constructor(errors: [unknown, unknown]) {
const message1 = errors[0] instanceof Error ? errors[0].message : String(errors[0]);
const message2 = util.stripVTControlCharacters(
errors[1] instanceof Error ? errors[1].message : String(errors[1]),
);
const message =
`▶ ESM (\`import\`) failed:\n ${message1.split("\n").join("\n ")}\n\n▶ CJS (\`require\`) failed:\n ${message2.split("\n").join("\n ")}`.trim();

super(message);

this.stack = "";
}
}

class WebpackCLI implements IWebpackCLI {
colors: WebpackCLIColors;

Expand Down Expand Up @@ -348,7 +368,7 @@ class WebpackCLI implements IWebpackCLI {
}

if (needInstall) {
const { sync } = require("cross-spawn");
const { sync } = await import("cross-spawn");

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

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

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

const envinfo = await this.tryRequireThenImport<typeof import("envinfo")>("envinfo", false);
const envinfo = await import("envinfo");

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

Expand Down Expand Up @@ -1094,8 +1115,8 @@ class WebpackCLI implements IWebpackCLI {
return options;
}

async loadWebpack(handleError = true) {
return this.tryRequireThenImport<typeof webpack>(WEBPACK_PACKAGE, handleError);
async loadWebpack(): Promise<typeof webpack> {
return require(WEBPACK_PACKAGE);
}

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

// Register own exit
this.program.exitOverride(async (error) => {
this.program.exitOverride((error) => {
if (error.exitCode === 0) {
process.exit(0);
}
Expand Down Expand Up @@ -1325,10 +1346,10 @@ class WebpackCLI implements IWebpackCLI {
process.exit(2);
}

const levenshtein = require("fastest-levenshtein");
const { distance } = require("fastest-levenshtein");

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

const levenshtein = require("fastest-levenshtein");
const { distance } = await import("fastest-levenshtein");

const found = knownCommands.find(
(commandOptions) =>
levenshtein.distance(operand, getCommandName(commandOptions.name)) < 3,
(commandOptions) => distance(operand, getCommandName(commandOptions.name)) < 3,
);

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

async loadConfig(options: Partial<WebpackDevServerOptions>) {
const disableInterpret =
typeof options.disableInterpret !== "undefined" && options.disableInterpret;
async #loadConfigurationFile(
configPath: string,
disableInterpret = false,
): Promise<LoadConfigOption | LoadConfigOption[] | undefined> {
let pkg: LoadConfigOption | LoadConfigOption[] | undefined;

const interpret = require("interpret");
let loadingError;

const loadConfigByPath = async (
configPath: string,
argv: Argv = {},
): Promise<{ options: Configuration | Configuration[]; path: string }> => {
try {
// eslint-disable-next-line no-eval
pkg = (await eval(`import("${pathToFileURL(configPath)}")`)).default;
} catch (err) {
if (this.isValidationError(err) || process.env?.WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG) {
throw err;
}

loadingError = err;
}

// Fallback logic when we can't use `import(...)`
if (loadingError) {
const { jsVariants, extensions } = await import("interpret");
const ext = path.extname(configPath).toLowerCase();
let interpreted = Object.keys(interpret.jsVariants).find((variant) => variant === ext);
// Fallback `.cts` to `.ts`
// TODO implement good `.mts` support after https://github.com/gulpjs/rechoir/issues/43
// For ESM and `.mts` you need to use: 'NODE_OPTIONS="--loader ts-node/esm" webpack-cli --config ./webpack.config.mts'

let interpreted = Object.keys(jsVariants).find((variant) => variant === ext);

if (!interpreted && ext.endsWith(".cts")) {
interpreted = interpret.jsVariants[".ts"];
interpreted = jsVariants[".ts"] as string;
}

if (interpreted && !disableInterpret) {
const rechoir: Rechoir = require("rechoir");
const rechoir: Rechoir = (await import("rechoir")).default;

try {
rechoir.prepare(interpret.extensions, configPath);
rechoir.prepare(extensions, configPath);
} catch (error) {
if ((error as RechoirError)?.failures) {
this.logger.error(`Unable load '${configPath}'`);
Expand All @@ -1823,52 +1854,59 @@ class WebpackCLI implements IWebpackCLI {
this.logger.error("Please install one of them");
process.exit(2);
}

this.logger.error(error);
process.exit(2);
}
}

let options: LoadableWebpackConfiguration | LoadableWebpackConfiguration[];
try {
pkg = require(configPath);
} catch (err) {
if (this.isValidationError(err)) {
throw err;
}

throw new ConfigurationLoadingError([loadingError, err]);
}
}

// To handle `babel`/`module.exports.default = {};`
if (pkg && typeof pkg === "object" && "default" in pkg) {
pkg = pkg.default as LoadConfigOption | LoadConfigOption[] | undefined;
}

type LoadConfigOption = PotentialPromise<Configuration>;
if (!pkg) {
this.logger.warn(
`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.`,
);
}

let moduleType: "unknown" | "commonjs" | "esm" = "unknown";
return pkg || {};
}

switch (ext) {
case ".cjs":
case ".cts":
moduleType = "commonjs";
break;
case ".mjs":
case ".mts":
moduleType = "esm";
break;
}
async loadConfig(options: Partial<WebpackDevServerOptions>) {
const disableInterpret =
typeof options.disableInterpret !== "undefined" && options.disableInterpret;

const loadConfigByPath = async (
configPath: string,
argv: Argv = {},
): Promise<{ options: Configuration | Configuration[]; path: string }> => {
let options: LoadableWebpackConfiguration | LoadableWebpackConfiguration[] | undefined;

try {
options = await this.tryRequireThenImport<LoadConfigOption | LoadConfigOption[]>(
configPath,
false,
moduleType,
);
options = await this.#loadConfigurationFile(configPath, disableInterpret);
} catch (error) {
this.logger.error(`Failed to load '${configPath}' config`);

if (this.isValidationError(error)) {
this.logger.error(error.message);
if (error instanceof ConfigurationLoadingError) {
this.logger.error(`Failed to load '${configPath}' config\n${error.message}`);
} else {
this.logger.error(`Failed to load '${configPath}' config`);
this.logger.error(error);
}

process.exit(2);
}

if (!options) {
this.logger.error(`Failed to load '${configPath}' config. Unable to find default export.`);
process.exit(2);
}

if (Array.isArray(options)) {
// reassign the value to assert type
const optionsArray: LoadableWebpackConfiguration[] = options;
Expand Down Expand Up @@ -1950,6 +1988,7 @@ class WebpackCLI implements IWebpackCLI {
}
}
} else {
const interpret = await import("interpret");
// Prioritize popular extensions first to avoid unnecessary fs calls
const extensions = new Set([
".js",
Expand All @@ -1962,7 +2001,7 @@ class WebpackCLI implements IWebpackCLI {
]);
// Order defines the priority, in decreasing order
const defaultConfigFiles = new Set(
["webpack.config", ".webpack/webpack.config", ".webpack/webpackfile"].flatMap((filename) =>
DEFAULT_CONFIGURATION_FILES.flatMap((filename) =>
[...extensions].map((ext) => path.resolve(filename + ext)),
),
);
Expand Down Expand Up @@ -2035,7 +2074,7 @@ class WebpackCLI implements IWebpackCLI {
),
);

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

if (loadedOptions.length > 0) {
Expand Down Expand Up @@ -2108,7 +2147,7 @@ class WebpackCLI implements IWebpackCLI {
}

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

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

const CLIPlugin =
await this.tryRequireThenImport<Instantiable<CLIPluginClass, [CLIPluginOptions]>>(
"./plugins/cli-plugin",
);
const CLIPlugin = (await import("./plugins/cli-plugin.js")).default;

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

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

createStringifyChunked = jsonExt.stringifyChunked;
createStringifyChunked = stringifyChunked;
}

const callback: WebpackCallback = (error, stats): void => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ describe("webpack cli", () => {
__dirname,
["-c", "./webpack.config.ts", "--disable-interpret"],
{
env: {
NODE_NO_WARNINGS: 1,
// Due nyc logic
WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG: true,
},
// Fallback to `ts-node/esm` for old Node.js versions
nodeOptions: major >= 24 ? [] : ["--require=ts-node/register"],
},
Expand Down
Loading