diff --git a/src/implementation.js b/src/implementation.js new file mode 100644 index 0000000..ea63f1d --- /dev/null +++ b/src/implementation.js @@ -0,0 +1,112 @@ +/** @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); +} + +/** + * 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; + } + + if (!options.embedded) { + return true; + } + + const embedded = Array.isArray(options.embedded.implementation) + ? options.embedded.implementation + : [options.embedded.implementation]; + + return embedded.every(hasPath); +} + +module.exports = { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, +}; diff --git a/src/index.js b/src/index.js index 362e062..7a9ab4b 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,11 @@ const crypto = require("crypto"); const os = require("os"); const path = require("path"); +const { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, +} = require("./implementation"); const { minify } = require("./minify"); const { cleanCssMinify, @@ -175,9 +180,20 @@ const { * @property {(minimizerOptions?: EXPECTED_OBJECT) => string[] | undefined=} getEmbeddedTypes the languages this minimizer can hand out from inside what it minifies, through the `renderEmbeddedSource` option. Empty (or absent) means it nests nothing a caller can reach, and the option is not passed */ +/** + * 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 {(BasicMinimizerImplementation & MinimizeFunctionHelpers) | string | ImplementationModuleRef} MinimizerImplementationValue + */ + /** * @template T - * @typedef {T extends EXPECTED_ANY[] ? { [P in keyof T]: BasicMinimizerImplementation & MinimizeFunctionHelpers } : BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerImplementation + * @typedef {T extends EXPECTED_ANY[] ? { [P in keyof T]: MinimizerImplementationValue } : MinimizerImplementationValue} MinimizerImplementation */ /** @@ -188,7 +204,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 */ @@ -253,7 +269,10 @@ class TerserPlugin { // TODO make `minimizer` option instead `minify` and `terserOptions` in the next major release, also rename `terserMinify` to `terserMinimize` const { minify = /** @type {MinimizerImplementation} */ ( - /** @type {unknown} */ (terserMinify) + /** @type {unknown} */ ({ + path: require.resolve("./utils.js"), + export: "terserMinify", + }) ), minimizerOptions, terserOptions, @@ -490,13 +509,9 @@ class TerserPlugin { */ 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(); /** * Collect the indices of minimizers whose `filter` accepts `name`. @@ -504,21 +519,19 @@ class TerserPlugin { * 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 = []; const { filters } = this.options.minimizer; - for (let i = 0; i < implementations.length; i++) { - const impl = implementations[i]; + for (let i = 0; i < minimizerSlots.length; i++) { + const { fn } = minimizerSlots[i]; // What `minify` states about this entry answers for it; the property on // 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); @@ -599,14 +612,20 @@ class TerserPlugin { // 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 = implementations.map( - (impl) => - typeof impl.supportsBinary === "function" && impl.supportsBinary(), + const binaryCapable = minimizerSlots.map( + ({ fn }) => + typeof fn.supportsBinary === "function" && fn.supportsBinary(), + ); + const enableWorkerThreads = minimizerSlots.every( + ({ fn }, i) => + !workerCapable[i] || + typeof fn.supportsWorkerThreads === "undefined" || + fn.supportsWorkerThreads() !== false, ); const needCreateWorker = optimizeOptions.availableNumberOfCores > 0 && @@ -632,12 +651,7 @@ class TerserPlugin { 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, }) ); @@ -666,10 +680,21 @@ class TerserPlugin { * @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} */ @@ -718,7 +743,7 @@ class TerserPlugin { // `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} */ @@ -740,7 +765,7 @@ class TerserPlugin { options: assetMinimizerOptions, }, extractComments: this.options.extractComments, - embedded: this.embeddedMinimizer(matched), + embedded: this.embeddedFromSlots(matched, minimizerSlots), }; if (typeof info.javascriptModule !== "undefined") { @@ -1062,42 +1087,43 @@ class TerserPlugin { } /** - * 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( @@ -1121,13 +1147,17 @@ class TerserPlugin { 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), ) ) @@ -1503,14 +1533,14 @@ class TerserPlugin { */ 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); @@ -1565,7 +1595,10 @@ class TerserPlugin { minimizer: { implementation: /** @type {MinimizerImplementation} */ - (/** @type {unknown} */ (matched.map((i) => minimizers[i]))), + ( + /** @type {unknown} */ + (matched.map((i) => minimizerSlots[i].fn)) + ), options: /** @type {MinimizerOptions} */ ( @@ -1577,7 +1610,7 @@ class TerserPlugin { ) ), }, - embedded: this.embeddedMinimizer(matched), + embedded: this.embeddedFromSlots(matched, minimizerSlots), ecma: getEcmaVersion( /** @type {NonNullable["environment"]>} */ (compiler.options.output.environment), @@ -1947,18 +1980,32 @@ class TerserPlugin { 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" + 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 fn = + typeof impl === "function" + ? impl + : getImplementationModuleRef(impl) + ? loadImplementation(impl) + : undefined; + + if (!fn) { + return "0.0.0"; + } + + return typeof fn.getMinimizerVersion !== "undefined" + ? fn.getMinimizerVersion() || "0.0.0" : "0.0.0"; + }; 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, @@ -2010,7 +2057,7 @@ class TerserPlugin { generator: Array.isArray(moduleGenerator.implementation) ? moduleGenerator.implementation.map(getVersion) : getVersion( - /** @type {BasicMinimizerImplementation & MinimizeFunctionHelpers} */ + /** @type {MinimizerImplementationValue} */ (moduleGenerator.implementation), ), options: moduleGenerator.options, diff --git a/src/minify.js b/src/minify.js index e704e43..425c029 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 c91fd78..85d2f40 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,11 +215,11 @@ ] }, "minify": { - "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included.", + "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. 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", @@ -198,7 +227,7 @@ "items": { "anyOf": [ { - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, { "type": "object", @@ -206,7 +235,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.", @@ -229,7 +258,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__/parallel-option.test.js.snap b/test/__snapshots__/parallel-option.test.js.snap index f59caf1..3b7ceb1 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__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index ebb9b02..85af0ee 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -132,13 +132,20 @@ exports[`validation validate 9`] = ` exports[`validation validate 10`] = ` "Invalid options object. Terser 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? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } - -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. + function | non-empty string | object { path, export? } | [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } + -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. 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? }, ...] (should not have fewer than 1 item) + [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) * options.minify should be an object: object { implementation, options?, filter? }" `; diff --git a/test/extractComments-option.test.js b/test/extractComments-option.test.js index 528960b..cff8415 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: { @@ -642,7 +651,7 @@ describe("extractComments option", () => { }, }); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { filename: (fileData) => fileData.filename === "b.js" ? "b.txt" : "shared.txt", diff --git a/test/fixtures/minify-default-export.js b/test/fixtures/minify-default-export.js new file mode 100644 index 0000000..2898d29 --- /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 0000000..26e6e5b --- /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 0000000..26bf619 --- /dev/null +++ b/test/implementation.test.js @@ -0,0 +1,163 @@ +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 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/parallel-option.test.js b/test/parallel-option.test.js index 0440983..f358e3c 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,49 @@ 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 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 +172,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 +192,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 +212,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 +236,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 +264,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 +292,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 +331,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 +362,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/types/implementation.d.ts b/types/implementation.d.ts new file mode 100644 index 0000000..eed3b9b --- /dev/null +++ b/types/implementation.d.ts @@ -0,0 +1,41 @@ +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; diff --git a/types/index.d.ts b/types/index.d.ts index 6c746ba..e16ef3a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -72,23 +72,21 @@ declare class TerserPlugin { */ 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. @@ -274,6 +272,8 @@ declare namespace TerserPlugin { MinimizerOptions, BasicMinimizerImplementation, MinimizeFunctionHelpers, + ImplementationModuleRef, + MinimizerImplementationValue, MinimizerImplementation, InternalOptions, MinimizerWorker, @@ -534,12 +534,21 @@ type MinimizeFunctionHelpers = { getEmbeddedTypes?: ((minimizerOptions?: EXPECTED_OBJECT) => 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 @@ -565,7 +574,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?: | { diff --git a/types/minify.d.ts b/types/minify.d.ts index d3ca469..7d21c9a 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