diff --git a/README.md b/README.md index 31c193e..0695979 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,9 @@ type filename = string | ((pathdata: PathData) => string); Default: `"[path][base].gz"` -The target asset filename. +The target asset filename. A name that comes out as the original's own replaces +that asset with its compressed bytes, rather than writing a second file beside +it. #### `string` @@ -389,11 +391,11 @@ Default: `false` Determines whether the original (uncompressed) assets should be deleted after compression. -- If set to `true` , all original assets will be deleted. +- If set to `true` , all original assets will be deleted — each original file and nothing else. Its source map, and a file another compression plugin wrote beside it, are kept. -- If set to `"keep-source-map"`, all original assets except source maps (`.map` files) will be deleted. +- `"keep-source-map"` is what `true` already does, and is kept for compatibility. -- If a function is provided, it will be called with each asset’s name and should return `true` to delete the asset or `false` to keep it. +- If a function is provided, it will be called with each asset’s name and should return `true` to delete the asset or `false` to keep it. An asset it keeps records the compressed file in its `related` info, the same as when nothing is deleted. Example: diff --git a/package.json b/package.json index 9e2be61..66e3f58 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "release": "standard-version" }, "dependencies": { + "minimizer-webpack-plugin": "^5.11.0", "schema-utils": "^4.2.0", "serialize-javascript": "^7.0.3" }, diff --git a/src/index.js b/src/index.js index 472aaa5..7558d72 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ const crypto = require("node:crypto"); const path = require("node:path"); +const MinimizerPlugin = require("minimizer-webpack-plugin"); const { validate } = require("schema-utils"); const serialize = require("serialize-javascript"); @@ -190,226 +191,31 @@ class CompressionPlugin { } /** + * The key the compressed file is recorded under on the asset it came from, + * which is how a dev server finds it and how an asset that already has one + * is declined. * @private - * @param {Buffer} input input - * @returns {Promise} compressed buffer + * @returns {string} the key */ - runCompressionAlgorithm(input) { - return new Promise((resolve, reject) => { - this.algorithm( - input, - this.options.compressionOptions, - (error, result) => { - if (error) { - reject(error); - - return; - } - - if (!Buffer.isBuffer(result)) { - resolve(Buffer.from(/** @type {string} */ (result))); - } else { - resolve(result); - } - }, - ); - }); - } - - /** - * @private - * @param {Compiler} compiler compiler - * @param {Compilation} compilation compilation - * @param {Record} assets assets - * @returns {Promise} - */ - async compress(compiler, compilation, assets) { - const cache = compilation.getCache("CompressionWebpackPlugin"); + relatedName() { + const { algorithm, filename } = this.options; - /** - * @typedef {object} AssetForCompression - * @property {string} name name - * @property {Source} source source - * @property {{ source: Source, compressed: Buffer }} output output - * @property {AssetInfo} info asset info - * @property {Buffer} buffer buffer - * @property {ReturnType["getItemCache"]>} cacheItem cache item - * @property {string} relatedName related name - */ + if (typeof algorithm !== "function") { + return algorithm === "gzip" ? "gzipped" : `${algorithm}ed`; + } - const assetsForCompression = ( - await Promise.all( - Object.keys(assets).map(async (name) => { - const { info, source } = - /** @type {Asset} */ - (compilation.getAsset(name)); - - if (info.compressed) { - return false; - } - - if ( - !compiler.webpack.ModuleFilenameHelpers.matchObject.bind( - undefined, - this.options, - )(name) - ) { - return false; - } - - /** - * @type {string | undefined} - */ - let relatedName; - - if (typeof this.options.algorithm === "function") { - if (typeof this.options.filename === "function") { - relatedName = `compression-function-${crypto - .createHash("md5") - .update(serialize(this.options.filename)) - .digest("hex")}`; - } else { - /** - * @type {string} - */ - let filenameForRelatedName = this.options.filename; - - const index = filenameForRelatedName.indexOf("?"); - - if (index >= 0) { - filenameForRelatedName = filenameForRelatedName.slice(0, index); - } - - relatedName = `${path - .extname(filenameForRelatedName) - .slice(1)}ed`; - } - } else if (this.options.algorithm === "gzip") { - relatedName = "gzipped"; - } else { - relatedName = `${this.options.algorithm}ed`; - } - - if (info.related && info.related[relatedName]) { - return false; - } - - const cacheItem = cache.getItemCache( - serialize({ - name, - algorithm: this.options.algorithm, - compressionOptions: this.options.compressionOptions, - }), - cache.getLazyHashedEtag(source), - ); - const output = (await cacheItem.getPromise()) || {}; - - let buffer; - - // No need original buffer for cached files - if (!output.source) { - if (typeof source.buffer === "function") { - buffer = source.buffer(); - } - // Compatibility with webpack plugins which don't use `webpack-sources` - // See https://github.com/webpack/compression-webpack-plugin/issues/236 - else { - buffer = source.source(); - - if (!Buffer.isBuffer(buffer)) { - buffer = Buffer.from(buffer); - } - } - - if (buffer.length < this.options.threshold) { - return false; - } - } - - return { name, source, info, buffer, output, cacheItem, relatedName }; - }), - ) - ).filter(Boolean); - - const { RawSource } = compiler.webpack.sources; - const scheduledTasks = []; - - for (const asset of assetsForCompression) { - scheduledTasks.push( - (async () => { - const { name, source, buffer, output, cacheItem, info, relatedName } = - /** @type {AssetForCompression} */ - (asset); - - if (!output.source) { - if (!output.compressed) { - try { - output.compressed = await this.runCompressionAlgorithm(buffer); - } catch (error) { - compilation.errors.push(/** @type {WebpackError} */ (error)); - - return; - } - } - - if ( - output.compressed.length / buffer.length > - this.options.minRatio - ) { - await cacheItem.storePromise({ compressed: output.compressed }); - - return; - } - - output.source = new RawSource(output.compressed); - - await cacheItem.storePromise(output); - } - - const newFilename = compilation.getPath(this.options.filename, { - filename: name, - }); - /** @type {AssetInfo} */ - const newInfo = { compressed: true }; - - // TODO: possible problem when developer uses custom function, ideally we need to get parts of filename (i.e. name/base/ext/etc) in info - // otherwise we can't detect an asset as immutable - if ( - info.immutable && - typeof this.options.filename === "string" && - /(\[name]|\[base]|\[file])/.test(this.options.filename) - ) { - newInfo.immutable = true; - } - - if (this.options.deleteOriginalAssets) { - if (this.options.deleteOriginalAssets === "keep-source-map") { - compilation.updateAsset(name, source, { - related: { sourceMap: null }, - }); - - compilation.deleteAsset(name); - } else if ( - typeof this.options.deleteOriginalAssets === "function" - ) { - if (this.options.deleteOriginalAssets(name)) { - compilation.deleteAsset(name); - } - } else { - compilation.deleteAsset(name); - } - } else { - compilation.updateAsset(name, source, { - related: { [relatedName]: newFilename }, - }); - } - - compilation.emitAsset(newFilename, output.source, newInfo); - })(), - ); + if (typeof filename === "function") { + return `compression-function-${crypto + .createHash("md5") + .update(serialize(filename)) + .digest("hex")}`; } - await Promise.all(scheduledTasks); + const queryIndex = filename.indexOf("?"); + const withoutQuery = + queryIndex >= 0 ? filename.slice(0, queryIndex) : filename; + + return `${path.extname(withoutQuery).slice(1)}ed`; } /** @@ -417,35 +223,46 @@ class CompressionPlugin { * @returns {void} */ apply(compiler) { - const pluginName = this.constructor.name; - - compiler.hooks.thisCompilation.tap(pluginName, (compilation) => { - compilation.hooks.processAssets.tapPromise( - { - name: pluginName, - stage: - compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, - additionalAssets: true, - }, - (assets) => this.compress(compiler, compilation, assets), - ); - - compilation.hooks.statsPrinter.tap(pluginName, (stats) => { - stats.hooks.print - .for("asset.info.compressed") - .tap( - "compression-webpack-plugin", - (compressed, { green, formatFlag }) => - compressed - ? /** @type {((value: string | number) => string)} */ - (green)( - /** @type {(prefix: string) => string} */ - (formatFlag)("compressed"), - ) - : "", - ); - }); - }); + const { + test, + include, + exclude, + algorithm, + compressionOptions, + filename, + threshold, + minRatio, + deleteOriginalAssets, + } = this.options; + + // Reading an asset, writing one beside it, caching both and running them + // where they belong is the same work whether the bytes come back smaller + // or differently encoded, and `minimizer-webpack-plugin` already does it. + // What stays here is what compression means by it. + new MinimizerPlugin({ + // Every asset unless told otherwise, where the engine's own default is + // the JavaScript one belongs to minifying. + test: typeof test === "undefined" ? /.*/ : test, + include, + exclude, + // This plugin compresses what a build emitted and changes none of it. + minify: [], + generate: { + implementation: MinimizerPlugin.compress, + options: { algorithm, compressionOptions }, + type: "asset", + filename, + threshold, + minRatio, + // The generator deletes the file it read and nothing its `related` + // names, so keeping the source map is what `true` already does. + deleteOriginalAssets: + deleteOriginalAssets === "keep-source-map" + ? true + : deleteOriginalAssets, + relatedName: this.relatedName(), + }, + }).apply(compiler); } } diff --git a/test/__snapshots__/algorithm.test.js.snap b/test/__snapshots__/algorithm.test.js.snap index d9a91d5..a676d66 100644 --- a/test/__snapshots__/algorithm.test.js.snap +++ b/test/__snapshots__/algorithm.test.js.snap @@ -253,10 +253,14 @@ exports[`"algorithm" option matches snapshot for custom function with error ({Fu exports[`"algorithm" option matches snapshot for custom function with error ({Function}): errors 1`] = ` [ - "Error", - "Error", - "Error", - "Error", + "Error: 09a1a1112c577c2794359715edfcb5ac.png from minimizer-webpack-plugin +Error", + "Error: 23fc1d3ac606d117e05a140e0de79806.svg from minimizer-webpack-plugin +Error", + "Error: async.async.55e6e9a872bcc7d4b226.js from minimizer-webpack-plugin +Error", + "Error: main.46d06887b61d39060e44.js from minimizer-webpack-plugin +Error", ] `; diff --git a/test/__snapshots__/deleteOriginalAssets.test.js.snap b/test/__snapshots__/deleteOriginalAssets.test.js.snap index 18958e2..0e9221c 100644 --- a/test/__snapshots__/deleteOriginalAssets.test.js.snap +++ b/test/__snapshots__/deleteOriginalAssets.test.js.snap @@ -60,6 +60,9 @@ exports[`"deleteOriginalAssets" option should work and delete original assets wh 78117, { "immutable": true, + "related": { + "gzipped": "09a1a1112c577c2794359715edfcb5ac.png.gz", + }, "size": 78117, "sourceFilename": "icon.png", }, @@ -78,6 +81,9 @@ exports[`"deleteOriginalAssets" option should work and delete original assets wh 672, { "immutable": true, + "related": { + "gzipped": "23fc1d3ac606d117e05a140e0de79806.svg.gz", + }, "size": 672, "sourceFilename": "icon.svg", }, @@ -384,7 +390,7 @@ exports[`"deleteOriginalAssets" option should work and keep original assets: err exports[`"deleteOriginalAssets" option should work and keep original assets: warnings 1`] = `[]`; -exports[`"deleteOriginalAssets" option should work and report errors on duplicate assets: assets 1`] = ` +exports[`"deleteOriginalAssets" option should work and write over the original where the filename is its own: assets 1`] = ` [ [ "09a1a1112c577c2794359715edfcb5ac.png", @@ -425,12 +431,6 @@ exports[`"deleteOriginalAssets" option should work and report errors on duplicat ] `; -exports[`"deleteOriginalAssets" option should work and report errors on duplicate assets: errors 1`] = ` -[ - "Error: Conflict: Multiple assets emit different content to the same filename 23fc1d3ac606d117e05a140e0de79806.svg", - "Error: Conflict: Multiple assets emit different content to the same filename async.async.55e6e9a872bcc7d4b226.js", - "Error: Conflict: Multiple assets emit different content to the same filename main.46d06887b61d39060e44.js", -] -`; +exports[`"deleteOriginalAssets" option should work and write over the original where the filename is its own: errors 1`] = `[]`; -exports[`"deleteOriginalAssets" option should work and report errors on duplicate assets: warnings 1`] = `[]`; +exports[`"deleteOriginalAssets" option should work and write over the original where the filename is its own: warnings 1`] = `[]`; diff --git a/test/deleteOriginalAssets.test.js b/test/deleteOriginalAssets.test.js index 89493e5..7a3cbef 100644 --- a/test/deleteOriginalAssets.test.js +++ b/test/deleteOriginalAssets.test.js @@ -72,7 +72,7 @@ describe('"deleteOriginalAssets" option', () => { expect(getErrors(stats)).toMatchSnapshot("errors"); }); - it("should work and report errors on duplicate assets", async () => { + it("should work and write over the original where the filename is its own", async () => { compiler = getCompiler("./entry.js"); new CompressionPlugin({ @@ -101,6 +101,35 @@ describe('"deleteOriginalAssets" option', () => { expect(getErrors(stats)).toMatchSnapshot("errors"); }); + it("should keep what a second instance wrote beside the deleted asset", async () => { + compiler = getCompiler("./entry.js"); + + new CompressionPlugin({ + algorithm: "brotliCompress", + filename: "[path][base].br", + }).apply(compiler); + new CompressionPlugin({ + algorithm: "gzip", + filename: "[path][base].gz", + deleteOriginalAssets: true, + }).apply(compiler); + + const stats = await compile(compiler); + const names = Object.keys(stats.compilation.assets); + + // Deleting an asset takes everything its `related` names with it, so the + // one deleting second must not take the first one's file too. + const brotli = names.filter((name) => name.endsWith(".br")); + const gzipped = names.filter((name) => name.endsWith(".gz")); + + expect(brotli.length).toBeGreaterThan(0); + expect(gzipped).toHaveLength(brotli.length); + // Or nothing deleting anything would satisfy the two above. + expect(names.some((name) => name.endsWith(".js"))).toBe(false); + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + }); + it('should delete original assets and keep source maps with option "keep-source-map"', async () => { compiler = getCompiler( "./entry.js",