Skip to content
This repository was archived by the owner on Sep 19, 2026. It is now read-only.
Closed
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"release": "standard-version"
},
"dependencies": {
"minimizer-webpack-plugin": "^5.11.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Require the engine release before enabling compression delegation.

The latest published minimizer-webpack-plugin version is 5.10.1. The declared ^5.11.0 version cannot resolve, and the generator contract is scheduled for the next release. Installation therefore fails before this implementation can run. (npmjs.com)

  • package.json#L49-L49: wait for the release containing PR #742, require that version, and update the lockfile.
  • src/index.js#L246-L259: enable this generator configuration only after the required package version is available.

Based on learnings, fix production compatibility before changing failing tests.

📍 Affects 2 files
  • package.json#L49-L49 (this comment)
  • src/index.js#L246-L259

Source: Learnings

"schema-utils": "^4.2.0",
"serialize-javascript": "^7.0.3"
},
Expand Down
303 changes: 60 additions & 243 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -190,262 +191,78 @@ 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<Buffer>} 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<string, Source>} assets assets
* @returns {Promise<void>}
*/
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<ReturnType<Compilation["getCache"]>["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`;
}

/**
* @param {Compiler} compiler compiler
* @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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '35,60p' package.json
sed -n '1,100p' package-lock.json
rg -n '"minimizer-webpack-plugin"|npm ci|npm install|package-lock' package.json package-lock.json .github README.md 2>/dev/null | head -160
sed -n '185,275p' src/index.js

Repository: webpack/compression-webpack-plugin

Length of output: 6978


🏁 Script executed:

python3 - <<'PY'
import json, urllib.request
url = "https://registry.npmjs.org/minimizer-webpack-plugin"
with urllib.request.urlopen(url, timeout=20) as response:
    data = json.load(response)
print("dist-tags:", json.dumps(data.get("dist-tags"), sort_keys=True))
for version in ["5.11.0", "5.11.1", "5.12.0", "6.0.0"]:
    item = data.get("versions", {}).get(version)
    if item:
        print("\nVERSION", version)
        print("package:", json.dumps({
            k: item.get(k) for k in ["version", "dependencies", "peerDependencies", "engines", "dist"]
        }, sort_keys=True))
        print("repository:", item.get("repository"))
PY
printf '\n--- relevant package metadata/source references ---\n'
python3 - <<'PY'
import json, urllib.request, tarfile, io, re
base = "https://registry.npmjs.org/minimizer-webpack-plugin"
with urllib.request.urlopen(base, timeout=20) as response:
    data = json.load(response)
for version in ["5.11.0", "5.11.1"]:
    item = data.get("versions", {}).get(version)
    if not item:
        continue
    tarball = item["dist"]["tarball"]
    with urllib.request.urlopen(tarball, timeout=20) as response:
        raw = response.read()
    with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as archive:
        print("\nVERSION", version)
        for member in archive.getmembers():
            if member.isfile() and re.search(r"(package\.json|dist/.*\.(js|mjs|cjs|d\.ts)|src/.*\.(js|ts))$", member.name):
                text = archive.extractfile(member).read().decode("utf-8", "replace")
                if "compress" in text or "generate" in text or member.name.endswith("package.json"):
                    print("---", member.name)
                    for i, line in enumerate(text.splitlines(), 1):
                        if re.search(r"compress|generate|version|peerDependencies|webpack", line):
                            print(f"{i}:{line[:240]}")
PY
printf '\n--- npm ci mismatch behavior help ---\n'
npm --version
npm ci --help | sed -n '1,80p'

Repository: webpack/compression-webpack-plugin

Length of output: 7833


🏁 Script executed:

python3 - <<'PY'
import json, urllib.request
with urllib.request.urlopen("https://registry.npmjs.org/minimizer-webpack-plugin", timeout=20) as r:
    d=json.load(r)
print("latest", d.get("dist-tags",{}).get("latest"))
print("matching versions", [v for v in d.get("versions",{}) if v.startswith("5.")][-20:])
for v in sorted(d.get("versions",{})):
    if v.startswith("5."):
        p=d["versions"][v]
        print(v, p.get("dist",{}).get("tarball"))
PY

Repository: webpack/compression-webpack-plugin

Length of output: 2458


🏁 Script executed:

set -eu
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSLk --max-time 20 https://registry.npmjs.org/minimizer-webpack-plugin > "$tmp"
python3 - "$tmp" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
versions = data.get("versions", {})
print("dist-tags:", json.dumps(data.get("dist-tags", {}), sort_keys=True))
print("5.x versions:", json.dumps([v for v in versions if v.startswith("5.")]))
for version in sorted(versions):
    if version.startswith("5."):
        item = versions[version]
        print("\nVERSION", version)
        print("dist:", json.dumps(item.get("dist", {}), sort_keys=True))
        print("repository:", json.dumps(item.get("repository"), sort_keys=True))
PY

Repository: webpack/compression-webpack-plugin

Length of output: 6666


🏁 Script executed:

set -eu
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSLk --max-time 30 https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.1.tgz -o "$tmp/pkg.tgz"
printf '%s\n' '--- archive members ---'
tar -tzf "$tmp/pkg.tgz"
printf '%s\n' '--- package.json ---'
tar -xOf "$tmp/pkg.tgz" package/package.json
printf '%s\n' '--- source references to compress/generate ---'
tar -xOf "$tmp/pkg.tgz" package/dist/index.js 2>/dev/null | grep -nE 'compress|generate|minify|relatedName|deleteOriginalAssets' || true
tar -xOf "$tmp/pkg.tgz" package/dist/index.d.ts 2>/dev/null | grep -nE 'compress|generate|minify|relatedName|deleteOriginalAssets' || true

Repository: webpack/compression-webpack-plugin

Length of output: 13928


🏁 Script executed:

set -eu
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSLk --max-time 30 https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.1.tgz -o "$tmp/pkg.tgz"
tar -xOf "$tmp/pkg.tgz" package/dist/index.js > "$tmp/index.js"
printf '%s\n' '--- exports and implementation ---'
grep -nE '^(class|function)|module\.exports|exports\.|\.compress|\.minify|generateAsset|generator\s*:' "$tmp/index.js" | tail -80
printf '%s\n' '--- generator normalization and asset generation ---'
sed -n '270,305p;900,950p;1050,1150p' "$tmp/index.js"

Repository: webpack/compression-webpack-plugin

Length of output: 8829


Pin the unreleased minimizer-webpack-plugin API in both manifests.

The registry publishes minimizer-webpack-plugin only through 5.10.1, so ^5.11.0 cannot resolve. Version 5.10.1 exports TerserPlugin without compress and does not accept the generator descriptor used here. package-lock.json also omits the dependency, so the workflow’s npm ci fails before the build. Pin an API-compatible prerelease or commit, then regenerate package-lock.json.

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);
}
}

Expand Down
12 changes: 8 additions & 4 deletions test/__snapshots__/algorithm.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
`;

Expand Down
Loading
Loading