diff --git a/.changeset/cli-performance.md b/.changeset/cli-performance.md new file mode 100644 index 00000000000..14838e32261 --- /dev/null +++ b/.changeset/cli-performance.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +Improved CLI startup performance and reduced memory usage. diff --git a/packages/webpack-cli/bin/cli.js b/packages/webpack-cli/bin/cli.js index 1e98935cefe..b3a6ca5e11a 100755 --- a/packages/webpack-cli/bin/cli.js +++ b/packages/webpack-cli/bin/cli.js @@ -2,7 +2,15 @@ "use strict"; -const importLocal = require("import-local"); +// Prefer the local installation of `webpack-cli` when one exists. Run this +// before requiring the (heavier) CLI implementation: a delegated run then never +// loads it, and `WEBPACK_CLI_SKIP_IMPORT_LOCAL` skips loading `import-local` too. +if (!process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL && require("import-local")(__filename)) { + return; +} + +process.title = "webpack"; + const WebpackCLI = require("../lib/webpack-cli").default; const runCLI = async (args) => { @@ -16,14 +24,5 @@ const runCLI = async (args) => { } }; -if ( - !process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL && // Prefer the local installation of `webpack-cli` - importLocal(__filename) -) { - return; -} - -process.title = "webpack"; - // eslint-disable-next-line unicorn/prefer-top-level-await runCLI(process.argv); diff --git a/packages/webpack-cli/src/levenshtein.ts b/packages/webpack-cli/src/levenshtein.ts deleted file mode 100644 index f719670bfa4..00000000000 --- a/packages/webpack-cli/src/levenshtein.ts +++ /dev/null @@ -1,165 +0,0 @@ -// Levenshtein distance via Myers' bit-parallel algorithm. -// Inspired by fastest-levenshtein (MIT, https://github.com/ka-weihe/fastest-levenshtein). - -const peq = new Uint32Array(0x10000); - -function myers32(a: string, b: string): number { - const n = a.length; - const m = b.length; - const lst = 1 << (n - 1); - let pv = -1; - let mv = 0; - let sc = n; - let i = n; - - while (i--) { - peq[a.charCodeAt(i)] |= 1 << i; - } - - for (i = 0; i < m; i++) { - let eq = peq[b.charCodeAt(i)]; - const xv = eq | mv; - - eq |= ((eq & pv) + pv) ^ pv; - mv |= ~(eq | pv); - pv &= eq; - - if (mv & lst) { - sc++; - } - - if (pv & lst) { - sc--; - } - - mv = (mv << 1) | 1; - pv = (pv << 1) | ~(xv | mv); - mv &= xv; - } - - i = n; - - while (i--) { - peq[a.charCodeAt(i)] = 0; - } - - return sc; -} - -function myersX(longer: string, shorter: string): number { - const n = shorter.length; - const m = longer.length; - const mhc: number[] = []; - const phc: number[] = []; - const horizontalSize = Math.ceil(n / 32); - const verticalSize = Math.ceil(m / 32); - - for (let i = 0; i < horizontalSize; i++) { - phc[i] = -1; - mhc[i] = 0; - } - - let j = 0; - - for (; j < verticalSize - 1; j++) { - let mv = 0; - let pv = -1; - const start = j * 32; - const verticalLen = Math.min(32, m) + start; - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] |= 1 << k; - } - - for (let i = 0; i < n; i++) { - const eq = peq[shorter.charCodeAt(i)]; - const pb = (phc[(i / 32) | 0] >>> i) & 1; - const mb = (mhc[(i / 32) | 0] >>> i) & 1; - const xv = eq | mv; - const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; - let ph = mv | ~(xh | pv); - let mh = pv & xh; - - if ((ph >>> 31) ^ pb) { - phc[(i / 32) | 0] ^= 1 << i; - } - - if ((mh >>> 31) ^ mb) { - mhc[(i / 32) | 0] ^= 1 << i; - } - - ph = (ph << 1) | pb; - mh = (mh << 1) | mb; - pv = mh | ~(xv | ph); - mv = ph & xv; - } - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] = 0; - } - } - - let mv = 0; - let pv = -1; - const start = j * 32; - const verticalLen = Math.min(32, m - start) + start; - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] |= 1 << k; - } - - let score = m; - - for (let i = 0; i < n; i++) { - const eq = peq[shorter.charCodeAt(i)]; - const pb = (phc[(i / 32) | 0] >>> i) & 1; - const mb = (mhc[(i / 32) | 0] >>> i) & 1; - const xv = eq | mv; - const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; - let ph = mv | ~(xh | pv); - let mh = pv & xh; - - score += (ph >>> (m - 1)) & 1; - score -= (mh >>> (m - 1)) & 1; - - if ((ph >>> 31) ^ pb) { - phc[(i / 32) | 0] ^= 1 << i; - } - - if ((mh >>> 31) ^ mb) { - mhc[(i / 32) | 0] ^= 1 << i; - } - - ph = (ph << 1) | pb; - mh = (mh << 1) | mb; - pv = mh | ~(xv | ph); - mv = ph & xv; - } - - for (let k = start; k < verticalLen; k++) { - peq[longer.charCodeAt(k)] = 0; - } - - return score; -} - -/** - * Returns the Levenshtein edit distance between two strings. - */ -export function distance(first: string, second: string): number { - let a = first; - let b = second; - - if (a.length < b.length) { - const tmp = b; - - b = a; - a = tmp; - } - - if (b.length === 0) { - return a.length; - } - - return a.length <= 32 ? myers32(a, b) : myersX(a, b); -} diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 7b3bca9345c..2e9962eac85 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -30,7 +30,6 @@ import { default as webpack, } from "webpack"; import { type Configuration as DevServerConfiguration } from "webpack-dev-server"; -import { distance } from "./levenshtein.js"; const WEBPACK_PACKAGE_IS_CUSTOM = Boolean(process.env.WEBPACK_PACKAGE); const WEBPACK_PACKAGE = WEBPACK_PACKAGE_IS_CUSTOM @@ -236,6 +235,181 @@ const DEFAULT_WEBPACK_PACKAGES: string[] = ["webpack", "loader"]; // Options that get a single-character alias derived from their name. const FLAGS_WITH_ALIAS = new Set(["devtool", "output-path", "target", "watch", "extends"]); +// Keys the CLI sets on the parsed options itself (never webpack arguments), so +// they don't need to be forwarded to webpack's `processArguments`. +const INTERNAL_OPTION_KEYS = new Set(["webpack", "argv", "isWatchingLikeCommand"]); + +// Levenshtein distance via Myers' bit-parallel algorithm, used only for "did you +// mean" suggestions. Inspired by fastest-levenshtein (MIT, +// https://github.com/ka-weihe/fastest-levenshtein). +// +// The 256 KB buffer is allocated lazily on first use: suggestions only run on +// error paths, so a normal build never pays for it. +let levenshteinPeq: Uint32Array | undefined; + +function myers32(a: string, b: string, peq: Uint32Array): number { + const n = a.length; + const m = b.length; + const lst = 1 << (n - 1); + let pv = -1; + let mv = 0; + let sc = n; + let i = n; + + while (i--) { + peq[a.charCodeAt(i)] |= 1 << i; + } + + for (i = 0; i < m; i++) { + let eq = peq[b.charCodeAt(i)]; + const xv = eq | mv; + + eq |= ((eq & pv) + pv) ^ pv; + mv |= ~(eq | pv); + pv &= eq; + + if (mv & lst) { + sc++; + } + + if (pv & lst) { + sc--; + } + + mv = (mv << 1) | 1; + pv = (pv << 1) | ~(xv | mv); + mv &= xv; + } + + i = n; + + while (i--) { + peq[a.charCodeAt(i)] = 0; + } + + return sc; +} + +function myersX(longer: string, shorter: string, peq: Uint32Array): number { + const n = shorter.length; + const m = longer.length; + const mhc: number[] = []; + const phc: number[] = []; + const horizontalSize = Math.ceil(n / 32); + const verticalSize = Math.ceil(m / 32); + + for (let i = 0; i < horizontalSize; i++) { + phc[i] = -1; + mhc[i] = 0; + } + + let j = 0; + + for (; j < verticalSize - 1; j++) { + let mv = 0; + let pv = -1; + const start = j * 32; + const verticalLen = Math.min(32, m) + start; + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] |= 1 << k; + } + + for (let i = 0; i < n; i++) { + const eq = peq[shorter.charCodeAt(i)]; + const pb = (phc[(i / 32) | 0] >>> i) & 1; + const mb = (mhc[(i / 32) | 0] >>> i) & 1; + const xv = eq | mv; + const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; + let ph = mv | ~(xh | pv); + let mh = pv & xh; + + if ((ph >>> 31) ^ pb) { + phc[(i / 32) | 0] ^= 1 << i; + } + + if ((mh >>> 31) ^ mb) { + mhc[(i / 32) | 0] ^= 1 << i; + } + + ph = (ph << 1) | pb; + mh = (mh << 1) | mb; + pv = mh | ~(xv | ph); + mv = ph & xv; + } + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] = 0; + } + } + + let mv = 0; + let pv = -1; + const start = j * 32; + const verticalLen = Math.min(32, m - start) + start; + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] |= 1 << k; + } + + let score = m; + + for (let i = 0; i < n; i++) { + const eq = peq[shorter.charCodeAt(i)]; + const pb = (phc[(i / 32) | 0] >>> i) & 1; + const mb = (mhc[(i / 32) | 0] >>> i) & 1; + const xv = eq | mv; + const xh = ((((eq | mb) & pv) + pv) ^ pv) | eq | mb; + let ph = mv | ~(xh | pv); + let mh = pv & xh; + + score += (ph >>> (m - 1)) & 1; + score -= (mh >>> (m - 1)) & 1; + + if ((ph >>> 31) ^ pb) { + phc[(i / 32) | 0] ^= 1 << i; + } + + if ((mh >>> 31) ^ mb) { + mhc[(i / 32) | 0] ^= 1 << i; + } + + ph = (ph << 1) | pb; + mh = (mh << 1) | mb; + pv = mh | ~(xv | ph); + mv = ph & xv; + } + + for (let k = start; k < verticalLen; k++) { + peq[longer.charCodeAt(k)] = 0; + } + + return score; +} + +// Levenshtein edit distance between two strings, used for "did you mean" +// suggestions. Exported only so it can be unit-tested directly; the CLI uses it +// through the private `WebpackCLI.#distance`. +export function distance(first: string, second: string): number { + let a = first; + let b = second; + + if (a.length < b.length) { + const tmp = b; + + b = a; + a = tmp; + } + + if (b.length === 0) { + return a.length; + } + + levenshteinPeq ??= new Uint32Array(0x10000); + + return a.length <= 32 ? myers32(a, b, levenshteinPeq) : myersX(a, b, levenshteinPeq); +} + class ConfigurationLoadingError extends Error { name = "ConfigurationLoadingError"; @@ -333,6 +507,11 @@ class WebpackCLI { return str.replaceAll(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); } + // Levenshtein edit distance between two strings, for "did you mean" suggestions. + static #distance(first: string, second: string): number { + return distance(first, second); + } + getLogger(): Logger { return { error: (val) => console.error(`[webpack-cli] ${this.colors.red(util.format(val))}`), @@ -639,43 +818,49 @@ class WebpackCLI { } if (options.options) { - let commandOptions: CommandOption[]; + // Register every option for help, otherwise only the ones present in argv. + const neededOptions = forHelp ? undefined : this.#neededOptionNames(); - if ( - forHelp && - !allDependenciesInstalled && - options.dependencies && - options.dependencies.length > 0 - ) { - commandOptions = []; - } else if (typeof options.options === "function") { - commandOptions = await options.options(command); - } else { - commandOptions = options.options; - } + // With no option flags in argv (e.g. a plain `webpack build`), nothing + // needs to be registered and no unknown-option suggestions are possible, + // so skip building the (large) option list entirely. This avoids the + // schema-to-arguments walk on the most common invocation. + if (!neededOptions || neededOptions.size > 0) { + let commandOptions: CommandOption[]; - // Keep all option names (including `no-` negated forms) for "did you mean" suggestions, since not every option is registered below. - const allOptionNames: string[] = []; + if ( + forHelp && + !allDependenciesInstalled && + options.dependencies && + options.dependencies.length > 0 + ) { + commandOptions = []; + } else if (typeof options.options === "function") { + commandOptions = await options.options(command); + } else { + commandOptions = options.options; + } - for (const option of commandOptions) { - allOptionNames.push(option.name); + // Keep all option names (including `no-` negated forms) for "did you mean" suggestions, since not every option is registered below. + const allOptionNames: string[] = []; - if (this.#optionSupportsNegation(option)) { - allOptionNames.push(`no-${option.name}`); + for (const option of commandOptions) { + allOptionNames.push(option.name); + + if (this.#optionSupportsNegation(option)) { + allOptionNames.push(`no-${option.name}`); + } } - } - (command as Command & { allOptionNames?: string[] }).allOptionNames = allOptionNames; + (command as Command & { allOptionNames?: string[] }).allOptionNames = allOptionNames; - // Register every option for help, otherwise only the ones present in argv. - const neededOptions = forHelp ? undefined : this.#neededOptionNames(); + for (const option of commandOptions) { + if (neededOptions && !this.#isOptionNeeded(option, neededOptions)) { + continue; + } - for (const option of commandOptions) { - if (neededOptions && !this.#isOptionNeeded(option, neededOptions)) { - continue; + this.makeOption(command, option); } - - this.makeOption(command, option); } } @@ -2116,7 +2301,7 @@ class WebpackCLI { .map((option) => option.long?.slice(2) as string); for (const candidate of candidateNames) { - if (candidate && distance(name, candidate) < 3) { + if (candidate && WebpackCLI.#distance(name, candidate) < 3) { this.logger.error(`Did you mean '--${candidate}'?`); } } @@ -2257,7 +2442,7 @@ class WebpackCLI { this.logger.error(`Unknown command or entry '${operand}'`); const found = Object.values(this.#commands).find( - (commandOptions) => distance(operand, commandOptions.rawName) < 3, + (commandOptions) => WebpackCLI.#distance(operand, commandOptions.rawName) < 3, ); if (found) { @@ -2286,25 +2471,23 @@ class WebpackCLI { // Finds the highest-priority default config file by reading each candidate directory once (case-insensitively) and confirming with `access`, instead of probing every `` combination separately. async #findDefaultConfigFile(): Promise { - const interpret = await import("interpret"); - // Prioritize popular extensions first to avoid unnecessary fs calls - const seenExtensions = new Set(); - const orderedExtensions: string[] = []; - - for (const ext of [ - ".js", - ".mjs", - ".cjs", - ".ts", - ".cts", - ".mts", - ...Object.keys(interpret.extensions), - ]) { - if (!seenExtensions.has(ext)) { - seenExtensions.add(ext); - orderedExtensions.push(ext); + // Popular extensions, tried first. The common case (e.g. `webpack.config.js`) + // matches here, so `interpret` is never loaded — see `getExoticExtensions`. + const commonExtensions = [".js", ".mjs", ".cjs", ".ts", ".cts", ".mts"]; + + // `interpret`'s extra extensions (e.g. `.coffee`) are only needed when no + // common-extension config exists, so defer importing it until then. + let exoticExtensions: string[] | undefined; + const getExoticExtensions = async () => { + if (typeof exoticExtensions === "undefined") { + const interpret = await import("interpret"); + const common = new Set(commonExtensions); + + exoticExtensions = Object.keys(interpret.extensions).filter((ext) => !common.has(ext)); } - } + + return exoticExtensions; + }; const directoryEntriesCache = new Map | null>(); const readDirectoryEntries = async (directory: string) => { @@ -2325,13 +2508,13 @@ class WebpackCLI { return entries; }; - // Order defines the priority, in decreasing order - for (const filename of DEFAULT_CONFIGURATION_FILES) { - const resolvedBase = path.resolve(filename); - const entries = await readDirectoryEntries(path.dirname(resolvedBase)); - const basename = path.basename(resolvedBase); - - for (const ext of orderedExtensions) { + const findInExtensions = async ( + resolvedBase: string, + basename: string, + entries: Set | null, + extensions: string[], + ): Promise => { + for (const ext of extensions) { // Skip candidates absent from the listing, but when the directory can't be listed (`entries` is `null`) probe every candidate directly. if (entries && !entries.has((basename + ext).toLowerCase())) { continue; @@ -2348,6 +2531,34 @@ class WebpackCLI { // Listed but not accessible, keep looking } } + + return undefined; + }; + + // Order defines the priority, in decreasing order. Within each filename, + // common extensions take priority over the exotic ones (matching the + // previous combined ordering). + for (const filename of DEFAULT_CONFIGURATION_FILES) { + const resolvedBase = path.resolve(filename); + const entries = await readDirectoryEntries(path.dirname(resolvedBase)); + const basename = path.basename(resolvedBase); + + const common = await findInExtensions(resolvedBase, basename, entries, commonExtensions); + + if (common) { + return common; + } + + const exotic = await findInExtensions( + resolvedBase, + basename, + entries, + await getExoticExtensions(), + ); + + if (exotic) { + return exotic; + } } return undefined; @@ -2705,7 +2916,9 @@ class WebpackCLI { // `getArguments()` already returns a name-keyed map of exactly the argument // metadata `processArguments` consumes, so use it directly (cached) instead // of rebuilding a `schemaToOptions` array and a lookup map on every run. - const builtInArgs = this.#getArguments(options.webpack, undefined); + // Computed lazily: a plain `webpack build` only has internal option keys, so + // it skips the schema-to-arguments walk entirely. + let builtInArgs: ReturnType<(typeof webpack)["cli"]["getArguments"]> | undefined; const internalBuildConfig = (configuration: Configuration) => { const originalWatchValue = configuration.watch; @@ -2714,10 +2927,10 @@ class WebpackCLI { const values: ProcessedArguments = {}; for (const name of Object.keys(options)) { - if (name === "argv") continue; + if (INTERNAL_OPTION_KEYS.has(name)) continue; const kebabName = this.toKebabCase(name); - const arg = builtInArgs[kebabName]; + const arg = (builtInArgs ??= this.#getArguments(options.webpack, undefined))[kebabName]; if (arg) { args[name] = arg; diff --git a/test/api/levenshtein.test.js b/test/api/levenshtein.test.js index 68ed2897488..0a5251ded03 100644 --- a/test/api/levenshtein.test.js +++ b/test/api/levenshtein.test.js @@ -1,4 +1,6 @@ -const { distance } = require("../../packages/webpack-cli/lib/levenshtein"); +// The CLI uses this through the private `WebpackCLI.#distance`; it is exported +// from the module so these unit tests can exercise the algorithm directly. +const { distance } = require("../../packages/webpack-cli/lib/webpack-cli"); describe("distance", () => { it("should return 0 for equal strings", () => {