From bc856376039287cf5fb6135ca5da65a9000f5664 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 12 Jul 2021 10:48:04 -0400 Subject: [PATCH] feat(@angular-devkit/build-angular): add estimated transfer size to build output report When optimizations are enabled (either scripts or styles), an additional column will now be present in the output report shown in the console for an application build. This additonal column will display the estimated transfer size for each file as well as the total initial estimated transfer size for the initial files. The estimated transfer size is determined by calculating the compressed size of the file using brotli's default settings. In a development configuration (a configuration with optimizations disabled), the calculations are not performed to avoid any potential increase in rebuild speed due to the large size of unoptimized files. Closes: #21394 --- .../src/webpack/configs/common.ts | 5 + .../webpack/plugins/transfer-size-plugin.ts | 61 ++++++++++ .../build_angular/src/webpack/utils/stats.ts | 105 ++++++++++++++---- tests/legacy-cli/e2e/tests/basic/build.ts | 13 ++- 4 files changed, 161 insertions(+), 23 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/webpack/plugins/transfer-size-plugin.ts diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts index cecdfa786e2d..ed4053ca3466 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts @@ -31,6 +31,7 @@ import { ScriptsWebpackPlugin, } from '../plugins'; import { ProgressPlugin } from '../plugins/progress-plugin'; +import { TransferSizePlugin } from '../plugins/transfer-size-plugin'; import { createIvyPlugin } from '../plugins/typescript'; import { assetPatterns, @@ -287,6 +288,10 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/transfer-size-plugin.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/transfer-size-plugin.ts new file mode 100644 index 000000000000..2d61b27d7634 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/webpack/plugins/transfer-size-plugin.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { promisify } from 'util'; +import { Compiler } from 'webpack'; +import { brotliCompress } from 'zlib'; + +const brotliCompressAsync = promisify(brotliCompress); + +const PLUGIN_NAME = 'angular-transfer-size-estimator'; + +export class TransferSizePlugin { + constructor() {} + + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + async (compilationAssets) => { + const actions = []; + for (const assetName of Object.keys(compilationAssets)) { + if (!assetName.endsWith('.js') && !assetName.endsWith('.css')) { + continue; + } + + const scriptAsset = compilation.getAsset(assetName); + if (!scriptAsset || scriptAsset.source.size() <= 0) { + continue; + } + + actions.push( + brotliCompressAsync(scriptAsset.source.source()) + .then((result) => { + compilation.updateAsset(assetName, (s) => s, { + estimatedTransferSize: result.length, + }); + }) + .catch((error) => { + compilation.warnings.push( + new compilation.compiler.webpack.WebpackError( + `Unable to calculate estimated transfer size for '${assetName}'. Reason: ${error.message}`, + ), + ); + }), + ); + } + + await Promise.all(actions); + }, + ); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts b/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts index da530a8ef295..1c479e90f6f9 100644 --- a/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts +++ b/packages/angular_devkit/build_angular/src/webpack/utils/stats.ts @@ -30,20 +30,28 @@ export function formatSize(size: number): string { return `${roundedSize.toFixed(fractionDigits)} ${abbreviations[index]}`; } -export type BundleStatsData = [files: string, names: string, size: number | string]; +export type BundleStatsData = [ + files: string, + names: string, + rawSize: number | string, + estimatedTransferSize: number | string, +]; export interface BundleStats { initial: boolean; stats: BundleStatsData; } export function generateBundleStats(info: { - size?: number; + rawSize?: number; + estimatedTransferSize?: number; files?: string[]; names?: string[]; initial?: boolean; rendered?: boolean; }): BundleStats { - const size = typeof info.size === 'number' ? info.size : '-'; + const rawSize = typeof info.rawSize === 'number' ? info.rawSize : '-'; + const estimatedTransferSize = + typeof info.estimatedTransferSize === 'number' ? info.estimatedTransferSize : '-'; const files = info.files ?.filter((f) => !f.endsWith('.map')) @@ -54,7 +62,7 @@ export function generateBundleStats(info: { return { initial, - stats: [files, names, size], + stats: [files, names, rawSize, estimatedTransferSize], }; } @@ -62,6 +70,7 @@ function generateBuildStatsTable( data: BundleStats[], colors: boolean, showTotalSize: boolean, + showEstimatedTransferSize: boolean, ): string { const g = (x: string) => (colors ? ansiColors.greenBright(x) : x); const c = (x: string) => (colors ? ansiColors.cyanBright(x) : x); @@ -71,21 +80,39 @@ function generateBuildStatsTable( const changedEntryChunksStats: BundleStatsData[] = []; const changedLazyChunksStats: BundleStatsData[] = []; - let initialTotalSize = 0; + let initialTotalRawSize = 0; + let initialTotalEstimatedTransferSize; for (const { initial, stats } of data) { - const [files, names, size] = stats; - - const data: BundleStatsData = [ - g(files), - names, - c(typeof size === 'number' ? formatSize(size) : size), - ]; + const [files, names, rawSize, estimatedTransferSize] = stats; + + let data: BundleStatsData; + + if (showEstimatedTransferSize) { + data = [ + g(files), + names, + c(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + c( + typeof estimatedTransferSize === 'number' + ? formatSize(estimatedTransferSize) + : estimatedTransferSize, + ), + ]; + } else { + data = [g(files), names, c(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), '']; + } if (initial) { changedEntryChunksStats.push(data); - if (typeof size === 'number') { - initialTotalSize += size; + if (typeof rawSize === 'number') { + initialTotalRawSize += rawSize; + } + if (showEstimatedTransferSize && typeof estimatedTransferSize === 'number') { + if (initialTotalEstimatedTransferSize === undefined) { + initialTotalEstimatedTransferSize = 0; + } + initialTotalEstimatedTransferSize += estimatedTransferSize; } } else { changedLazyChunksStats.push(data); @@ -93,14 +120,30 @@ function generateBuildStatsTable( } const bundleInfo: (string | number)[][] = []; + const baseTitles = ['Names', 'Raw Size']; + const tableAlign: ('l' | 'r')[] = ['l', 'l', 'r']; + + if (showEstimatedTransferSize) { + baseTitles.push('Estimated Transfer Size'); + tableAlign.push('r'); + } // Entry chunks if (changedEntryChunksStats.length) { - bundleInfo.push(['Initial Chunk Files', 'Names', 'Size'].map(bold), ...changedEntryChunksStats); + bundleInfo.push(['Initial Chunk Files', ...baseTitles].map(bold), ...changedEntryChunksStats); if (showTotalSize) { bundleInfo.push([]); - bundleInfo.push([' ', 'Initial Total', formatSize(initialTotalSize)].map(bold)); + + const totalSizeElements = [' ', 'Initial Total', formatSize(initialTotalRawSize)]; + if (showEstimatedTransferSize) { + totalSizeElements.push( + typeof initialTotalEstimatedTransferSize === 'number' + ? formatSize(initialTotalEstimatedTransferSize) + : '-', + ); + } + bundleInfo.push(totalSizeElements.map(bold)); } } @@ -111,13 +154,13 @@ function generateBuildStatsTable( // Lazy chunks if (changedLazyChunksStats.length) { - bundleInfo.push(['Lazy Chunk Files', 'Names', 'Size'].map(bold), ...changedLazyChunksStats); + bundleInfo.push(['Lazy Chunk Files', ...baseTitles].map(bold), ...changedLazyChunksStats); } return textTable(bundleInfo, { hsep: dim(' | '), stringLength: (s) => removeColor(s).length, - align: ['l', 'l', 'r'], + align: tableAlign, }); } @@ -148,6 +191,7 @@ function statsToString( const changedChunksStats: BundleStats[] = bundleState ?? []; let unchangedChunkNumber = 0; + let hasEstimatedTransferSizes = false; if (!bundleState?.length) { const isFirstRun = !runsCache.has(json.outputPath || ''); @@ -159,10 +203,26 @@ function statsToString( } const assets = json.assets?.filter((asset) => chunk.files?.includes(asset.name)); - const summedSize = assets - ?.filter((asset) => !asset.name.endsWith('.map')) - .reduce((total, asset) => total + asset.size, 0); - changedChunksStats.push(generateBundleStats({ ...chunk, size: summedSize })); + let rawSize = 0; + let estimatedTransferSize; + if (assets) { + for (const asset of assets) { + if (asset.name.endsWith('.map')) { + continue; + } + + rawSize += asset.size; + + if (typeof asset.info.estimatedTransferSize === 'number') { + if (estimatedTransferSize === undefined) { + estimatedTransferSize = 0; + hasEstimatedTransferSizes = true; + } + estimatedTransferSize += asset.info.estimatedTransferSize; + } + } + } + changedChunksStats.push(generateBundleStats({ ...chunk, rawSize, estimatedTransferSize })); } unchangedChunkNumber = json.chunks.length - changedChunksStats.length; @@ -186,6 +246,7 @@ function statsToString( changedChunksStats, colors, unchangedChunkNumber === 0, + hasEstimatedTransferSizes, ); // In some cases we do things outside of webpack context diff --git a/tests/legacy-cli/e2e/tests/basic/build.ts b/tests/legacy-cli/e2e/tests/basic/build.ts index a6277d0e377c..5f1ed9eae130 100644 --- a/tests/legacy-cli/e2e/tests/basic/build.ts +++ b/tests/legacy-cli/e2e/tests/basic/build.ts @@ -3,8 +3,13 @@ import { ng } from '../../utils/process'; export default async function () { // Development build - await ng('build', '--configuration=development'); + const { stdout: stdoutDev } = await ng('build', '--configuration=development'); await expectFileToMatch('dist/test-project/index.html', 'main.js'); + if (stdoutDev.includes('Estimated Transfer Size')) { + throw new Error( + `Expected stdout not to contain 'Estimated Transfer Size' but it did.\n${stdoutDev}`, + ); + } // Named Development build await ng('build', 'test-project', '--configuration=development'); @@ -19,6 +24,12 @@ export default async function () { throw new Error(`Expected stdout to contain 'Initial Total' but it did not.\n${stdout}`); } + if (!stdout.includes('Estimated Transfer Size')) { + throw new Error( + `Expected stdout to contain 'Estimated Transfer Size' but it did not.\n${stdout}`, + ); + } + const logs: string[] = [ 'Browser application bundle generation complete', 'Copying assets complete',