diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts index b6fb803b5087..a6b7533e20b6 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts @@ -554,10 +554,14 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { noEmitOnErrors: true, minimizer: [ new HashedModuleIdsPlugin(), - // TODO: check with Mike what this feature needs. - new BundleBudgetPlugin({ budgets: buildOptions.budgets }), ...extraMinimizers, - ], + ].concat(differentialLoadingMode ? [ + // Budgets are computed after differential builds, not via a plugin. + // https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/build_angular/src/browser/index.ts + ] : [ + // Non differential builds should be computed here, as a plugin. + new BundleBudgetPlugin({ budgets: buildOptions.budgets }), + ]), }, plugins: [ // Always replace the context for the System.import in angular/core to prevent warnings. diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/styles.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/styles.ts index f48c1d0de344..bbbeeb624e14 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/styles.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/styles.ts @@ -9,6 +9,7 @@ import * as path from 'path'; import * as webpack from 'webpack'; import { + AnyComponentStyleBudgetChecker, PostcssCliResources, RawCssLoader, RemoveHashPlugin, @@ -26,7 +27,9 @@ export function getStylesConfig(wco: WebpackConfigOptions) { const { root, buildOptions } = wco; const entryPoints: { [key: string]: string[] } = {}; const globalStylePaths: string[] = []; - const extraPlugins = []; + const extraPlugins: webpack.Plugin[] = [ + new AnyComponentStyleBudgetChecker(buildOptions.budgets), + ]; const cssSourceMap = buildOptions.sourceMap.styles; diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/any-component-style-budget-checker.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/any-component-style-budget-checker.ts new file mode 100644 index 000000000000..7ee1a5cc1e81 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/any-component-style-budget-checker.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google Inc. 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 { Compiler, Plugin } from 'webpack'; +import { Budget, Type } from '../../../src/browser/schema'; +import { ThresholdSeverity, calculateThresholds, checkThresholds } from '../utilities/bundle-calculator'; + +const PLUGIN_NAME = 'AnyComponentStyleBudgetChecker'; + +/** + * Check budget sizes for component styles by emitting a warning or error if a + * budget is exceeded by a particular component's styles. + */ +export class AnyComponentStyleBudgetChecker implements Plugin { + private readonly budgets: Budget[]; + constructor(budgets: Budget[]) { + this.budgets = budgets.filter((budget) => budget.type === Type.AnyComponentStyle); + } + + apply(compiler: Compiler) { + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.afterOptimizeChunkAssets.tap(PLUGIN_NAME, () => { + // In AOT compilations component styles get processed in child compilations. + // tslint:disable-next-line: no-any + const parentCompilation = (compilation.compiler as any).parentCompilation; + if (!parentCompilation) { + return; + } + + const componentStyles = Object.keys(compilation.assets) + .filter((name) => name.endsWith('.css')) + .map((name) => ({ + size: compilation.assets[name].size(), + label: name, + })); + const thresholds = flatMap(this.budgets, (budget) => calculateThresholds(budget)); + + for (const { size, label } of componentStyles) { + for (const { severity, message } of checkThresholds(thresholds[Symbol.iterator](), size, label)) { + switch (severity) { + case ThresholdSeverity.Warning: + compilation.warnings.push(message); + break; + case ThresholdSeverity.Error: + compilation.errors.push(message); + break; + default: + assertNever(severity); + break; + } + } + } + }); + }); + } +} + +function assertNever(input: never): never { + throw new Error(`Unexpected call to assertNever() with input: ${ + JSON.stringify(input, null /* replacer */, 4 /* tabSize */)}`); +} + +function flatMap(list: T[], mapper: (item: T, index: number, array: T[]) => IterableIterator): R[] { + return ([] as R[]).concat(...list.map(mapper).map((iterator) => Array.from(iterator))); + +} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/bundle-budget.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/bundle-budget.ts index ca2284b35a43..6f61f2754a54 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/bundle-budget.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/bundle-budget.ts @@ -7,19 +7,7 @@ */ import { Compiler, compilation } from 'webpack'; import { Budget, Type } from '../../browser/schema'; -import { Size, calculateBytes, calculateSizes } from '../utilities/bundle-calculator'; -import { formatSize } from '../utilities/stats'; - -interface Thresholds { - maximumWarning?: number; - maximumError?: number; - minimumWarning?: number; - minimumError?: number; - warningLow?: number; - warningHigh?: number; - errorLow?: number; - errorHigh?: number; -} +import { ThresholdSeverity, checkBudgets } from '../utilities/bundle-calculator'; export interface BundleBudgetPluginOptions { budgets: Budget[]; @@ -35,97 +23,22 @@ export class BundleBudgetPlugin { return; } - compiler.hooks.compilation.tap('BundleBudgetPlugin', (compilation: compilation.Compilation) => { - compilation.hooks.afterOptimizeChunkAssets.tap('BundleBudgetPlugin', () => { - // In AOT compilations component styles get processed in child compilations. - // tslint:disable-next-line: no-any - const parentCompilation = (compilation.compiler as any).parentCompilation; - if (!parentCompilation) { - return; - } - - const filteredBudgets = budgets.filter(budget => budget.type === Type.AnyComponentStyle); - this.runChecks(filteredBudgets, compilation); - }); - }); - compiler.hooks.afterEmit.tap('BundleBudgetPlugin', (compilation: compilation.Compilation) => { - const filteredBudgets = budgets.filter(budget => budget.type !== Type.AnyComponentStyle); - this.runChecks(filteredBudgets, compilation); + this.runChecks(budgets, compilation); }); } - private checkMinimum(threshold: number | undefined, size: Size, messages: string[]) { - if (threshold && threshold > size.size) { - const sizeDifference = formatSize(threshold - size.size); - messages.push(`budgets, minimum exceeded for ${size.label}. ` - + `Budget ${formatSize(threshold)} was not reached by ${sizeDifference}.`); - } - } - - private checkMaximum(threshold: number | undefined, size: Size, messages: string[]) { - if (threshold && threshold < size.size) { - const sizeDifference = formatSize(size.size - threshold); - messages.push(`budgets, maximum exceeded for ${size.label}. ` - + `Budget ${formatSize(threshold)} was exceeded by ${sizeDifference}.`); - } - } - - private calculate(budget: Budget): Thresholds { - const thresholds: Thresholds = {}; - if (budget.maximumWarning) { - thresholds.maximumWarning = calculateBytes(budget.maximumWarning, budget.baseline, 1); - } - - if (budget.maximumError) { - thresholds.maximumError = calculateBytes(budget.maximumError, budget.baseline, 1); - } - - if (budget.minimumWarning) { - thresholds.minimumWarning = calculateBytes(budget.minimumWarning, budget.baseline, -1); - } - - if (budget.minimumError) { - thresholds.minimumError = calculateBytes(budget.minimumError, budget.baseline, -1); - } - - if (budget.warning) { - thresholds.warningLow = calculateBytes(budget.warning, budget.baseline, -1); - } - - if (budget.warning) { - thresholds.warningHigh = calculateBytes(budget.warning, budget.baseline, 1); - } - - if (budget.error) { - thresholds.errorLow = calculateBytes(budget.error, budget.baseline, -1); - } - - if (budget.error) { - thresholds.errorHigh = calculateBytes(budget.error, budget.baseline, 1); - } - - return thresholds; - } - private runChecks(budgets: Budget[], compilation: compilation.Compilation) { - budgets - .map(budget => ({ - budget, - thresholds: this.calculate(budget), - sizes: calculateSizes(budget, compilation), - })) - .forEach(budgetCheck => { - budgetCheck.sizes.forEach(size => { - this.checkMaximum(budgetCheck.thresholds.maximumWarning, size, compilation.warnings); - this.checkMaximum(budgetCheck.thresholds.maximumError, size, compilation.errors); - this.checkMinimum(budgetCheck.thresholds.minimumWarning, size, compilation.warnings); - this.checkMinimum(budgetCheck.thresholds.minimumError, size, compilation.errors); - this.checkMinimum(budgetCheck.thresholds.warningLow, size, compilation.warnings); - this.checkMaximum(budgetCheck.thresholds.warningHigh, size, compilation.warnings); - this.checkMinimum(budgetCheck.thresholds.errorLow, size, compilation.errors); - this.checkMaximum(budgetCheck.thresholds.errorHigh, size, compilation.errors); - }); - }); + const stats = compilation.getStats().toJson(); + for (const { severity, message } of checkBudgets(budgets, stats)) { + switch (severity) { + case ThresholdSeverity.Warning: + compilation.warnings.push(`budgets: ${message}`); + break; + case ThresholdSeverity.Error: + compilation.errors.push(`budgets: ${message}`); + break; + } + } } } diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts index ff8505733c4c..f9e3fb825ea2 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts @@ -7,6 +7,7 @@ */ // Exports the webpack plugins we use internally. +export { AnyComponentStyleBudgetChecker } from './any-component-style-budget-checker'; export { CleanCssWebpackPlugin, CleanCssWebpackPluginOptions } from './cleancss-webpack-plugin'; export { BundleBudgetPlugin, BundleBudgetPluginOptions } from './bundle-budget'; export { ScriptsWebpackPlugin, ScriptsWebpackPluginOptions } from './scripts-webpack-plugin'; diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts index 8ef3cb93884f..47c0ceb097f8 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts @@ -5,39 +5,136 @@ * 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 { Budget } from '../../browser/schema'; +import * as webpack from 'webpack'; +import { Budget, Type } from '../../browser/schema'; +import { formatSize } from '../utilities/stats'; -export interface Compilation { - assets: { [name: string]: { size: () => number } }; - chunks: { name: string, files: string[], isOnlyInitial: () => boolean }[]; - warnings: string[]; - errors: string[]; -} - -export interface Size { +interface Size { size: number; label?: string; } -export function calculateSizes(budget: Budget, compilation: Compilation): Size[] { - const calculatorMap = { +interface Threshold { + limit: number; + type: ThresholdType; + severity: ThresholdSeverity; +} + +enum ThresholdType { + Max = 'maximum', + Min = 'minimum', +} + +export enum ThresholdSeverity { + Warning = 'warning', + Error = 'error', +} + +export function* calculateThresholds(budget: Budget): IterableIterator { + if (budget.maximumWarning) { + yield { + limit: calculateBytes(budget.maximumWarning, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.maximumError) { + yield { + limit: calculateBytes(budget.maximumError, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Error, + }; + } + + if (budget.minimumWarning) { + yield { + limit: calculateBytes(budget.minimumWarning, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.minimumError) { + yield { + limit: calculateBytes(budget.minimumError, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Error, + }; + } + + if (budget.warning) { + yield { + limit: calculateBytes(budget.warning, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Warning, + }; + + yield { + limit: calculateBytes(budget.warning, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.error) { + yield { + limit: calculateBytes(budget.error, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Error, + }; + + yield { + limit: calculateBytes(budget.error, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Error, + }; + } +} + +/** + * Calculates the sizes for bundles in the budget type provided. + */ +function calculateSizes( + budget: Budget, + stats: webpack.Stats.ToJsonOutput, +): Size[] { + if (budget.type === Type.AnyComponentStyle) { + // Component style size information is not available post-build, this must + // be checked mid-build via the `AnyComponentStyleBudgetChecker` plugin. + throw new Error('Can not calculate size of AnyComponentStyle. Use `AnyComponentStyleBudgetChecker` instead.'); + } + + type NonComponentStyleBudgetTypes = Exclude; + const calculatorMap: Record = { all: AllCalculator, allScript: AllScriptCalculator, any: AnyCalculator, anyScript: AnyScriptCalculator, - anyComponentStyle: AnyComponentStyleCalculator, bundle: BundleCalculator, initial: InitialCalculator, }; const ctor = calculatorMap[budget.type]; - const calculator = new ctor(budget, compilation); + const {chunks, assets} = stats; + if (!chunks) { + throw new Error('Webpack stats output did not include chunk information.'); + } + if (!assets) { + throw new Error('Webpack stats output did not include asset information.'); + } + + const calculator = new ctor(budget, chunks, assets); return calculator.calculate(); } -export abstract class Calculator { - constructor (protected budget: Budget, protected compilation: Compilation) {} +abstract class Calculator { + constructor ( + protected budget: Budget, + protected chunks: Exclude, + protected assets: Exclude, + ) {} abstract calculate(): Size[]; } @@ -47,11 +144,24 @@ export abstract class Calculator { */ class BundleCalculator extends Calculator { calculate() { - const size: number = this.compilation.chunks - .filter(chunk => chunk.name === this.budget.name) + const budgetName = this.budget.name; + if (!budgetName) { + return []; + } + + const size: number = this.chunks + .filter(chunk => chunk.names.indexOf(budgetName) !== -1) .reduce((files, chunk) => [...files, ...chunk.files], []) .filter((file: string) => !file.endsWith('.map')) - .map((file: string) => this.compilation.assets[file].size()) + .map((file: string) => { + const asset = this.assets.find((asset) => asset.name === file); + if (!asset) { + throw new Error(`Could not find asset for file: ${file}`); + } + + return asset; + }) + .map((asset) => asset.size) .reduce((total: number, size: number) => total + size, 0); return [{size, label: this.budget.name}]; @@ -63,11 +173,19 @@ class BundleCalculator extends Calculator { */ class InitialCalculator extends Calculator { calculate() { - const initialChunks = this.compilation.chunks.filter(chunk => chunk.isOnlyInitial()); - const size: number = initialChunks + const size = this.chunks + .filter(chunk => chunk.initial) .reduce((files, chunk) => [...files, ...chunk.files], []) .filter((file: string) => !file.endsWith('.map')) - .map((file: string) => this.compilation.assets[file].size()) + .map((file: string) => { + const asset = this.assets.find((asset) => asset.name === file); + if (!asset) { + throw new Error(`Could not find asset for file: ${file}`); + } + + return asset; + }) + .map((asset) => asset.size) .reduce((total: number, size: number) => total + size, 0); return [{size, label: 'initial'}]; @@ -79,10 +197,9 @@ class InitialCalculator extends Calculator { */ class AllScriptCalculator extends Calculator { calculate() { - const size: number = Object.keys(this.compilation.assets) - .filter(key => key.endsWith('.js')) - .map(key => this.compilation.assets[key]) - .map(asset => asset.size()) + const size = this.assets + .filter((asset) => asset.name.endsWith('.js')) + .map(asset => asset.size) .reduce((total: number, size: number) => total + size, 0); return [{size, label: 'total scripts'}]; @@ -94,44 +211,26 @@ class AllScriptCalculator extends Calculator { */ class AllCalculator extends Calculator { calculate() { - const size: number = Object.keys(this.compilation.assets) - .filter(key => !key.endsWith('.map')) - .map(key => this.compilation.assets[key].size()) + const size = this.assets + .filter(asset => !asset.name.endsWith('.map')) + .map(asset => asset.size) .reduce((total: number, size: number) => total + size, 0); return [{size, label: 'total'}]; } } -/** - * Any components styles - */ -class AnyComponentStyleCalculator extends Calculator { - calculate() { - return Object.keys(this.compilation.assets) - .filter(key => key.endsWith('.css')) - .map(key => ({ - size: this.compilation.assets[key].size(), - label: key, - })); - } -} - /** * Any script, individually. */ class AnyScriptCalculator extends Calculator { calculate() { - return Object.keys(this.compilation.assets) - .filter(key => key.endsWith('.js')) - .map(key => { - const asset = this.compilation.assets[key]; - - return { - size: asset.size(), - label: key, - }; - }); + return this.assets + .filter(asset => asset.name.endsWith('.js')) + .map(asset => ({ + size: asset.size, + label: asset.name, + })); } } @@ -140,23 +239,19 @@ class AnyScriptCalculator extends Calculator { */ class AnyCalculator extends Calculator { calculate() { - return Object.keys(this.compilation.assets) - .filter(key => !key.endsWith('.map')) - .map(key => { - const asset = this.compilation.assets[key]; - - return { - size: asset.size(), - label: key, - }; - }); + return this.assets + .filter(asset => !asset.name.endsWith('.map')) + .map(asset => ({ + size: asset.size, + label: asset.name, + })); } } /** * Calculate the bytes given a string value. */ -export function calculateBytes( +function calculateBytes( input: string, baseline?: string, factor: 1 | -1 = 1, @@ -190,3 +285,61 @@ export function calculateBytes( return baselineBytes + value * factor; } + +export function* checkBudgets( + budgets: Budget[], webpackStats: webpack.Stats.ToJsonOutput): + IterableIterator<{ severity: ThresholdSeverity, message: string }> { + // Ignore AnyComponentStyle budgets as these are handled in `AnyComponentStyleBudgetChecker`. + const computableBudgets = budgets.filter((budget) => budget.type !== Type.AnyComponentStyle); + + for (const budget of computableBudgets) { + const sizes = calculateSizes(budget, webpackStats); + for (const { size, label } of sizes) { + yield* checkThresholds(calculateThresholds(budget), size, label); + } + } +} + +export function* checkThresholds(thresholds: IterableIterator, size: number, label?: string): + IterableIterator<{ severity: ThresholdSeverity, message: string }> { + for (const threshold of thresholds) { + switch (threshold.type) { + case ThresholdType.Max: { + if (size <= threshold.limit) { + continue; + } + + const sizeDifference = formatSize(threshold.limit - size); + yield { + severity: threshold.severity, + message: `Exceeded maximum budget for ${label}. Budget ${ + formatSize(threshold.limit)} was not met by ${ + sizeDifference} with a total of ${formatSize(size)}.`, + }; + break; + } + case ThresholdType.Min: { + if (size >= threshold.limit) { + continue; + } + + const sizeDifference = formatSize(threshold.limit - size); + yield { + severity: threshold.severity, + message: `Failed to meet minimum budget for ${label}. Budget ${ + formatSize(threshold.limit)} was not met by ${ + sizeDifference} with a total of ${formatSize(size)}.`, + }; + break; + } default: { + assertNever(threshold.type); + break; + } + } + } +} + +function assertNever(input: never): never { + throw new Error(`Unexpected call to assertNever() with input: ${ + JSON.stringify(input, null /* replacer */, 4 /* tabSize */)}`); +} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts index c2d3774b7ea3..9446bfc76599 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts @@ -5,80 +5,300 @@ * 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 { calculateBytes } from './bundle-calculator'; +import { ThresholdSeverity, checkBudgets } from './bundle-calculator'; +import { Type, Budget } from '../../browser/schema'; +import webpack = require('webpack'); + +const KB = 1024; describe('bundle-calculator', () => { - it('converts an integer with no postfix', () => { - expect(calculateBytes('0')).toBe(0); - expect(calculateBytes('5')).toBe(5); - expect(calculateBytes('190')).toBe(190); - expect(calculateBytes('92')).toBe(92); - }); + describe('checkBudgets()', () => { + it('yields maximum budgets exceeded', () => { + const budgets: Budget[] = [{ + type: Type.Any, + maximumError: '1kb', + }]; + const stats = { + chunks: [], + assets: [ + { + name: 'foo.js', + size: 1.5 * KB, + }, + { + name: 'bar.js', + size: 0.5 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; - it('converts a decimal with no postfix', () => { - expect(calculateBytes('3.14')).toBe(3.14); - expect(calculateBytes('0.25')).toBe(0.25); - expect(calculateBytes('90.5')).toBe(90.5); - expect(calculateBytes('25.0')).toBe(25); - }); + const failures = Array.from(checkBudgets(budgets, stats)); - it('converts an integer with kb postfix', () => { - expect(calculateBytes('0kb')).toBe(0); - expect(calculateBytes('5kb')).toBe(5 * 1024); - expect(calculateBytes('190KB')).toBe(190 * 1024); - expect(calculateBytes('92Kb')).toBe(92 * 1024); - expect(calculateBytes('25kB')).toBe(25 * 1024); - }); + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Exceeded maximum budget for foo.js.'), + }); + }); - it('converts a decimal with kb postfix', () => { - expect(calculateBytes('3.14kb')).toBe(3.14 * 1024); - expect(calculateBytes('0.25KB')).toBe(0.25 * 1024); - expect(calculateBytes('90.5Kb')).toBe(90.5 * 1024); - expect(calculateBytes('25.0kB')).toBe(25 * 1024); - }); + it('yields minimum budgets exceeded', () => { + const budgets: Budget[] = [{ + type: Type.Any, + minimumError: '1kb', + }]; + const stats = { + chunks: [], + assets: [ + { + name: 'foo.js', + size: 1.5 * KB, + }, + { + name: 'bar.js', + size: 0.5 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; - it('converts an integer with mb postfix', () => { - expect(calculateBytes('0mb')).toBe(0); - expect(calculateBytes('5mb')).toBe(5 * 1024 * 1024); - expect(calculateBytes('190MB')).toBe(190 * 1024 * 1024); - expect(calculateBytes('92Mb')).toBe(92 * 1024 * 1024); - expect(calculateBytes('25mB')).toBe(25 * 1024 * 1024); - }); + const failures = Array.from(checkBudgets(budgets, stats)); - it('converts a decimal with mb postfix', () => { - expect(calculateBytes('3.14mb')).toBe(3.14 * 1024 * 1024); - expect(calculateBytes('0.25MB')).toBe(0.25 * 1024 * 1024); - expect(calculateBytes('90.5Mb')).toBe(90.5 * 1024 * 1024); - expect(calculateBytes('25.0mB')).toBe(25 * 1024 * 1024); - }); + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Failed to meet minimum budget for bar.js.'), + }); + }); - it('converts an integer with gb postfix', () => { - expect(calculateBytes('0gb')).toBe(0); - expect(calculateBytes('5gb')).toBe(5 * 1024 * 1024 * 1024); - expect(calculateBytes('190GB')).toBe(190 * 1024 * 1024 * 1024); - expect(calculateBytes('92Gb')).toBe(92 * 1024 * 1024 * 1024); - expect(calculateBytes('25gB')).toBe(25 * 1024 * 1024 * 1024); - }); + it('yields exceeded bundle budgets', () => { + const budgets: Budget[] = [{ + type: Type.Bundle, + name: 'foo', + maximumError: '1kb', + }]; + const stats = { + chunks: [ + { + names: [ 'foo' ], + files: [ 'foo.js', 'bar.js' ], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * KB, + }, + { + name: 'bar.js', + size: 0.75 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; - it('converts a decimal with gb postfix', () => { - expect(calculateBytes('3.14gb')).toBe(3.14 * 1024 * 1024 * 1024); - expect(calculateBytes('0.25GB')).toBe(0.25 * 1024 * 1024 * 1024); - expect(calculateBytes('90.5Gb')).toBe(90.5 * 1024 * 1024 * 1024); - expect(calculateBytes('25.0gB')).toBe(25 * 1024 * 1024 * 1024); - }); + const failures = Array.from(checkBudgets(budgets, stats)); - it ('converts a decimal with mb and baseline', () => { - expect(calculateBytes('3mb', '5mb', -1)).toBe(2 * 1024 * 1024); - }); + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Exceeded maximum budget for foo.'), + }); + }); - it ('converts a percentage with baseline', () => { - expect(calculateBytes('20%', '1mb')).toBe(1024 * 1024 * 1.2); - expect(calculateBytes('20%', '1mb', -1)).toBe(1024 * 1024 * 0.8); - }); + it('yields exceeded initial budget', () => { + const budgets: Budget[] = [{ + type: Type.Initial, + maximumError: '1kb', + }]; + const stats = { + chunks: [ + { + initial: true, + files: [ 'foo.js', 'bar.js' ], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * KB, + }, + { + name: 'bar.js', + size: 0.75 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Exceeded maximum budget for initial.'), + }); + }); + + it('yields exceeded total scripts budget', () => { + const budgets: Budget[] = [{ + type: Type.AllScript, + maximumError: '1kb', + }]; + const stats = { + chunks: [ + { + initial: true, + files: [ 'foo.js', 'bar.js' ], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * KB, + }, + { + name: 'bar.js', + size: 0.75 * KB, + }, + { + name: 'baz.css', + size: 1.5 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Exceeded maximum budget for total scripts.'), + }); + }); + + it('yields exceeded total budget', () => { + const budgets: Budget[] = [{ + type: Type.All, + maximumError: '1kb', + }]; + const stats = { + chunks: [ + { + initial: true, + files: [ 'foo.js', 'bar.css' ], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * KB, + }, + { + name: 'bar.css', + size: 0.75 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Exceeded maximum budget for total.'), + }); + }); + + it('skips component style budgets', () => { + const budgets: Budget[] = [{ + type: Type.AnyComponentStyle, + maximumError: '1kb', + }]; + const stats = { + chunks: [ + { + initial: true, + files: [ 'foo.css', 'bar.js' ], + }, + ], + assets: [ + { + name: 'foo.css', + size: 1.5 * KB, + }, + { + name: 'bar.js', + size: 0.5 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(0); + }); + + it('yields exceeded individual script budget', () => { + const budgets: Budget[] = [{ + type: Type.AnyScript, + maximumError: '1kb', + }]; + const stats = { + chunks: [ + { + initial: true, + files: [ 'foo.js', 'bar.js' ], + }, + ], + assets: [ + { + name: 'foo.js', + size: 1.5 * KB, + }, + { + name: 'bar.js', + size: 0.5 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Exceeded maximum budget for foo.js.'), + }); + }); + + it('yields exceeded individual file budget', () => { + const budgets: Budget[] = [{ + type: Type.Any, + maximumError: '1kb', + }]; + const stats = { + chunks: [ + { + initial: true, + files: [ 'foo.ext', 'bar.ext' ], + }, + ], + assets: [ + { + name: 'foo.ext', + size: 1.5 * KB, + }, + { + name: 'bar.ext', + size: 0.5 * KB, + }, + ], + } as unknown as webpack.Stats.ToJsonOutput; + + const failures = Array.from(checkBudgets(budgets, stats)); - it ('supports whitespace', () => { - expect(calculateBytes(' 5kb ')).toBe(5 * 1024); - expect(calculateBytes('0.25 MB')).toBe(0.25 * 1024 * 1024); - expect(calculateBytes(' 20 % ', ' 1 mb ')).toBe(1024 * 1024 * 1.2); + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + message: jasmine.stringMatching('Exceeded maximum budget for foo.ext.'), + }); + }); }); }); diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index 1dbcfd86c552..b362ecdb40fa 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -27,6 +27,7 @@ import { getWorkerConfig, normalizeExtraEntryPoints, } from '../angular-cli-files/models/webpack-configs'; +import { ThresholdSeverity, checkBudgets } from '../angular-cli-files/utilities/bundle-calculator'; import { IndexHtmlTransform, writeIndexHtml, @@ -604,35 +605,30 @@ export function buildWebpackBrowser( } let bundleInfoText = ''; - const processedNames = new Set(); for (const result of processResults) { - processedNames.add(result.name); + const chunk = webpackStats.chunks + && webpackStats.chunks.find((chunk) => chunk.id.toString() === result.name); - const chunk = - webpackStats && - webpackStats.chunks && - webpackStats.chunks.find(c => result.name === c.id.toString()); if (result.original) { bundleInfoText += '\n' + generateBundleInfoStats(result.name, result.original, chunk); } + if (result.downlevel) { bundleInfoText += '\n' + generateBundleInfoStats(result.name, result.downlevel, chunk); } } - if (webpackStats && webpackStats.chunks) { - for (const chunk of webpackStats.chunks) { - if (processedNames.has(chunk.id.toString())) { - continue; - } - - const asset = + const unprocessedChunks = webpackStats.chunks && webpackStats.chunks + .filter((chunk) => !processResults + .find((result) => chunk.id.toString() === result.name), + ) || []; + for (const chunk of unprocessedChunks) { + const asset = webpackStats.assets && webpackStats.assets.find(a => a.name === chunk.files[0]); - bundleInfoText += - '\n' + generateBundleStats({ ...chunk, size: asset && asset.size }, true); - } + bundleInfoText += + '\n' + generateBundleStats({ ...chunk, size: asset && asset.size }, true); } bundleInfoText += @@ -643,11 +639,30 @@ export function buildWebpackBrowser( true, ); context.logger.info(bundleInfoText); + + // Check for budget errors and display them to the user. + const budgets = options.budgets || []; + for (const {severity, message} of checkBudgets(budgets, webpackStats)) { + switch (severity) { + case ThresholdSeverity.Warning: + webpackStats.warnings.push(message); + break; + case ThresholdSeverity.Error: + webpackStats.errors.push(message); + break; + default: + assertNever(severity); + break; + } + } + if (webpackStats && webpackStats.warnings.length > 0) { context.logger.warn(statsWarningsToString(webpackStats, { colors: true })); } if (webpackStats && webpackStats.errors.length > 0) { context.logger.error(statsErrorsToString(webpackStats, { colors: true })); + + return { success: false }; } } else { files = emittedFiles.filter(x => x.name !== 'polyfills-es5'); @@ -789,4 +804,9 @@ function mapErrorToMessage(error: unknown): string | undefined { return undefined; } +function assertNever(input: never): never { + throw new Error(`Unexpected call to assertNever() with input: ${ + JSON.stringify(input, null /* replacer */, 4 /* tabSize */)}`); +} + export default createBuilder(buildWebpackBrowser); diff --git a/packages/ngtools/webpack/src/resource_loader.ts b/packages/ngtools/webpack/src/resource_loader.ts index 05c58149369c..6a8ffe6d0035 100644 --- a/packages/ngtools/webpack/src/resource_loader.ts +++ b/packages/ngtools/webpack/src/resource_loader.ts @@ -46,7 +46,7 @@ export class WebpackResourceLoader { return this._reverseDependencies.get(file) || []; } - private _compile(filePath: string): Promise { + private async _compile(filePath: string): Promise { if (!this._parentCompilation) { throw new Error('WebpackResourceLoader cannot be used without parentCompilation'); @@ -97,66 +97,58 @@ export class WebpackResourceLoader { }); // Compile and return a promise - return new Promise((resolve, reject) => { + const childCompilation = await new Promise((resolve, reject) => { childCompiler.compile((err: Error, childCompilation: any) => { if (err) { reject(err); - - return; + } else { + resolve(childCompilation); } + }); + }); - // Resolve / reject the promise - const { warnings, errors } = childCompilation; + // Propagate warnings to parent compilation. + const { warnings, errors } = childCompilation; + if (warnings && warnings.length) { + this._parentCompilation.warnings.push(...warnings); + } + if (errors && errors.length) { + this._parentCompilation.errors.push(...errors); + } - if (warnings && warnings.length) { - this._parentCompilation.warnings.push(...warnings); - } + Object.keys(childCompilation.assets).forEach(assetName => { + // Add all new assets to the parent compilation, with the exception of + // the file we're loading and its sourcemap. + if ( + assetName !== filePath + && assetName !== `${filePath}.map` + && this._parentCompilation.assets[assetName] == undefined + ) { + this._parentCompilation.assets[assetName] = childCompilation.assets[assetName]; + } + }); - if (errors && errors.length) { - this._parentCompilation.errors.push(...errors); + // Save the dependencies for this resource. + this._fileDependencies.set(filePath, childCompilation.fileDependencies); + for (const file of childCompilation.fileDependencies) { + const entry = this._reverseDependencies.get(file); + if (entry) { + entry.push(filePath); + } else { + this._reverseDependencies.set(file, [filePath]); + } + } - const errorDetails = errors - .map((error: any) => error.message + (error.error ? ':\n' + error.error : '')) - .join('\n'); + const compilationHash = childCompilation.fullHash; + const maybeSource = this._cachedSources.get(compilationHash); + if (maybeSource) { + return { outputName: filePath, source: maybeSource }; + } else { + const source = childCompilation.assets[filePath].source(); + this._cachedSources.set(compilationHash, source); - reject(new Error('Child compilation failed:\n' + errorDetails)); - } else { - Object.keys(childCompilation.assets).forEach(assetName => { - // Add all new assets to the parent compilation, with the exception of - // the file we're loading and its sourcemap. - if ( - assetName !== filePath - && assetName !== `${filePath}.map` - && this._parentCompilation.assets[assetName] == undefined - ) { - this._parentCompilation.assets[assetName] = childCompilation.assets[assetName]; - } - }); - - // Save the dependencies for this resource. - this._fileDependencies.set(filePath, childCompilation.fileDependencies); - for (const file of childCompilation.fileDependencies) { - const entry = this._reverseDependencies.get(file); - if (entry) { - entry.push(filePath); - } else { - this._reverseDependencies.set(file, [filePath]); - } - } - - const compilationHash = childCompilation.fullHash; - const maybeSource = this._cachedSources.get(compilationHash); - if (maybeSource) { - resolve({ outputName: filePath, source: maybeSource }); - } else { - const source = childCompilation.assets[filePath].source(); - this._cachedSources.set(compilationHash, source); - - resolve({ outputName: filePath, source }); - } - } - }); - }); + return { outputName: filePath, source }; + } } private async _evaluate({ outputName, source }: CompilationOutput): Promise {