From b7319aba887683f4cddddb654afd48c605f5e196 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Tue, 15 Sep 2026 12:31:39 +0000 Subject: [PATCH 1/8] refactor: read, write, cache and schedule through minimizer-webpack-plugin 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 the minimizer plugin already does it. What stays here is what compression means by it: which algorithm, what it is run with, and the key the compressed file is recorded under. Work in progress: it needs a release carrying that engine, which is not published yet, and three of its own options still have no answer there - deleteOriginalAssets as "keep-source-map" or a function, and filename as a function. Their tests fail. --- package.json | 1 + src/index.js | 298 ++++++++++----------------------------------------- 2 files changed, 56 insertions(+), 243 deletions(-) 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..7446245 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,41 @@ 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, where nothing said which: the `.js` default belongs to + // minifying JavaScript, and compression is offered whatever is emitted. + test: typeof test === "undefined" ? /[\s\S]/ : test, + include, + exclude, + minify: { + implementation: MinimizerPlugin.compress, + options: { algorithm, compressionOptions }, + filename, + threshold, + minRatio, + deleteOriginalAssets: + typeof deleteOriginalAssets === "boolean" + ? deleteOriginalAssets + : undefined, + relatedName: this.relatedName(), + }, + }).apply(compiler); } } From 8b3574f533aad75aac2f8449488764ac4ec51858 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Tue, 15 Sep 2026 12:45:51 +0000 Subject: [PATCH 2/8] refactor: compress through the generator, not the minimizer A .gz is a new file beside the asset it was read from, which is what an `asset` generator writes. Nothing here minifies, and the engine now reads that off the instance rather than from a `minify: false`. Still failing, both for the same reason - the engine takes only a boolean: `deleteOriginalAssets` as "keep-source-map" or as a function, plus the duplicate-asset reporting that rides on it. --- src/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index 7446245..f3af04e 100644 --- a/src/index.js +++ b/src/index.js @@ -240,14 +240,13 @@ class CompressionPlugin { // or differently encoded, and `minimizer-webpack-plugin` already does it. // What stays here is what compression means by it. new MinimizerPlugin({ - // Every asset, where nothing said which: the `.js` default belongs to - // minifying JavaScript, and compression is offered whatever is emitted. - test: typeof test === "undefined" ? /[\s\S]/ : test, + test, include, exclude, - minify: { + generate: { implementation: MinimizerPlugin.compress, options: { algorithm, compressionOptions }, + type: "asset", filename, threshold, minRatio, From eec4a2a573ac359d2635155f33a1529b021c2390 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 14:35:50 +0000 Subject: [PATCH 3/8] refactor: keep-source-map is what deleting already does The generator deletes the file it read and nothing its related names, so the string maps onto true and a function passes straight through. --- src/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index f3af04e..1d87b4d 100644 --- a/src/index.js +++ b/src/index.js @@ -250,10 +250,12 @@ class CompressionPlugin { 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: - typeof deleteOriginalAssets === "boolean" - ? deleteOriginalAssets - : undefined, + deleteOriginalAssets === "keep-source-map" + ? true + : deleteOriginalAssets, relatedName: this.relatedName(), }, }).apply(compiler); From 7f590ec8af676fa8b63dce37c420e268909dfd3e Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 15:35:13 +0000 Subject: [PATCH 4/8] docs: what deleting an original takes with it Only the compressed asset's own file goes now, so a source map or another plugin's file beside it stays, and keep-source-map is what true does. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 31c193e..08dd023 100644 --- a/README.md +++ b/README.md @@ -389,9 +389,9 @@ 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. Only the compressed asset's own file goes: a source map, or a file another compression plugin wrote beside it, is 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. From e453f63d7d23c6c56330a722ef111614c5ed8142 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 15:58:56 +0000 Subject: [PATCH 5/8] docs: say which file deleting an original takes The sentence read as if the compressed file went; it is the original, and only that one. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08dd023..0001621 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ Default: `false` Determines whether the original (uncompressed) assets should be deleted after compression. -- If set to `true` , all original assets will be deleted. Only the compressed asset's own file goes: a source map, or a file another compression plugin wrote beside it, is kept. +- 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. - `"keep-source-map"` is what `true` already does, and is kept for compatibility. From ea8ec4f7a03365c467fd2625162d28bcb462475b Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 16:52:33 +0000 Subject: [PATCH 6/8] fix: compress without minifying, and over every asset again The engine defaults terser and a .js test for an instance that minifies; this one does not, so it asks for no minimizers and keeps compression's own every-asset default. --- README.md | 6 ++++-- src/index.js | 6 +++++- test/__snapshots__/algorithm.test.js.snap | 12 ++++++++---- .../deleteOriginalAssets.test.js.snap | 18 +++++++++--------- test/deleteOriginalAssets.test.js | 2 +- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0001621..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` @@ -393,7 +395,7 @@ Determines whether the original (uncompressed) assets should be deleted after co - `"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/src/index.js b/src/index.js index 1d87b4d..7558d72 100644 --- a/src/index.js +++ b/src/index.js @@ -240,9 +240,13 @@ class CompressionPlugin { // or differently encoded, and `minimizer-webpack-plugin` already does it. // What stays here is what compression means by it. new MinimizerPlugin({ - test, + // 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 }, 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..5afa029 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({ From c9875f11aaf398ceda867de9fc0dbc4e86f07d1c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 17:03:25 +0000 Subject: [PATCH 7/8] test: a second instance keeps what the first wrote Deleting an original took everything its related named with it, so a gzip instance destroyed a brotli one's files; the case now fails where that happens. --- test/deleteOriginalAssets.test.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/deleteOriginalAssets.test.js b/test/deleteOriginalAssets.test.js index 5afa029..31bff71 100644 --- a/test/deleteOriginalAssets.test.js +++ b/test/deleteOriginalAssets.test.js @@ -101,6 +101,33 @@ 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); + 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", From 9e79f464619372711824cd4be6028b9998c2e376 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 17:12:12 +0000 Subject: [PATCH 8/8] test: prove the original was the file that went Without it the case passes where nothing is deleted at all, which is not what it is about. --- test/deleteOriginalAssets.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/deleteOriginalAssets.test.js b/test/deleteOriginalAssets.test.js index 31bff71..7a3cbef 100644 --- a/test/deleteOriginalAssets.test.js +++ b/test/deleteOriginalAssets.test.js @@ -124,6 +124,8 @@ describe('"deleteOriginalAssets" option', () => { 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([]); });