diff --git a/.changeset/worker-implementation-path.md b/.changeset/worker-implementation-path.md new file mode 100644 index 00000000..4cc85d0f --- /dev/null +++ b/.changeset/worker-implementation-path.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Take a minimizer as a module path or `{ path, export }`, which a worker requires rather than rebuilding it from its source. diff --git a/README.md b/README.md index 2f875da6..beedd214 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,32 @@ default one, and the tables in [webpack's own minimizers](#webpacks-own-minimizers) for `cssMinify` and `htmlMinify`. +`implementation` may also name a **module path** rather than hold the function +itself — a string, or `{ path, export }` for a named export, the way +`sass-loader` takes its `implementation`. A worker then `require`s the +minimizer, which is what the default terser is named by; one written as a +function is handed over as source for the worker to rebuild with `new +Function`: + +```js +new MinimizerPlugin({ + minify: { + // Or the string on its own, where the module exports the function itself. + implementation: { + path: require.resolve("./my-minifier"), + export: "minify", + }, + }, +}); +``` + +The saving is per task, so it needs every minimizer of that task to be nameable: +one written as a function anywhere in the list leaves the whole task on the +source path, since that function has no other way across. So does a function +anywhere in what the task carries — an `extractComments` callback, or a +minimizer's own options holding one — because a required minimizer is handed +the payload as it is, and a structured clone throws on a function. + `filter(name, info)` states which assets this minimizer is offered — return `false` to decline one, and anything else (`undefined` included) to accept. It answers for a `filter` property on the minimizer function itself, which is what diff --git a/src/implementation.js b/src/implementation.js new file mode 100644 index 00000000..ad8d731e --- /dev/null +++ b/src/implementation.js @@ -0,0 +1,174 @@ +/** @typedef {import("./index.js").MinimizedResult} MinimizedResult */ +/** @typedef {import("./index.js").CustomOptions} CustomOptions */ +/** @typedef {import("./index.js").MinimizeFunctionHelpers} MinimizeFunctionHelpers */ +/** @typedef {import("./index.js").ImplementationModuleRef} ImplementationModuleRef */ +/** + * @typedef {import("./index.js").BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerFn + */ + +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {ImplementationModuleRef | undefined} how to `require` it in a worker + */ +function getImplementationModuleRef(implementation) { + if (typeof implementation === "string") { + return { path: implementation }; + } + + if ( + implementation && + typeof implementation === "object" && + typeof (/** @type {ImplementationModuleRef} */ (implementation).path) === + "string" + ) { + const ref = /** @type {ImplementationModuleRef} */ (implementation); + + return typeof ref.export === "string" && ref.export.length > 0 + ? { path: ref.path, export: ref.export } + : { path: ref.path }; + } + + return undefined; +} + +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {MinimizerFn} the minify function + */ +function loadImplementation(implementation) { + if (typeof implementation === "function") { + return /** @type {MinimizerFn} */ (implementation); + } + + const ref = getImplementationModuleRef(implementation); + + if (!ref) { + throw new TypeError( + "Invalid minimizer implementation: expected a function, module path string, or { path, export }", + ); + } + + const mod = require(ref.path); + + const loaded = + typeof ref.export === "string" + ? mod[ref.export] + : typeof mod === "function" + ? mod + : mod && mod.default; + + if (typeof loaded !== "function") { + throw new TypeError( + typeof ref.export === "string" + ? `Minimizer export "${ref.export}" is not a function in ${ref.path}` + : `Minimizer module does not export a function: ${ref.path}`, + ); + } + + return /** @type {MinimizerFn} */ (loaded); +} + +/** + * Whether a value holds a function anywhere inside it. A worker reached by + * module path is handed the payload as it is, and a structured clone throws on + * one rather than dropping it. + * @param {unknown} value what a worker would be handed + * @param {Set=} seen values already walked + * @returns {boolean} true when a function is in there + */ +function holdsFunction(value, seen = new Set()) { + if (typeof value === "function") { + return true; + } + + if (!value || typeof value !== "object" || seen.has(value)) { + return false; + } + + seen.add(value); + + // A structured clone carries a `Map` or a `Set` but not what it holds, and + // neither answers to `Object.values`. + if (value instanceof Map) { + return [...value].some( + ([key, one]) => holdsFunction(key, seen) || holdsFunction(one, seen), + ); + } + + if (value instanceof Set) { + return [...value].some((one) => holdsFunction(one, seen)); + } + + return Object.values(/** @type {Record} */ (value)).some( + (one) => holdsFunction(one, seen), + ); +} + +/** + * True when every `minimizer.implementation` is a module path (`string` or + * `{ path, export }`). Inline minify functions keep `transform`. When + * `embedded` is present, *every* configured implementation must be a path — + * a single inline function in the embedded set forces `transform` for the + * whole asset task, even if that asset's own matched minimizers are paths. + * @template T + * @param {import("./index.js").InternalOptions} options options + * @returns {boolean} whether `worker.minify` can run without `transform` + */ +function canMinifyByPath(options) { + /** + * @param {unknown} implementation implementation + * @returns {boolean} true when a module path is known + */ + const hasPath = (implementation) => + Boolean(getImplementationModuleRef(implementation)); + + const minimizers = Array.isArray(options.minimizer.implementation) + ? options.minimizer.implementation + : [options.minimizer.implementation]; + + if (!minimizers.every(hasPath)) { + return false; + } + + // `extractComments` and a minimizer's own options both take functions, and + // those only ever reached a worker as source. + if ( + holdsFunction(options.extractComments) || + holdsFunction(options.minimizer.options) + ) { + return false; + } + + if (!options.embedded) { + return true; + } + + if (holdsFunction(options.embedded.options)) { + return false; + } + + const embedded = Array.isArray(options.embedded.implementation) + ? options.embedded.implementation + : [options.embedded.implementation]; + + return embedded.every(hasPath); +} + +/** + * The file `loadImplementation` would `require`, which is what tells two + * references apart: a bare specifier and a file of that name are not one module. + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {string | undefined} its resolved module, or nothing where no module is named + */ +function resolveImplementationModule(implementation) { + const ref = getImplementationModuleRef(implementation); + + return ref ? require.resolve(ref.path) : undefined; +} + +module.exports = { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, + resolveImplementationModule, +}; diff --git a/src/index.js b/src/index.js index 42200a77..80da7302 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,12 @@ const crypto = require("crypto"); const os = require("os"); const path = require("path"); +const { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, + resolveImplementationModule, +} = require("./implementation"); const { minify } = require("./minify"); const { cleanCssMinify, @@ -178,9 +184,20 @@ const { * @property {() => string | undefined=} getAssetFlag the name this function's work goes under in the asset's info, which is what the asset it wrote is marked with and what stats print. `compress` says `compressed`, another encoding of the bytes being no smaller a version of them; a minimizer saying nothing minified the asset, so `minimized`, and a generator saying nothing wrote a new file, so `generated`. It is also what is not run twice: an asset already marked with every name a function writes is declined, which is how a minified asset a child compilation handed up is left alone */ +/** + * Module path form of `minimizer.implementation` (like sass-loader): the worker + * `require`s it instead of evaluating serialized function source via `new Function`. + * @typedef {{ path: string, export?: string }} ImplementationModuleRef + */ + /** * @template T - * @typedef {T extends EXPECTED_ANY[] ? { [P in keyof T]: BasicMinimizerImplementation & MinimizeFunctionHelpers } : BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerImplementation + * @typedef {(BasicMinimizerImplementation & MinimizeFunctionHelpers) | string | ImplementationModuleRef} MinimizerImplementationValue + */ + +/** + * @template T + * @typedef {T extends EXPECTED_ANY[] ? { [P in keyof T]: MinimizerImplementationValue } : MinimizerImplementationValue} MinimizerImplementation */ /** @@ -191,7 +208,7 @@ const { * @property {RawSourceMap | undefined} inputSourceMap input source map * @property {ExtractCommentsOptions | undefined} extractComments extract comments option * @property {{ implementation: MinimizerImplementation, options: MinimizerOptions }} minimizer minimizer - * @property {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] }=} embedded every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` is the languages each minifies and `offers` the languages each can hand out, both as data and both parallel to `implementation`, since a minify function reaches a worker as source and carries none of its properties; `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all + * @property {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] }=} embedded every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` / `offers` travel as data parallel to `implementation` so the legacy serialize path still knows what each entry minifies and can nest (a function shipped as source loses its helpers; a module path `require` restores them, but the arrays stay so both paths share one shape). `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all * @property {boolean=} module true when code is a EC module, otherwise false * @property {number | string=} ecma ecma version */ @@ -205,10 +222,17 @@ const { * @typedef {undefined | boolean | number} Parallel */ +/** + * A generator is the function itself: nothing `require`s one in a worker, and + * the schema takes no module path for it. + * @template T + * @typedef {BasicMinimizerImplementation & MinimizeFunctionHelpers} GeneratorImplementation + */ + /** * One generator, written as an object stating how to run it. * @typedef {object} GeneratorDescriptor - * @property {MinimizerImplementation} implementation the generator itself + * @property {GeneratorImplementation} implementation the generator itself * @property {MinimizerOptions=} options options for this generator, preferred over the deprecated `generatorOptions` * @property {("import" | "asset")=} type `import` re-encodes a module as it is built, so the import that asked for it is renamed with it; `asset` writes a new file beside one already emitted * @property {(string | ((pathData: EXPECTED_ANY) => string))=} filename name for the generated asset, as a webpack filename template or a function answering with one. `asset` generators only @@ -222,7 +246,7 @@ const { /** * What `generate` may be written as: one generator, a list of them, a * descriptor, or an object naming descriptors an asset asks for with `?as=`. - * @typedef {MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor | { [preset: string]: MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor }} Generate + * @typedef {GeneratorImplementation | GeneratorImplementation[] | GeneratorDescriptor | { [preset: string]: GeneratorImplementation | GeneratorImplementation[] | GeneratorDescriptor }} Generate */ /** @@ -412,8 +436,13 @@ class MinimizerPlugin { const minimizers = typeof declaredMinify !== "undefined" ? declaredMinify - : /** @type {MinimizerImplementation} */ ( - /** @type {unknown} */ (terserMinify) + : // Named by module path rather than handed over as a function, so a + // worker requires it instead of rebuilding it from its source. + /** @type {MinimizerImplementation} */ ( + /** @type {unknown} */ ({ + path: require.resolve("./utils.js"), + export: "terserMinify", + }) ); const test = typeof declaredTest !== "undefined" ? declaredTest : /\.[cm]?js(\?.*)?$/i; @@ -631,7 +660,7 @@ class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {Record} assets assets - * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset, and what an earlier pass of this plugin already wrote onto each asset + * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset and from a run under different minimizers, and what an earlier pass of this plugin already wrote onto each asset * @returns {Promise} */ async optimize(compiler, compilation, assets, optimizeOptions) { @@ -646,18 +675,14 @@ class MinimizerPlugin { */ const matchesName = (name) => this.matchesName(compiler, name); - // Normalize the implementation list to an array so dispatch and the - // worker-pool capability checks below can iterate uniformly. The - // original shape on `this.options.minimizer.implementation` is preserved - // for chunk hashing. - const implementations = Array.isArray(this.options.minimizer.implementation) - ? this.options.minimizer.implementation - : [this.options.minimizer.implementation]; + // One slot per configured minimizer: keep the option value for the worker + // (path / function) and the loaded function for filter / capabilities. + const minimizerSlots = this.getMinimizerSlots(); // What each minimizer marks an asset with, asked once here rather than // again for every asset it is offered. - const flagsByMinimizer = implementations.map((one) => - declaredFlags(one, "minimized"), + const flagsByMinimizer = minimizerSlots.map(({ fn }) => + declaredFlags(fn, "minimized"), ); // `additionalAssets` hands this pass whatever was emitted after it ran, @@ -690,7 +715,7 @@ class MinimizerPlugin { * convention used by `supportsWorkerThreads`). * @param {string} name asset name * @param {AssetInfo} info asset info - * @returns {number[]} indices into `implementations` that accept the asset + * @returns {number[]} indices into `minimizerSlots` that accept the asset */ const matchingMinimizers = (name, info) => { const matched = []; @@ -698,14 +723,14 @@ class MinimizerPlugin { const written = optimizeOptions.written.get(name); const says = /** @type {Record} */ (info); - for (let i = 0; i < implementations.length; i++) { + for (let i = 0; i < minimizerSlots.length; i++) { // A pass runs only the minimizers asking for its stage; the rest read // this same asset at theirs. if (optimizeOptions.only && !optimizeOptions.only.includes(i)) { continue; } - const impl = implementations[i]; + const { fn } = minimizerSlots[i]; // Skip double minimize assets from child compilation: one already // saying what this minimizer writes has been through it. An earlier @@ -718,9 +743,7 @@ class MinimizerPlugin { // the function is what a minimizer says about itself, and is the // fallback rather than a second filter to satisfy. const filter = - filters && typeof filters[i] === "function" - ? filters[i] - : impl.filter; + filters && typeof filters[i] === "function" ? filters[i] : fn.filter; if (typeof filter !== "function" || filter(name, info) !== false) { matched.push(i); @@ -806,14 +829,20 @@ class MinimizerPlugin { // only to the minimizers its name matched, so one that cannot run in a // worker — an image minimizer, whose bytes have no way across — must not // take the pool away from the JavaScript ones configured beside it. - const workerCapable = implementations.map( - (impl) => - typeof impl.supportsWorker === "undefined" || - (typeof impl.supportsWorker === "function" && impl.supportsWorker()), + const workerCapable = minimizerSlots.map( + ({ fn }) => + typeof fn.supportsWorker === "undefined" || + (typeof fn.supportsWorker === "function" && fn.supportsWorker()), + ); + const binaryCapable = minimizerSlots.map( + ({ fn }) => + typeof fn.supportsBinary === "function" && fn.supportsBinary(), ); - const binaryCapable = implementations.map( - (impl) => - typeof impl.supportsBinary === "function" && impl.supportsBinary(), + const enableWorkerThreads = minimizerSlots.every( + ({ fn }, i) => + !workerCapable[i] || + typeof fn.supportsWorkerThreads === "undefined" || + fn.supportsWorkerThreads() !== false, ); const needCreateWorker = optimizeOptions.availableNumberOfCores > 0 && @@ -839,12 +868,7 @@ class MinimizerPlugin { new Worker(require.resolve("./minify"), { numWorkers: numberOfWorkers, // Only what can reach the pool decides how it is run. - enableWorkerThreads: implementations.every( - (impl, i) => - !workerCapable[i] || - typeof impl.supportsWorkerThreads === "undefined" || - impl.supportsWorkerThreads() !== false, - ), + enableWorkerThreads, }) ); @@ -873,10 +897,21 @@ class MinimizerPlugin { * @param {number[]} matched indices of the minimizers this asset is dispatched to * @returns {Promise} the result */ - const run = (options, matched) => - getWorker && matched.every((i) => workerCapable[i]) - ? getWorker().transform(getSerializeJavascript()(options)) - : minify(options); + const run = (options, matched) => { + if (!(getWorker && matched.every((i) => workerCapable[i]))) { + return minify(options); + } + + // Prefer `worker.minify` only when this task's implementations are all + // module paths — including every entry on `embedded`, not just the + // asset's matched subset. A mixed path + inline-function config keeps + // the whole asset on `transform`. + if (canMinifyByPath(options)) { + return getWorker().minify(options); + } + + return getWorker().transform(getSerializeJavascript()(options)); + }; /** @typedef {{ extractedCommentsSource: import("webpack").sources.RawSource, commentsFilename: string }} ExtractedCommentsInfo */ /** @type {Map} */ @@ -925,7 +960,7 @@ class MinimizerPlugin { // `module`/`ecma` without mutating the caller's object. const assetImplementation = /** @type {MinimizerImplementation} */ - (matched.map((i) => implementations[i])); + (matched.map((i) => minimizerSlots[i].implementation)); const sourceOptions = this.options.minimizer.options; const assetMinimizerOptions = /** @type {MinimizerOptions} */ @@ -947,7 +982,7 @@ class MinimizerPlugin { options: assetMinimizerOptions, }, extractComments: this.options.extractComments, - embedded: this.embeddedMinimizer(matched), + embedded: this.embeddedFromSlots(matched, minimizerSlots), }; if (typeof info.javascriptModule !== "undefined") { @@ -1286,42 +1321,43 @@ class MinimizerPlugin { } /** - * Every configured minimizer, in order. The `minify` option takes one or an - * array; embedded source is dispatched across all of them either way. + * One slot per configured minimizer: the option value for workers (path / + * function) and the loaded function for helpers (`getTypes`, `filter`, …). * @private - * @returns {(BasicMinimizerImplementation & MinimizeFunctionHelpers)[]} the minimizers + * @returns {{ implementation: MinimizerImplementationValue, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} loaded slots */ - minimizers() { + getMinimizerSlots() { const { implementation } = this.options.minimizer; + const list = Array.isArray(implementation) + ? implementation + : [implementation]; - return /** @type {(BasicMinimizerImplementation & MinimizeFunctionHelpers)[]} */ ( - /** @type {unknown} */ ( - Array.isArray(implementation) ? implementation : [implementation] - ) - ); + return list.map((one) => ({ + implementation: + /** @type {MinimizerImplementationValue} */ + (one), + fn: loadImplementation(one), + })); } /** - * Every configured minimizer and its options, for dispatching source one - * language embeds in another. The asset's own entry holds only what its - * filename matched, and a language's minimizer need not be among them — a - * `.css` asset embedding an `` reaches an SVG minifier that claims no - * asset at all. + * Build the embedded minimizer payload from already-loaded slots (path or + * function kept as configured; `fn` supplies claims / offers). * @private * @param {number[]} matched indices of the minimizers this input's own entry holds + * @param {{ implementation: unknown, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} slots loaded minimizer slots * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] } | undefined} every configured minimizer, or undefined when nothing nested could be reached */ - embeddedMinimizer(matched) { - const minimizers = this.minimizers(); - // What each declares travels as data, not on the function: a minify function - // reaches a worker as its source, which carries none of its properties. - const claims = minimizers.map((minimizer) => - typeof minimizer.getTypes === "function" - ? minimizer.getTypes() || [] - : [], + embeddedFromSlots(matched, slots) { + // `claims` / `offers` are duplicated as data so the serialize worker path + // still knows each entry's languages (function source drops helpers). Path + // `implementation` values are kept as configured so the worker can `require` + // them. + const claims = slots.map(({ fn }) => + typeof fn.getTypes === "function" ? fn.getTypes() || [] : [], ); - const offers = minimizers.map((minimizer, i) => { - const { getEmbeddedTypes } = minimizer; + const offers = slots.map(({ fn }, i) => { + const { getEmbeddedTypes } = fn; return typeof getEmbeddedTypes === "function" ? getEmbeddedTypes( @@ -1345,13 +1381,17 @@ class MinimizerPlugin { return { implementation: /** @type {MinimizerImplementation} */ - (/** @type {unknown} */ (minimizers)), + ( + /** @type {unknown} */ ( + slots.map(({ implementation }) => implementation) + ) + ), options: /** @type {MinimizerOptions} */ ( /** @type {unknown} */ ( - minimizers.map((_, i) => + slots.map((_, i) => getMinimizerOptionsAt(this.options.minimizer.options, i), ) ) @@ -1498,11 +1538,10 @@ class MinimizerPlugin { assetFlags() { const flags = new Set(); - for (const flag of declaredFlags( - this.options.minimizer.implementation, - "minimized", - )) { - flags.add(flag); + for (const { fn } of this.getMinimizerSlots()) { + for (const flag of declaredFlags(fn, "minimized")) { + flags.add(flag); + } } // Only the generators that write a file: an `import` one rewrites a module @@ -1876,16 +1915,15 @@ class MinimizerPlugin { * @returns {Map} the indices, by stage */ minimizersByStage(compiler) { - const { implementation } = this.options.minimizer; - const each = Array.isArray(implementation) - ? implementation - : [implementation]; + // The loaded functions rather than what was configured: a module reference + // carries none of the helpers that say where its minimizer runs. + const each = this.getMinimizerSlots(); const fallback = this.defaultStage(compiler); /** @type {Map} */ const byStage = new Map(); for (let i = 0; i < each.length; i++) { - const asked = declaredStage(compiler, each[i]); + const asked = declaredStage(compiler, each[i].fn); const at = typeof asked === "number" ? asked : fallback; const already = byStage.get(at); @@ -1915,14 +1953,14 @@ class MinimizerPlugin { */ async renderEmbeddedSource(compiler, compilation, variesOn, source, info) { const { type, hostType, module } = info; - const minimizers = this.minimizers(); + const minimizerSlots = this.getMinimizerSlots(); const matched = []; // A minimizer that declares nothing takes no embedded source: such source // carries no filename to guess from, and guessing is what `getTypes` // replaces. - for (let i = 0; i < minimizers.length; i++) { - const { getTypes } = minimizers[i]; + for (let i = 0; i < minimizerSlots.length; i++) { + const { getTypes } = minimizerSlots[i].fn; if (typeof getTypes === "function" && (getTypes() || []).includes(type)) { matched.push(i); @@ -1977,7 +2015,10 @@ class MinimizerPlugin { minimizer: { implementation: /** @type {MinimizerImplementation} */ - (/** @type {unknown} */ (matched.map((i) => minimizers[i]))), + ( + /** @type {unknown} */ + (matched.map((i) => minimizerSlots[i].fn)) + ), options: /** @type {MinimizerOptions} */ ( @@ -1989,7 +2030,7 @@ class MinimizerPlugin { ) ), }, - embedded: this.embeddedMinimizer(matched), + embedded: this.embeddedFromSlots(matched, minimizerSlots), ecma: getEcmaVersion( /** @type {NonNullable["environment"]>} */ (compiler.options.output.environment), @@ -2371,27 +2412,59 @@ class MinimizerPlugin { compilation, ); /** - * @param {BasicMinimizerImplementation & MinimizeFunctionHelpers} impl implementation + * @param {MinimizerImplementationValue} impl implementation * @returns {string} minimizer version or "0.0.0" */ - const getVersion = (impl) => - typeof impl.getMinimizerVersion !== "undefined" - ? impl.getMinimizerVersion() || "0.0.0" - : "0.0.0"; + const getVersion = (impl) => { + // Path refs need a load; functions already carry helpers. Preset maps + // and other shapes are not a single minimizer — keep the prior "0.0.0". + const ref = getImplementationModuleRef(impl); + const fn = + typeof impl === "function" + ? impl + : ref + ? loadImplementation(impl) + : undefined; + const version = + fn && typeof fn.getMinimizerVersion !== "undefined" + ? fn.getMinimizerVersion() || "0.0.0" + : "0.0.0"; + + if (!ref) { + return version; + } + + // Which module it is, read against the build rather than the disk: two + // paths reporting no version are otherwise one identity, and an + // absolute one would answer differently in another checkout. + const where = path + .relative( + compiler.context, + resolveImplementationModule(impl) || ref.path, + ) + .replace(/\\/g, "/"); + + return `${version}|${where}|${ref.export || ""}`; + }; const data = getSerializeJavascript()({ minimizer: Array.isArray(this.options.minimizer.implementation) ? this.options.minimizer.implementation.map(getVersion) : getVersion( - /** @type {BasicMinimizerImplementation & MinimizeFunctionHelpers} */ + /** @type {MinimizerImplementationValue} */ (this.options.minimizer.implementation), ), options: this.options.minimizer.options, }); + const identity = crypto + .createHash("sha256") + .update(data) + .digest("hex") + .slice(0, 16); hooks.chunkHash.tap(pluginName, (chunk, hash) => { // Nothing minifying rewrites nothing, so no name owes it a hash of its // own. - if (this.minimizers().length === 0) { + if (this.getMinimizerSlots().length === 0) { return; } @@ -2420,7 +2493,7 @@ class MinimizerPlugin { // Nothing minifying rewrites no embedded source either, and salting the // module hash would rename a file this instance never touches. if ( - this.minimizers().length > 0 && + this.getMinimizerSlots().length > 0 && embeddedHooks.renderEmbeddedSource && embeddedHooks.embeddedSourceHash ) { @@ -2455,7 +2528,7 @@ class MinimizerPlugin { generator: Array.isArray(moduleGenerator.implementation) ? moduleGenerator.implementation.map(getVersion) : getVersion( - /** @type {BasicMinimizerImplementation & MinimizeFunctionHelpers} */ + /** @type {MinimizerImplementationValue} */ (moduleGenerator.implementation), ), options: moduleGenerator.options, @@ -2507,7 +2580,10 @@ class MinimizerPlugin { written, // Only where a second pass exists to be confused with: one pass // keeps the cache keys every earlier release wrote. - cacheSuffix: minimizersByStage.size > 1 ? `|${at}` : "", + // The minimizers and their options answer for what is cached + // under an asset's name, which otherwise varies only with its + // source. + cacheSuffix: `${minimizersByStage.size > 1 ? `|${at}` : ""}|${identity}`, }), ); } diff --git a/src/minify.js b/src/minify.js index e704e430..425c0299 100644 --- a/src/minify.js +++ b/src/minify.js @@ -2,6 +2,11 @@ /** @typedef {import("./index.js").CustomOptions} CustomOptions */ /** @typedef {import("./index.js").RawSourceMap} RawSourceMap */ /** @typedef {import("./index.js").EXPECTED_ANY} EXPECTED_ANY */ +/** @typedef {import("./index.js").MinimizeFunctionHelpers} MinimizeFunctionHelpers */ +/** + * A concrete minify function, including optional worker-path helpers. + * @typedef {import("./index.js").BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerFn + */ /** * @template T * @typedef {import("./index.js").MinimizerOptions} MinimizerOptions @@ -299,6 +304,8 @@ function composeSourceMaps(currentMap, prevMap, name) { } /* eslint-enable prefer-destructuring, no-eq-null, eqeqeq */ +const { loadImplementation } = require("./implementation"); + /** * @template T * @param {import("./index.js").InternalOptions} options options @@ -463,7 +470,7 @@ async function minify(options) { for (let i = 0; i < implementations.length; i++) { const currentImplementation = /** @type {import("./index.js").BasicMinimizerImplementation & import("./index.js").MinimizeFunctionHelpers} */ - (implementations[i]); + (loadImplementation(implementations[i])); const baseOptions = /** @type {import("./index.js").MinimizerOptions & { module?: boolean, ecma?: number | string }} */ (optionsAt(i)); @@ -561,6 +568,9 @@ async function minify(options) { * @returns {Promise} minified result */ async function transform(options) { + // Legacy worker path: the whole task (including minify function source) is a + // string evaluated here. Prefer `minify` when every `implementation` is a + // module path (`string` / `{ path, export }`) so the worker can `require` it. // 'use strict' => this === undefined (Clean Scope) // Safer for possible security issues, albeit not critical at all here @@ -585,4 +595,7 @@ async function transform(options) { return minify(evaluatedOptions); } -module.exports = { minify, transform }; +module.exports = { + minify, + transform, +}; diff --git a/src/options.json b/src/options.json index 093bcb57..72195b67 100644 --- a/src/options.json +++ b/src/options.json @@ -31,6 +31,35 @@ "$ref": "#/definitions/Rule" } ] + }, + "MinimizerImplementation": { + "description": "The minimizer itself: a function, a module path string (worker `require`s it), or `{ path, export }` for a named export.", + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "description": "Absolute path or resolvable id of the module that exports the minimizer.", + "type": "string", + "minLength": 1 + }, + "export": { + "description": "Named export when the module is not `module.exports` / `default`.", + "type": "string", + "minLength": 1 + } + }, + "required": ["path"] + } + ] } }, "title": "MinimizerPluginOptions", @@ -186,18 +215,18 @@ ] }, "minify": { - "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. An empty array is no minimizers at all, for an instance whose whole job is its `generate`.", + "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. An empty array is no minimizers at all, for an instance whose whole job is its `generate`. A string or `{ path, export }` loads the minimizer by module path in workers (like sass-loader `implementation`).", "link": "https://github.com/webpack/minimizer-webpack-plugin#number", "anyOf": [ { - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, { "type": "array", "items": { "anyOf": [ { - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, { "type": "object", @@ -205,7 +234,7 @@ "properties": { "implementation": { "description": "The minimizer itself.", - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, "options": { "description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.", @@ -228,7 +257,7 @@ "properties": { "implementation": { "description": "The minimizer itself.", - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, "options": { "description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.", diff --git a/test/__snapshots__/MinimizerPlugin.test.js.snap b/test/__snapshots__/MinimizerPlugin.test.js.snap index 00303652..5161f710 100644 --- a/test/__snapshots__/MinimizerPlugin.test.js.snap +++ b/test/__snapshots__/MinimizerPlugin.test.js.snap @@ -99,7 +99,7 @@ exports[`MinimizerPlugin should not fail when only a js minimizer is set up but exports[`MinimizerPlugin should regenerate hash: assets 1`] = ` { "389.389.__hash4__.js": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.__hash3__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".__hash4__.js",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.__hash3__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".__hash4__.js",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.__hash2__.js": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.__hash0__.js": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.__hash1__.js": "(()=>{"use strict";function o(){console.log(11)}o()})();", @@ -182,7 +182,7 @@ exports[`MinimizerPlugin should work and do not use memory cache when the "cache exports[`MinimizerPlugin should work and generate real content hash: assets 1`] = ` { "389.__hash3__.__hash4__.__hash2__.js": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,a)=>{if(e[o])return void e[o].push(n);let c,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{c.onerror=c.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],c.parentNode?.removeChild(c),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=u.bind(null,c.onerror),c.onload=u.bind(null,c.onload),l&&document.head.appendChild(c)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const a=r.p+r.u(t),c=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;c.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",c.name="ChunkLoadError",c.type=e,c.request=r,c.event=o,n[1](c)}};r.l(a,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,a]=o;var c,l,s=0;if(n.some(t=>0!==e[t])){for(c in i)r.o(i,c)&&(r.m[c]=i[c]);if(a)a(r)}for(t&&t(o);s{console.log("Good")})})();", + "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", } `; diff --git a/test/__snapshots__/parallel-option.test.js.snap b/test/__snapshots__/parallel-option.test.js.snap index f59caf1b..3b7ceb1e 100644 --- a/test/__snapshots__/parallel-option.test.js.snap +++ b/test/__snapshots__/parallel-option.test.js.snap @@ -353,3 +353,13 @@ exports[`worker should match snapshot with options.inputSourceMap 1`] = ` "warnings": [], } `; + +exports[`worker should minify via implementation path without serialize/new Function 1`] = ` +{ + "code": "var foo=1;", + "errors": [], + "extractedComments": [], + "map": undefined, + "warnings": [], +} +`; diff --git a/test/__snapshots__/test-option.test.js.snap b/test/__snapshots__/test-option.test.js.snap index f191d6cc..8fc192a0 100644 --- a/test/__snapshots__/test-option.test.js.snap +++ b/test/__snapshots__/test-option.test.js.snap @@ -715,7 +715,7 @@ __webpack_require__.r(__webpack_exports__); /***/ } }]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", "importExport.js?var=__hash0__": "/******/ (() => { // webpackBootstrap /******/ "use strict"; @@ -817,7 +817,7 @@ exports[`test option should match snapshot for multiple "test" values ({String}) exports[`test option should match snapshot with empty value: assets 1`] = ` { "389.389.js?ver=__hash0__": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.js?var=__hash0__": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.js?var=__hash0__": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.js?var=__hash0__": "(()=>{"use strict";function o(){console.log(11)}o()})();", diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index b5468c25..20e88a64 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -127,13 +127,20 @@ exports[`validation validate 8`] = ` exports[`validation validate 9`] = ` "Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - function | [function | object { implementation, options?, filter? }, ...] | object { implementation, options?, filter? } - -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. An empty array is no minimizers at all, for an instance whose whole job is its \`generate\`. + function | non-empty string | object { path, export? } | [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] | object { implementation, options?, filter? } + -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. An empty array is no minimizers at all, for an instance whose whole job is its \`generate\`. A string or \`{ path, export }\` loads the minimizer by module path in workers (like sass-loader \`implementation\`). -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number Details: - * options.minify should be an instance of function. + * options.minify should be one of these: + function | non-empty string | object { path, export? } + -> The minimizer itself: a function, a module path string (worker \`require\`s it), or \`{ path, export }\` for a named export. + Details: + * options.minify should be an instance of function. + * options.minify should be a non-empty string. + * options.minify should be an object: + object { path, export? } * options.minify should be an array: - [function | object { implementation, options?, filter? }, ...] + [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] * options.minify should be an object: object { implementation, options?, filter? }" `; @@ -240,13 +247,20 @@ exports[`validation validate 18`] = ` exports[`validation validate 19`] = ` "Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - function | [function | object { implementation, options?, filter? }, ...] | object { implementation, options?, filter? } - -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. An empty array is no minimizers at all, for an instance whose whole job is its \`generate\`. + function | non-empty string | object { path, export? } | [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] | object { implementation, options?, filter? } + -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. An empty array is no minimizers at all, for an instance whose whole job is its \`generate\`. A string or \`{ path, export }\` loads the minimizer by module path in workers (like sass-loader \`implementation\`). -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number Details: - * options.minify should be an instance of function. + * options.minify should be one of these: + function | non-empty string | object { path, export? } + -> The minimizer itself: a function, a module path string (worker \`require\`s it), or \`{ path, export }\` for a named export. + Details: + * options.minify should be an instance of function. + * options.minify should be a non-empty string. + * options.minify should be an object: + object { path, export? } * options.minify should be an array: - [function | object { implementation, options?, filter? }, ...] + [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] * options.minify should be an object: object { implementation, options?, filter? }" `; diff --git a/test/embedded-source.test.js b/test/embedded-source.test.js index ca87e3c9..cd2b15ac 100644 --- a/test/embedded-source.test.js +++ b/test/embedded-source.test.js @@ -448,6 +448,62 @@ describe("embedded source", () => { await del(cacheDirectory); }); + it("does not answer an embedded source from a cache another module filled", async () => { + const cacheDirectory = path.resolve( + __dirname, + "helpers/dist/embedded-module-cache", + ); + + await del(cacheDirectory); + + /** + * @param {string} name the CSS minimizer's module, under `fixtures/embedded` + * @returns {Promise} the stylesheet as it was embedded + */ + const buildWith = async (name) => { + const compiler = getCompiler({ + entry: fixture("entry-length.js"), + target: "node", + cache: { type: "filesystem", cacheDirectory }, + experiments: { css: true }, + module: { + rules: [ + { + test: /\.css$/, + type: "css/auto", + parser: { exportType: "text" }, + }, + ], + }, + }); + + defaultPlugin({ + minify: [MinimizerPlugin.terserMinify, fixture(name)], + minimizerOptions: [{}, {}], + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + + const embedded = exported(compiler, stats); + + await new Promise((resolve) => { + compiler.close(() => resolve()); + }); + + return embedded; + }; + + // Neither module reports a version and their options match, so where each + // one sits is all that tells the two entries apart. + expect(await buildWith("css-says-one.js")).toBe(".a{--said:one}"); + expect(await buildWith("css-says-two.js")).toBe(".a{--said:two}"); + expect(await buildWith("css-says-one.js")).toBe(".a{--said:one}"); + + await del(cacheDirectory); + }); + it("keeps the map a source carried into what it is embedded as", async () => { const compiler = getCompiler({ entry: fixture("entry-mapped.js"), diff --git a/test/extractComments-option.test.js b/test/extractComments-option.test.js index a951b3a7..0171f0b8 100644 --- a/test/extractComments-option.test.js +++ b/test/extractComments-option.test.js @@ -24,6 +24,13 @@ function createFilenameFn() { }; } +function pluginWithFunctionExtractComments(options) { + return new MinimizerPlugin({ + minify: MinimizerPlugin.terserMinify, + ...options, + }); +} + describe("extractComments option", () => { let compiler; @@ -113,7 +120,9 @@ describe("extractComments option", () => { }); it('should match snapshot for a "function" value', async () => { - new MinimizerPlugin({ extractComments: () => true }).apply(compiler); + pluginWithFunctionExtractComments({ extractComments: () => true }).apply( + compiler, + ); const stats = await compile(compiler); @@ -139,7 +148,7 @@ describe("extractComments option", () => { it("should match snapshot when extracts comments to multiple files", async () => { expect.assertions(8); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: createFilenameFn(), @@ -156,7 +165,7 @@ describe("extractComments option", () => { }); it("should match snapshot when extracts comments to a single file", async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "extracted-comments.js", @@ -174,7 +183,7 @@ describe("extractComments option", () => { }); it("should match snapshot when extracts without condition", async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "extracted-comments.js", @@ -211,7 +220,7 @@ describe("extractComments option", () => { it('should match snapshot when no condition, preserve only `/@license/i` comments and extract "some" comments', async () => { expect.assertions(8); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ terserOptions: { output: { comments: /@license/i, @@ -242,7 +251,7 @@ describe("extractComments option", () => { }); it("should match snapshot when extracts comments to a single file and dedupe duplicate comments", async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "extracted-comments.js", @@ -296,7 +305,7 @@ describe("extractComments option", () => { }, }); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "[file].LICENSE.txt?query=[query]&filebase=[base]", @@ -329,7 +338,7 @@ describe("extractComments option", () => { }, }); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: createFilenameFn(), @@ -447,7 +456,7 @@ describe("extractComments option", () => { }); it('should match snapshot and do not preserve and extract "all" comments when the option if a function', async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: () => true, }).apply(compiler); @@ -459,7 +468,7 @@ describe("extractComments option", () => { }); it('should match snapshot and preserve "all" and extract "all" comments with output.comments "all"', async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: () => true, terserOptions: { output: { @@ -662,7 +671,7 @@ describe("extractComments option", () => { }, }); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { filename: (fileData) => fileData.filename === "b.js" ? "b.txt" : "shared.txt", diff --git a/test/fixtures/embedded/css-says-one.js b/test/fixtures/embedded/css-says-one.js new file mode 100644 index 00000000..da63c4c3 --- /dev/null +++ b/test/fixtures/embedded/css-says-one.js @@ -0,0 +1,14 @@ +/** + * Stands in for a CSS minifier that says one thing, next to a second module + * saying another: what each returns is how a cache mix-up shows. + * @param {{ [file: string]: string }} input a single `{ filename: code }` entry + * @returns {{ code: string }} what it made of the stylesheet + */ +function cssSaysOne() { + return { code: ".a{--said:one}" }; +} + +cssSaysOne.getTypes = () => ["css"]; +cssSaysOne.filter = (asset) => /\.css(\?.*)?$/i.test(asset); + +module.exports = cssSaysOne; diff --git a/test/fixtures/embedded/css-says-two.js b/test/fixtures/embedded/css-says-two.js new file mode 100644 index 00000000..d5e668d7 --- /dev/null +++ b/test/fixtures/embedded/css-says-two.js @@ -0,0 +1,14 @@ +/** + * Stands in for a CSS minifier that says another thing, next to a first module + * saying one: what each returns is how a cache mix-up shows. + * @param {{ [file: string]: string }} input a single `{ filename: code }` entry + * @returns {{ code: string }} what it made of the stylesheet + */ +function cssSaysTwo() { + return { code: ".a{--said:two}" }; +} + +cssSaysTwo.getTypes = () => ["css"]; +cssSaysTwo.filter = (asset) => /\.css(\?.*)?$/i.test(asset); + +module.exports = cssSaysTwo; diff --git a/test/fixtures/minify-default-export.js b/test/fixtures/minify-default-export.js new file mode 100644 index 00000000..2898d299 --- /dev/null +++ b/test/fixtures/minify-default-export.js @@ -0,0 +1,9 @@ +/** + * @param {import("../../src/index.js").Input} input input + * @returns {Promise} result + */ +module.exports = async function minifyDefaultExport(input) { + const [[name, code]] = Object.entries(input); + + return { code: String(code).replace(/\s+/g, " ").trim(), filename: name }; +}; diff --git a/test/fixtures/minify-default-property.js b/test/fixtures/minify-default-property.js new file mode 100644 index 00000000..26e6e5bd --- /dev/null +++ b/test/fixtures/minify-default-property.js @@ -0,0 +1,11 @@ +/** + * @param {import("../../src/index.js").Input} input input + * @returns {Promise} result + */ +async function minifyDefaultProperty(input) { + const [[name, code]] = Object.entries(input); + + return { code: String(code).replace(/\s+/g, " ").trim(), filename: name }; +} + +module.exports = { default: minifyDefaultProperty }; diff --git a/test/implementation.test.js b/test/implementation.test.js new file mode 100644 index 00000000..e8ce00f2 --- /dev/null +++ b/test/implementation.test.js @@ -0,0 +1,196 @@ +import path from "path"; + +import { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, +} from "../src/implementation.js"; +import { terserMinify } from "../src/utils.js"; + +describe("getImplementationModuleRef", () => { + it("should accept a module path string", () => { + expect(getImplementationModuleRef("/abs/utils.js")).toEqual({ + path: "/abs/utils.js", + }); + }); + + it("should accept { path } without export", () => { + expect(getImplementationModuleRef({ path: "/abs/utils.js" })).toEqual({ + path: "/abs/utils.js", + }); + }); + + it("should accept { path, export }", () => { + expect( + getImplementationModuleRef({ + path: "/abs/utils.js", + export: "terserMinify", + }), + ).toEqual({ path: "/abs/utils.js", export: "terserMinify" }); + }); + + it("should ignore an empty export name", () => { + expect( + getImplementationModuleRef({ path: "/abs/utils.js", export: "" }), + ).toEqual({ path: "/abs/utils.js" }); + }); + + it("should return undefined for functions and other values", () => { + expect(getImplementationModuleRef(terserMinify)).toBeUndefined(); + expect(getImplementationModuleRef(null)).toBeUndefined(); + expect( + getImplementationModuleRef({ export: "terserMinify" }), + ).toBeUndefined(); + }); +}); + +describe("loadImplementation", () => { + it("should return a function implementation as-is", () => { + expect(loadImplementation(terserMinify)).toBe(terserMinify); + }); + + it("should load a named export from { path, export }", () => { + expect( + loadImplementation({ + path: require.resolve("../src/utils.js"), + export: "terserMinify", + }), + ).toBe(terserMinify); + }); + + it("should load module.exports when it is the function", () => { + const fixture = path.resolve( + __dirname, + "./fixtures/minify-default-export.js", + ); + + expect(loadImplementation(fixture)).toBe(require(fixture)); + }); + + it("should load the default export when the module is not a function", () => { + const fixture = path.resolve( + __dirname, + "./fixtures/minify-default-property.js", + ); + + expect(loadImplementation(fixture)).toBe(require(fixture).default); + }); + + it("should throw for an invalid implementation value", () => { + expect(() => loadImplementation(null)).toThrow( + /expected a function, module path string, or \{ path, export \}/, + ); + }); + + it("should throw when a named export is not a function", () => { + expect(() => + loadImplementation({ + path: require.resolve("../src/utils.js"), + export: "CLASSIC_SCRIPT", + }), + ).toThrow(/Minimizer export "CLASSIC_SCRIPT" is not a function/); + }); + + it("should throw when the module does not export a function", () => { + expect(() => + loadImplementation(require.resolve("../src/utils.js")), + ).toThrow(/Minimizer module does not export a function/); + }); +}); + +describe("canMinifyByPath", () => { + const utilsPath = require.resolve("../src/utils.js"); + const pathImpl = { path: utilsPath, export: "terserMinify" }; + + it("should allow a single path implementation", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: pathImpl }, + }), + ).toBe(true); + }); + + it("should allow a string path implementation", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: path.resolve( + __dirname, + "./fixtures/minify-default-export.js", + ), + }, + }), + ).toBe(true); + }); + + it("should reject an inline function implementation", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: terserMinify }, + }), + ).toBe(false); + }); + + it("should reject a function a `Map` option holds", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: pathImpl, + options: { rules: new Map([["one", () => true]]) }, + }, + }), + ).toBe(false); + }); + + it("should reject a function a `Set` option holds", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: pathImpl, + options: { rules: new Set([() => true]) }, + }, + }), + ).toBe(false); + }); + + it("should allow a `Map` option holding no function", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: pathImpl, + options: { rules: new Map([["one", "two"]]) }, + }, + }), + ).toBe(true); + }); + + it("should allow embedded when every implementation is a path", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: [pathImpl] }, + embedded: { + implementation: pathImpl, + options: {}, + claims: [], + offers: [], + at: [0], + }, + }), + ).toBe(true); + }); + + it("should reject embedded when any implementation is a function", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: [pathImpl] }, + embedded: { + implementation: [pathImpl, terserMinify], + options: [{}, {}], + claims: [[], []], + offers: [[], []], + at: [0], + }, + }), + ).toBe(false); + }); +}); diff --git a/test/minify-option.test.js b/test/minify-option.test.js index 946a3fca..147f251d 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1,6 +1,8 @@ import fs from "fs"; import path from "path"; +import del from "del"; + import MinimizerPlugin from "../src"; import { cleanCssMinify, @@ -1864,4 +1866,51 @@ describe("minify option written as an object", () => { }), ).toThrow(/`minify` sets its own `options`/); }); + + it("should emit the same file wherever the minimizer's module sits", async () => { + const roots = path.resolve(__dirname, "./helpers/dist/checkouts"); + const minimizer = + "module.exports = (input) => ({ code: Object.values(input)[0] });\n"; + const entry = 'export default "one";\n'; + + /** + * @param {string} root a checkout of the same two files + * @param {string} named how the minimizer's module is spelled + * @returns {Promise} the names it emitted + */ + const namesFrom = async (root, named = "mini.js") => { + const context = path.join(roots, root, "src"); + + fs.mkdirSync(context, { recursive: true }); + fs.writeFileSync(path.join(roots, root, "mini.js"), minimizer); + fs.writeFileSync(path.join(context, "entry.js"), entry); + + const compiler = getCompiler({ + context, + entry: "./entry.js", + output: { + path: path.resolve(__dirname, "./dist-terser"), + filename: "[name].[fullhash].js", + }, + }); + + new MinimizerPlugin({ + minify: path.join(roots, root, named), + }).apply(compiler); + + return Object.keys((await compile(compiler)).compilation.assets); + }; + + // The same minimizer, in the same place relative to the build, under two + // different roots: what is emitted cannot vary with where the checkout is. + expect(await namesFrom("one")).toStrictEqual(await namesFrom("two")); + + // And one module named two ways is still one module, which only the file + // `require` would reach says. + expect(await namesFrom("one", "mini")).toStrictEqual( + await namesFrom("one", "mini.js"), + ); + + await del(roots); + }); }); diff --git a/test/parallel-option.test.js b/test/parallel-option.test.js index 04409835..8396e554 100644 --- a/test/parallel-option.test.js +++ b/test/parallel-option.test.js @@ -3,8 +3,9 @@ import path from "path"; import { Worker } from "jest-worker"; +import { canMinifyByPath } from "../src/implementation.js"; import MinimizerPlugin from "../src/index"; -import { transform } from "../src/minify.js"; +import { minify as minifyWorker, transform } from "../src/minify.js"; import serialize from "../src/serialize-javascript.js"; import { terserMinify } from "../src/utils.js"; @@ -31,6 +32,7 @@ jest.mock("os", () => { // Based on https://github.com/facebook/jest/blob/edde20f75665c2b1e3c8937f758902b5cf28a7b4/packages/jest-runner/src/__tests__/test_runner.test.js let workerTransform; +let workerMinify; let workerEnd; const ENABLE_WORKER_THREADS = @@ -43,6 +45,9 @@ jest.mock("jest-worker", () => ({ transform: (workerTransform = jest.fn((data) => require(workerPath).transform(data), )), + minify: (workerMinify = jest.fn((data) => + require(workerPath).minify(data), + )), end: (workerEnd = jest.fn()), getStderr: jest.fn(), getStdout: jest.fn(), @@ -85,9 +90,16 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: getParallelism() - 1, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); + expect(workerTransform).not.toHaveBeenCalled(); + expect(workerMinify.mock.calls[0][0].minimizer.implementation).toEqual([ + { + path: require.resolve("../src/utils.js"), + export: "terserMinify", + }, + ]); expect(workerEnd).toHaveBeenCalledTimes(1); expect(readsAssets(compiler, stats)).toMatchSnapshot("assets"); @@ -95,6 +107,134 @@ describe("parallel option", () => { expect(getWarnings(stats)).toMatchSnapshot("warnings"); }); + it("should use transform when implementation is an inline function", async () => { + const impl = async (input, map, options, extractComments) => + terserMinify(input, map, options, extractComments); + + new MinimizerPlugin({ parallel: true, minify: impl }).apply(compiler); + + const stats = await compile(compiler); + + expect(Worker).toHaveBeenCalledTimes(1); + expect(workerTransform).toHaveBeenCalledTimes( + Object.keys(stats.compilation.assets).length, + ); + expect(workerMinify).not.toHaveBeenCalled(); + expect(workerEnd).toHaveBeenCalledTimes(1); + }); + + it("should minify by path when implementation is a module path string", async () => { + new MinimizerPlugin({ + parallel: true, + minify: path.resolve(__dirname, "./fixtures/minify-default-export.js"), + }).apply(compiler); + + await compile(compiler); + + expect(workerMinify).toHaveBeenCalled(); + expect(workerTransform).not.toHaveBeenCalled(); + expect(workerMinify.mock.calls[0][0].minimizer.implementation).toEqual([ + path.resolve(__dirname, "./fixtures/minify-default-export.js"), + ]); + }); + + it("should use transform where one of several is an inline function", async () => { + new MinimizerPlugin({ + parallel: true, + minify: [ + path.resolve(__dirname, "./fixtures/minify-default-export.js"), + async (input, map, options, extractComments) => + terserMinify(input, map, options, extractComments), + ], + }).apply(compiler); + + await compile(compiler); + + // A worker requires what it is given a path to, and rebuilds what it is + // given a function from — one of each leaves the whole task on the second. + expect(workerTransform).toHaveBeenCalled(); + expect(workerMinify).not.toHaveBeenCalled(); + }); + + it("should cache a path minimizer apart from another path", async () => { + /** + * @param {string} fixture the minimizer's module + * @returns {Promise} the cache identifiers it asked for + */ + const identifiersFrom = async (fixture) => { + const own = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + }); + const asked = []; + + own.cache.hooks.get.tap({ name: "ReadCacheKeys", stage: -100 }, (id) => { + if (id.includes("TerserWebpackPlugin")) { + asked.push(id); + } + }); + new MinimizerPlugin({ + parallel: true, + minify: path.resolve(__dirname, fixture), + }).apply(own); + + await compile(own); + + return asked; + }; + + const first = await identifiersFrom("./fixtures/minify-default-export.js"); + const second = await identifiersFrom( + "./fixtures/minify-default-property.js", + ); + + // Neither module reports a version, so the path is all that tells them + // apart — a warm cache would otherwise answer for whichever ran first. + expect(first.length).toBeGreaterThan(0); + expect(second).not.toEqual(first); + }); + + it("should use transform when `extractComments` is a function", async () => { + new MinimizerPlugin({ + parallel: true, + extractComments: (astNode, comment) => comment.value.includes("@license"), + }).apply(compiler); + + const stats = await compile(compiler); + + // The payload reaches a required minimizer as it is, and a structured + // clone throws on a function rather than dropping it. + expect(workerTransform).toHaveBeenCalled(); + expect(workerMinify).not.toHaveBeenCalled(); + expect(getErrors(stats)).toEqual([]); + }); + + it("should use transform when a minimizer's own options hold a function", async () => { + new MinimizerPlugin({ + parallel: true, + minimizerOptions: { + format: { comments: (astNode, comment) => comment.value.length > 0 }, + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(workerTransform).toHaveBeenCalled(); + expect(workerMinify).not.toHaveBeenCalled(); + expect(getErrors(stats)).toEqual([]); + }); + + it("should minify by path when extractComments is a RegExp", async () => { + new MinimizerPlugin({ + parallel: true, + extractComments: /license/i, + }).apply(compiler); + + await compile(compiler); + + expect(workerMinify).toHaveBeenCalled(); + expect(workerTransform).not.toHaveBeenCalled(); + }); + it('should match snapshot for the "false" value', async () => { new MinimizerPlugin({ parallel: false }).apply(compiler); @@ -117,7 +257,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: getParallelism() - 1, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -137,7 +277,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: getParallelism() - 1, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -157,7 +297,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: 2, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -181,7 +321,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(1, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -209,7 +349,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(Object.keys(entries).length, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -237,7 +377,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(Object.keys(entries).length, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -276,7 +416,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(Object.keys(entries).length, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -307,6 +447,27 @@ describe("parallel option", () => { }); describe("worker", () => { + it("should minify via implementation path without serialize/new Function", async () => { + const options = { + name: "test1.js", + input: "var foo = 1;/* hello */", + minimizer: { + implementation: { + path: require.resolve("../src/utils.js"), + export: "terserMinify", + }, + }, + extractComments: false, + }; + + expect(canMinifyByPath(options)).toBe(true); + + const workerResult = await minifyWorker(options); + + expect(workerResult.code).toContain("foo"); + expect(workerResult).toMatchSnapshot(); + }); + it('should match snapshot when options.extractComments is "false"', async () => { const options = { name: "test1.js", diff --git a/test/stage-option.test.js b/test/stage-option.test.js index 3a81bad7..d0346b96 100644 --- a/test/stage-option.test.js +++ b/test/stage-option.test.js @@ -517,6 +517,51 @@ describe("a minimizer that asks for its own stage", () => { expect(getErrors(stats)).toEqual([]); }); + it("should run a minimizer named by module path where it asks", async () => { + /** + * @param {EXPECTED_ANY} minify what to minify with + * @returns {Promise<{ names: string[], printed: string }>} what it emitted + */ + const emittedBy = async (minify) => { + const own = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[contenthash].js", + }, + }); + + new MinimizerPlugin({ + test: /\.js$/i, + parallel: false, + minify, + minimizerOptions: { algorithm: "gzip" }, + }).apply(own); + + const stats = await compile(own); + + return { + names: Object.keys(stats.compilation.assets).sort(), + printed: stats.toString({ relatedAssets: true }), + }; + }; + + const byFunction = await emittedBy(MinimizerPlugin.compress); + // The helpers saying where it runs and what it marks are on the loaded + // function, not on this reference. + const byPath = await emittedBy({ + path: require.resolve("../src/utils.js"), + export: "compress", + }); + + // `compress` asks to run after the hash is taken, so the name is of what + // was compressed rather than of the compressed bytes — either way of + // naming it. + expect(byPath.names).toEqual(byFunction.names); + expect(byPath.printed).toContain("[compressed]"); + expect(byPath.printed).not.toContain("[minimized]"); + }); + it("should put `compress` after the minimizers on its own", async () => { const order = []; diff --git a/types/implementation.d.ts b/types/implementation.d.ts new file mode 100644 index 00000000..b2499287 --- /dev/null +++ b/types/implementation.d.ts @@ -0,0 +1,50 @@ +export type MinimizedResult = import("./index.js").MinimizedResult; +export type CustomOptions = import("./index.js").CustomOptions; +export type MinimizeFunctionHelpers = + import("./index.js").MinimizeFunctionHelpers; +export type ImplementationModuleRef = + import("./index.js").ImplementationModuleRef; +export type MinimizerFn = + import("./index.js").BasicMinimizerImplementation & + MinimizeFunctionHelpers; +/** + * True when every `minimizer.implementation` is a module path (`string` or + * `{ path, export }`). Inline minify functions keep `transform`. When + * `embedded` is present, *every* configured implementation must be a path — + * a single inline function in the embedded set forces `transform` for the + * whole asset task, even if that asset's own matched minimizers are paths. + * @template T + * @param {import("./index.js").InternalOptions} options options + * @returns {boolean} whether `worker.minify` can run without `transform` + */ +export function canMinifyByPath( + options: import("./index.js").InternalOptions, +): boolean; +/** @typedef {import("./index.js").MinimizedResult} MinimizedResult */ +/** @typedef {import("./index.js").CustomOptions} CustomOptions */ +/** @typedef {import("./index.js").MinimizeFunctionHelpers} MinimizeFunctionHelpers */ +/** @typedef {import("./index.js").ImplementationModuleRef} ImplementationModuleRef */ +/** + * @typedef {import("./index.js").BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerFn + */ +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {ImplementationModuleRef | undefined} how to `require` it in a worker + */ +export function getImplementationModuleRef( + implementation: unknown, +): ImplementationModuleRef | undefined; +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {MinimizerFn} the minify function + */ +export function loadImplementation(implementation: unknown): MinimizerFn; +/** + * The file `loadImplementation` would `require`, which is what tells two + * references apart: a bare specifier and a file of that name are not one module. + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {string | undefined} its resolved module, or nothing where no module is named + */ +export function resolveImplementationModule( + implementation: unknown, +): string | undefined; diff --git a/types/index.d.ts b/types/index.d.ts index d235a224..a5b95f0a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -67,28 +67,26 @@ declare class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {Record} assets assets - * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset, and what an earlier pass of this plugin already wrote onto each asset + * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset and from a run under different minimizers, and what an earlier pass of this plugin already wrote onto each asset * @returns {Promise} */ private optimize; /** - * Every configured minimizer, in order. The `minify` option takes one or an - * array; embedded source is dispatched across all of them either way. + * One slot per configured minimizer: the option value for workers (path / + * function) and the loaded function for helpers (`getTypes`, `filter`, …). * @private - * @returns {(BasicMinimizerImplementation & MinimizeFunctionHelpers)[]} the minimizers + * @returns {{ implementation: MinimizerImplementationValue, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} loaded slots */ - private minimizers; + private getMinimizerSlots; /** - * Every configured minimizer and its options, for dispatching source one - * language embeds in another. The asset's own entry holds only what its - * filename matched, and a language's minimizer need not be among them — a - * `.css` asset embedding an `` reaches an SVG minifier that claims no - * asset at all. + * Build the embedded minimizer payload from already-loaded slots (path or + * function kept as configured; `fn` supplies claims / offers). * @private * @param {number[]} matched indices of the minimizers this input's own entry holds + * @param {{ implementation: unknown, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} slots loaded minimizer slots * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] } | undefined} every configured minimizer, or undefined when nothing nested could be reached */ - private embeddedMinimizer; + private embeddedFromSlots; /** * One generator, however it was written: as the generator itself or as an * object stating how to run it. @@ -308,10 +306,13 @@ declare namespace MinimizerPlugin { MinimizerOptions, BasicMinimizerImplementation, MinimizeFunctionHelpers, + ImplementationModuleRef, + MinimizerImplementationValue, MinimizerImplementation, InternalOptions, MinimizerWorker, Parallel, + GeneratorImplementation, GeneratorDescriptor, Generate, BasePluginOptions, @@ -585,12 +586,21 @@ type MinimizeFunctionHelpers = { */ getAssetFlag?: (() => string | undefined) | undefined; }; +/** + * Module path form of `minimizer.implementation` (like sass-loader): the worker + * `require`s it instead of evaluating serialized function source via `new Function`. + */ +type ImplementationModuleRef = { + path: string; + export?: string; +}; +type MinimizerImplementationValue = + | (BasicMinimizerImplementation & MinimizeFunctionHelpers) + | string + | ImplementationModuleRef; type MinimizerImplementation = T extends EXPECTED_ANY[] - ? { - [P in keyof T]: BasicMinimizerImplementation & - MinimizeFunctionHelpers; - } - : BasicMinimizerImplementation & MinimizeFunctionHelpers; + ? { [P in keyof T]: MinimizerImplementationValue } + : MinimizerImplementationValue; type InternalOptions = { /** * name @@ -616,7 +626,7 @@ type InternalOptions = { options: MinimizerOptions; }; /** - * every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` is the languages each minifies and `offers` the languages each can hand out, both as data and both parallel to `implementation`, since a minify function reaches a worker as source and carries none of its properties; `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all + * every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` / `offers` travel as data parallel to `implementation` so the legacy serialize path still knows what each entry minifies and can nest (a function shipped as source loses its helpers; a module path `require` restores them, but the arrays stay so both paths share one shape). `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all */ embedded?: | { @@ -641,6 +651,12 @@ type MinimizerWorker = JestWorker & { minify: (options: InternalOptions) => Promise; }; type Parallel = undefined | boolean | number; +/** + * A generator is the function itself: nothing `require`s one in a worker, and + * the schema takes no module path for it. + */ +type GeneratorImplementation = BasicMinimizerImplementation & + MinimizeFunctionHelpers; /** * One generator, written as an object stating how to run it. */ @@ -648,7 +664,7 @@ type GeneratorDescriptor = { /** * the generator itself */ - implementation: MinimizerImplementation; + implementation: GeneratorImplementation; /** * options for this generator, preferred over the deprecated `generatorOptions` */ @@ -687,13 +703,13 @@ type GeneratorDescriptor = { * descriptor, or an object naming descriptors an asset asks for with `?as=`. */ type Generate = - | MinimizerImplementation - | MinimizerImplementation[] + | GeneratorImplementation + | GeneratorImplementation[] | GeneratorDescriptor | { [preset: string]: - | MinimizerImplementation - | MinimizerImplementation[] + | GeneratorImplementation + | GeneratorImplementation[] | GeneratorDescriptor; }; type BasePluginOptions = { diff --git a/types/minify.d.ts b/types/minify.d.ts index d3ca4691..7d21c9aa 100644 --- a/types/minify.d.ts +++ b/types/minify.d.ts @@ -2,6 +2,14 @@ export type MinimizedResult = import("./index.js").MinimizedResult; export type CustomOptions = import("./index.js").CustomOptions; export type RawSourceMap = import("./index.js").RawSourceMap; export type EXPECTED_ANY = import("./index.js").EXPECTED_ANY; +export type MinimizeFunctionHelpers = + import("./index.js").MinimizeFunctionHelpers; +/** + * A concrete minify function, including optional worker-path helpers. + */ +export type MinimizerFn = + import("./index.js").BasicMinimizerImplementation & + MinimizeFunctionHelpers; export type MinimizerOptions = import("./index.js").MinimizerOptions; /** * @template T