-
-
Notifications
You must be signed in to change notification settings - Fork 108
refactor: compress through minimizer-webpack-plugin's asset generator #435
Changes from all commits
b7319ab
8b3574f
eec4a2a
7f590ec
e453f63
ea8ec4f
c9875f1
9e79f46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.jsRepository: 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"))
PYRepository: 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))
PYRepository: 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' || trueRepository: 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 The registry publishes |
||
| 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); | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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-pluginversion is 5.10.1. The declared^5.11.0version 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-L259Source: Learnings