diff --git a/CHANGELOG.md b/CHANGELOG.md index e30017e9..a2c2a39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ _Note: Gaps between patch versions are faulty, broken or test releases._ ## UNRELEASED +* **New Feature** + * Add support for Zstandard compression ([#693](https://github.com/webpack-contrib/webpack-bundle-analyzer/pull/693) by [@bjohansebas](https://github.com/bjohansebas)) + * **Internal** * Update `ws` dependency ([#691](https://github.com/webpack-contrib/webpack-bundle-analyzer/pull/691) by [@bjohansebas](https://github.com/bjohansebas)) diff --git a/README.md b/README.md index 99b8a156..662ceeba 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ This module will help you: 4. Optimize it! And the best thing is it supports minified bundles! It parses them to get real size of bundled modules. -And it also shows their gzipped or Brotli sizes! +And it also shows their gzipped, Brotli, or Zstandard sizes!

Options (for plugin)

@@ -62,7 +62,7 @@ new BundleAnalyzerPlugin(options?: object) |**`reportFilename`**|`{String}`|Default: `report.html`. Path to bundle report file that will be generated in `static` mode. It can be either an absolute path or a path relative to a bundle output directory (which is output.path in webpack config).| |**`reportTitle`**|`{String\|function}`|Default: function that returns pretty printed current date and time. Content of the HTML `title` element; or a function of the form `() => string` that provides the content.| |**`defaultSizes`**|One of: `stat`, `parsed`, `gzip`, `brotli`|Default: `parsed`. Module sizes to show in report by default. [Size definitions](#size-definitions) section describes what these values mean.| -|**`compressionAlgorithm`**|One of: `gzip`, `brotli`|Default: `gzip`. Compression type used to calculate the compressed module sizes.| +|**`compressionAlgorithm`**|One of: `gzip`, `brotli`, `zstd`|Default: `gzip`. Compression type used to calculate the compressed module sizes.| |**`openAnalyzer`**|`{Boolean}`|Default: `true`. Automatically open report in default browser.| |**`generateStatsFile`**|`{Boolean}`|Default: `false`. If `true`, webpack stats JSON file will be generated in bundle output directory| |**`statsFilename`**|`{String}`|Default: `stats.json`. Name of webpack stats JSON file that will be generated if `generateStatsFile` is `true`. It can be either an absolute path or a path relative to a bundle output directory (which is output.path in webpack config).| @@ -122,9 +122,9 @@ Directory containing all generated bundles. -r, --report Path to bundle report file that will be generated in `static` mode. (default: report.html) -t, --title String to use in title element of html report. (default: pretty printed current date) -s, --default-sizes <type> Module sizes to show in treemap by default. - Possible values: stat, parsed, gzip, brotli (default: parsed) + Possible values: stat, parsed, gzip, brotli, zstd (default: parsed) --compression-algorithm <type> Compression algorithm that will be used to calculate the compressed module sizes. - Possible values: gzip, brotli (default: gzip) + Possible values: gzip, brotli, zstd (default: gzip) -O, --no-open Don't open report in default browser automatically. -e, --exclude <regexp> Assets that should be excluded from the report. Can be specified multiple times. @@ -158,6 +158,10 @@ This is the size of running the parsed bundles/modules through gzip compression. This is the size of running the parsed bundles/modules through Brotli compression. +### `zstd` + +This is the size of running the parsed bundles/modules through Zstandard compression. (Node.js 22.15.0+ is required for this feature) + <h2 align="center">Selecting Which Chunks to Display</h2> When opened, the report displays all of the Webpack chunks for your project. It's possible to filter to a more specific list of chunks by using the sidebar or the chunk context menu. diff --git a/client/components/ModulesTreemap.jsx b/client/components/ModulesTreemap.jsx index f5cf5d1c..14d707e5 100644 --- a/client/components/ModulesTreemap.jsx +++ b/client/components/ModulesTreemap.jsx @@ -29,6 +29,8 @@ function getSizeSwitchItems() { if (window.compressionAlgorithm === 'brotli') items.push({label: 'Brotli', prop: 'brotliSize'}); + if (window.compressionAlgorithm === 'zstd') items.push({label: 'Zstandard', prop: 'zstdSize'}); + return items; }; diff --git a/client/store.js b/client/store.js index 59bbeaf3..79608027 100644 --- a/client/store.js +++ b/client/store.js @@ -4,7 +4,7 @@ import localStorage from './localStorage'; export class Store { cid = 0; - sizes = new Set(['statSize', 'parsedSize', 'gzipSize', 'brotliSize']); + sizes = new Set(['statSize', 'parsedSize', 'gzipSize', 'brotliSize', 'zstdSize']); @observable.ref allChunks; @observable.shallow selectedChunks; diff --git a/src/analyzer.js b/src/analyzer.js index d684928f..93600a25 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -113,6 +113,7 @@ function getViewerData(bundleStats, bundleDir, opts) { asset.parsedSize = Buffer.byteLength(assetSources.src); if (compressionAlgorithm === 'gzip') asset.gzipSize = getCompressedSize('gzip', assetSources.src); if (compressionAlgorithm === 'brotli') asset.brotliSize = getCompressedSize('brotli', assetSources.src); + if (compressionAlgorithm === 'zstd') asset.zstdSize = getCompressedSize('zstd', assetSources.src); } // Picking modules from current bundle script @@ -169,6 +170,7 @@ function getViewerData(bundleStats, bundleDir, opts) { parsedSize: asset.parsedSize, gzipSize: asset.gzipSize, brotliSize: asset.brotliSize, + zstdSize: asset.zstdSize, groups: Object.values(asset.tree.children).map(i => i.toChartData()), isInitialByEntrypoint: chunkToInitialByEntrypoint[filename] ?? {} })); diff --git a/src/bin/analyzer.js b/src/bin/analyzer.js index f551d55d..688b4dee 100755 --- a/src/bin/analyzer.js +++ b/src/bin/analyzer.js @@ -9,9 +9,10 @@ const analyzer = require('../analyzer'); const viewer = require('../viewer'); const Logger = require('../Logger'); const utils = require('../utils'); +const {isZstdSupported} = require('../sizeUtils'); const SIZES = new Set(['stat', 'parsed', 'gzip']); -const COMPRESSION_ALGORITHMS = new Set(['gzip', 'brotli']); +const COMPRESSION_ALGORITHMS = new Set(isZstdSupported ? ['gzip', 'brotli', 'zstd'] : ['gzip', 'brotli']); const program = commander .version(require('../../package.json').version) diff --git a/src/sizeUtils.js b/src/sizeUtils.js index 4d6bd855..3eef93cd 100644 --- a/src/sizeUtils.js +++ b/src/sizeUtils.js @@ -1,8 +1,13 @@ const zlib = require('zlib'); +export const isZstdSupported = 'createZstdCompress' in zlib; + export function getCompressedSize(compressionAlgorithm, input) { if (compressionAlgorithm === 'gzip') return zlib.gzipSync(input, {level: 9}).length; if (compressionAlgorithm === 'brotli') return zlib.brotliCompressSync(input).length; + if (compressionAlgorithm === 'zstd' && isZstdSupported) { + return zlib.zstdCompressSync(input).length; + } throw new Error(`Unsupported compression algorithm: ${compressionAlgorithm}.`); } diff --git a/src/tree/ConcatenatedModule.js b/src/tree/ConcatenatedModule.js index 34ee7c05..c3871776 100644 --- a/src/tree/ConcatenatedModule.js +++ b/src/tree/ConcatenatedModule.js @@ -24,6 +24,10 @@ export default class ConcatenatedModule extends Module { return this.getBrotliSize() ?? this.getEstimatedSize('brotliSize'); } + get zstdSize() { + return this.getZstdSize() ?? this.getEstimatedSize('zstdSize'); + } + getEstimatedSize(sizeType) { const parentModuleSize = this.parent[sizeType]; diff --git a/src/tree/ContentFolder.js b/src/tree/ContentFolder.js index c58d668e..70e8c24c 100644 --- a/src/tree/ContentFolder.js +++ b/src/tree/ContentFolder.js @@ -19,6 +19,10 @@ export default class ContentFolder extends BaseFolder { return this.getSize('brotliSize'); } + get zstdSize() { + return this.getSize('zstdSize'); + } + getSize(sizeType) { const ownerModuleSize = this.ownerModule[sizeType]; @@ -33,6 +37,7 @@ export default class ContentFolder extends BaseFolder { parsedSize: this.parsedSize, gzipSize: this.gzipSize, brotliSize: this.brotliSize, + zstdSize: this.zstdSize, inaccurateSizes: true }; } diff --git a/src/tree/ContentModule.js b/src/tree/ContentModule.js index 116a23c2..872c8297 100644 --- a/src/tree/ContentModule.js +++ b/src/tree/ContentModule.js @@ -19,6 +19,10 @@ export default class ContentModule extends Module { return this.getSize('brotliSize'); } + get zstdSize() { + return this.getSize('zstdSize'); + } + getSize(sizeType) { const ownerModuleSize = this.ownerModule[sizeType]; diff --git a/src/tree/Folder.js b/src/tree/Folder.js index 5d9aeb97..169921b4 100644 --- a/src/tree/Folder.js +++ b/src/tree/Folder.js @@ -23,6 +23,10 @@ export default class Folder extends BaseFolder { return this.opts.compressionAlgorithm === 'brotli' ? this.getCompressedSize('brotli') : undefined; } + get zstdSize() { + return this.opts.compressionAlgorithm === 'zstd' ? this.getCompressedSize('zstd') : undefined; + } + getCompressedSize(compressionAlgorithm) { const key = `_${compressionAlgorithm}Size`; @@ -71,7 +75,8 @@ export default class Folder extends BaseFolder { ...super.toChartData(), parsedSize: this.parsedSize, gzipSize: this.gzipSize, - brotliSize: this.brotliSize + brotliSize: this.brotliSize, + zstdSize: this.zstdSize }; } diff --git a/src/tree/Module.js b/src/tree/Module.js index 079871d8..9adb2d5c 100644 --- a/src/tree/Module.js +++ b/src/tree/Module.js @@ -17,6 +17,7 @@ export default class Module extends Node { this.data.parsedSrc = value; delete this._gzipSize; delete this._brotliSize; + delete this._zstdSize; } get size() { @@ -39,6 +40,10 @@ export default class Module extends Node { return this.getBrotliSize(); } + get zstdSize() { + return this.getZstdSize(); + } + getParsedSize() { return this.src ? this.src.length : undefined; } @@ -51,6 +56,10 @@ export default class Module extends Node { return this.opts.compressionAlgorithm === 'brotli' ? this.getCompressedSize('brotli') : undefined; } + getZstdSize() { + return this.opts.compressionAlgorithm === 'zstd' ? this.getCompressedSize('zstd') : undefined; + } + getCompressedSize(compressionAlgorithm) { const key = `_${compressionAlgorithm}Size`; if (!(key in this)) { @@ -78,7 +87,8 @@ export default class Module extends Node { statSize: this.size, parsedSize: this.parsedSize, gzipSize: this.gzipSize, - brotliSize: this.brotliSize + brotliSize: this.brotliSize, + zstdSize: this.zstdSize }; } diff --git a/src/viewer.js b/src/viewer.js index 81028f21..016a867f 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -22,7 +22,7 @@ function resolveTitle(reportTitle) { } function resolveDefaultSizes(defaultSizes, compressionAlgorithm) { - if (['gzip', 'brotli'].includes(defaultSizes)) return compressionAlgorithm; + if (['gzip', 'brotli', 'zstd'].includes(defaultSizes)) return compressionAlgorithm; return defaultSizes; } diff --git a/test/analyzer.js b/test/analyzer.js index ee019a31..16ee23e7 100644 --- a/test/analyzer.js +++ b/test/analyzer.js @@ -6,6 +6,7 @@ const path = require('path'); const del = require('del'); const childProcess = require('child_process'); const puppeteer = require('puppeteer'); +const {isZstdSupported} = require('../src/sizeUtils'); let browser; @@ -270,6 +271,13 @@ describe('Analyzer', function () { expect(await getCompressionAlgorithm()).to.equal('gzip'); }); + if (isZstdSupported) { + it('should accept --compression-algorithm zstd', async function () { + generateReportFrom('with-modules-chunk.json', '--compression-algorithm zstd'); + expect(await getCompressionAlgorithm()).to.equal('zstd'); + }); + } + it('should default to gzip', async function () { generateReportFrom('with-modules-chunk.json'); expect(await getCompressionAlgorithm()).to.equal('gzip'); diff --git a/test/plugin.js b/test/plugin.js index ff097d16..1ae77719 100644 --- a/test/plugin.js +++ b/test/plugin.js @@ -6,6 +6,7 @@ const del = require('del'); const path = require('path'); const puppeteer = require('puppeteer'); const BundleAnalyzerPlugin = require('../lib/BundleAnalyzerPlugin'); +const {isZstdSupported} = require('../src/sizeUtils'); describe('Plugin', function () { describe('options', function () { @@ -188,6 +189,13 @@ describe('Plugin', function () { await webpackCompile(config, '4.44.2'); await expectValidReport({parsedSize: 1311, gzipSize: undefined, brotliSize: 302}); }); + if (isZstdSupported) { + it('should support zstd', async function () { + const config = makeWebpackConfig({analyzerOpts: {compressionAlgorithm: 'zstd'}}); + await webpackCompile(config, '4.44.2'); + await expectValidReport({parsedSize: 1311, gzipSize: undefined, brotliSize: undefined, zstdSize: 345}); + }); + } }); }); @@ -208,7 +216,9 @@ describe('Plugin', function () { label: bundleLabel, statSize, parsedSize, - gzipSize + gzipSize, + brotliSize: opts.brotliSize, + zstdSize: opts.zstdSize }); }