diff --git a/docs/documentation/stories.md b/docs/documentation/stories.md index babf2802e116..7b5d3f6703d0 100644 --- a/docs/documentation/stories.md +++ b/docs/documentation/stories.md @@ -14,6 +14,7 @@ - [Angular Material](stories/include-angular-material) - [AngularFire](stories/include-angularfire) - [Bootstrap](stories/include-bootstrap) + - [Budgets](stories/budgets) - [Font Awesome](stories/include-font-awesome) - [Moving Into the CLI](stories/moving-into-the-cli) - [Moving Out of the CLI](stories/moving-out-of-the-cli) diff --git a/docs/documentation/stories/budgets.md b/docs/documentation/stories/budgets.md new file mode 100644 index 000000000000..33c863465078 --- /dev/null +++ b/docs/documentation/stories/budgets.md @@ -0,0 +1,61 @@ +# Budgets + +As applications grow in functionality, they also grow in size. Budgets is a feature in the +Angular CLI which allows you to set budget thresholds in your configuration to ensure parts +of your application stay within boundries which you set. + +**.angular-cli.json** +``` +{ + ... + apps: [ + { + ... + budgets: [] + } + ] +} +``` + +## Budget Definition + +- type + - The type of budget. + - Possible values: + - bundle - The size of a specific bundle. + - initial - The initial size of the app. + - allScript - The size of all scripts. + - all - The size of the entire app. + - anyScript - The size of any one script. + - any - The size of any file. +- name + - The name of the bundle. + - Required only for type of "bundle" +- baseline + - The baseline size for comparison. +- maximumWarning + - The maximum threshold for warning relative to the baseline. +- maximumError + - The maximum threshold for error relative to the baseline. +- minimumWarning + - The minimum threshold for warning relative to the baseline. +- minimumError + - The minimum threshold for error relative to the baseline. +- warning + - The threshold for warning relative to the baseline (min & max). +- error + - The threshold for error relative to the baseline (min & max). + +## Specifying sizes + +Available formats: +123 - size in bytes +123b - size in bytes +123kb - size in kilobytes +123mb - size in megabytes +12% - percentage + +## NOTES + +All sizes are relative to baseline. +Percentages are not valid for baseline values. diff --git a/packages/@angular/cli/lib/config/schema.json b/packages/@angular/cli/lib/config/schema.json index dd34c1b389ea..7359d5d348d8 100644 --- a/packages/@angular/cli/lib/config/schema.json +++ b/packages/@angular/cli/lib/config/schema.json @@ -99,6 +99,53 @@ }, "default": [] }, + "budgets": { + "type": "array", + "description": "Threshold definitions for bundle sizes.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["bundle", "initial", "allScript", "all", "anyScript", "any"], + "description": "The type of budget" + }, + "name": { + "type": "string", + "description": "The name of the bundle" + }, + "baseline": { + "type": "string", + "description": "The baseline size for comparison." + }, + "maximumWarning": { + "type": "string", + "description": "The maximum threshold for warning relative to the baseline." + }, + "maximumError": { + "type": "string", + "description": "The maximum threshold for error relative to the baseline." + }, + "minimumWarning": { + "type": "string", + "description": "The minimum threshold for warning relative to the baseline." + }, + "minimumError": { + "type": "string", + "description": "The minimum threshold for error relative to the baseline." + }, + "warning": { + "type": "string", + "description": "The threshold for warning relative to the baseline (min & max)." + }, + "error": { + "type": "string", + "description": "The threshold for error relative to the baseline (min & max)." + } + } + }, + "default": [] + }, "deployUrl": { "type": "string", "description": "URL where files will be deployed." diff --git a/packages/@angular/cli/models/webpack-configs/production.ts b/packages/@angular/cli/models/webpack-configs/production.ts index 3f43353174e4..5b6be4dfd754 100644 --- a/packages/@angular/cli/models/webpack-configs/production.ts +++ b/packages/@angular/cli/models/webpack-configs/production.ts @@ -5,6 +5,7 @@ import * as semver from 'semver'; import { stripIndent } from 'common-tags'; import { LicenseWebpackPlugin } from 'license-webpack-plugin'; import { PurifyPlugin } from '@angular-devkit/build-optimizer'; +import { BundleBudgetPlugin } from '../../plugins/bundle-budget'; import { StaticAssetPlugin } from '../../plugins/static-asset'; import { GlobCopyWebpackPlugin } from '../../plugins/glob-copy-webpack-plugin'; import { WebpackConfigOptions } from '../webpack-config'; @@ -108,6 +109,10 @@ export function getProdConfig(wco: WebpackConfigOptions) { } } + extraPlugins.push(new BundleBudgetPlugin({ + budgets: appConfig.budgets + })); + if (buildOptions.extractLicenses) { extraPlugins.push(new LicenseWebpackPlugin({ pattern: /^(MIT|ISC|BSD.*)$/, diff --git a/packages/@angular/cli/plugins/bundle-budget.ts b/packages/@angular/cli/plugins/bundle-budget.ts new file mode 100644 index 000000000000..3ce0b7f77ee0 --- /dev/null +++ b/packages/@angular/cli/plugins/bundle-budget.ts @@ -0,0 +1,129 @@ +/** + * @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 { Budget, calculateBytes, calculateSizes } from '../utilities/bundle-calculator'; + +interface Thresholds { + maximumWarning?: number; + maximumError?: number; + minimumWarning?: number; + minimumError?: number; + warningLow?: number; + warningHigh?: number; + errorLow?: number; + errorHigh?: number; +} + +export interface BundleBudgetPluginOptions { + budgets: Budget[]; +} + +export class BundleBudgetPlugin { + constructor(private options: BundleBudgetPluginOptions) {} + + apply(compiler: any): void { + const { budgets } = this.options; + compiler.plugin('after-emit', (compilation: any, cb: Function) => { + if (!budgets || budgets.length === 0) { + cb(); + return; + } + + budgets.map(budget => { + const thresholds = this.calcualte(budget); + return { + budget, + thresholds, + sizes: calculateSizes(budget, compilation) + }; + }) + .forEach(budgetCheck => { + budgetCheck.sizes.forEach(size => { + if (budgetCheck.thresholds.maximumWarning) { + if (budgetCheck.thresholds.maximumWarning < size.size) { + compilation.warnings.push(`budgets, maximum exceeded for ${size.label}.`); + } + } + if (budgetCheck.thresholds.maximumError) { + if (budgetCheck.thresholds.maximumError < size.size) { + compilation.errors.push(`budgets, maximum exceeded for ${size.label}.`); + } + } + if (budgetCheck.thresholds.minimumWarning) { + if (budgetCheck.thresholds.minimumWarning > size.size) { + compilation.warnings.push(`budgets, minimum exceeded for ${size.label}.`); + } + } + if (budgetCheck.thresholds.minimumError) { + if (budgetCheck.thresholds.minimumError > size.size) { + compilation.errors.push(`budgets, minimum exceeded for ${size.label}.`); + } + } + if (budgetCheck.thresholds.warningLow) { + if (budgetCheck.thresholds.warningLow > size.size) { + compilation.warnings.push(`budgets, minimum exceeded for ${size.label}.`); + } + } + if (budgetCheck.thresholds.warningHigh) { + if (budgetCheck.thresholds.warningHigh < size.size) { + compilation.warnings.push(`budgets, maximum exceeded for ${size.label}.`); + } + } + if (budgetCheck.thresholds.errorLow) { + if (budgetCheck.thresholds.errorLow > size.size) { + compilation.errors.push(`budgets, minimum exceeded for ${size.label}.`); + } + } + if (budgetCheck.thresholds.errorHigh) { + if (budgetCheck.thresholds.errorHigh < size.size) { + compilation.errors.push(`budgets, maximum exceeded for ${size.label}.`); + } + } + }); + + }); + cb(); + }); + } + + private calcualte(budget: Budget): Thresholds { + let thresholds: Thresholds = {}; + if (budget.maximumWarning) { + thresholds.maximumWarning = calculateBytes(budget.maximumWarning, budget.baseline, 'pos'); + } + + if (budget.maximumError) { + thresholds.maximumError = calculateBytes(budget.maximumError, budget.baseline, 'pos'); + } + + if (budget.minimumWarning) { + thresholds.minimumWarning = calculateBytes(budget.minimumWarning, budget.baseline, 'neg'); + } + + if (budget.minimumError) { + thresholds.minimumError = calculateBytes(budget.minimumError, budget.baseline, 'neg'); + } + + if (budget.warning) { + thresholds.warningLow = calculateBytes(budget.warning, budget.baseline, 'neg'); + } + + if (budget.warning) { + thresholds.warningHigh = calculateBytes(budget.warning, budget.baseline, 'pos'); + } + + if (budget.error) { + thresholds.errorLow = calculateBytes(budget.error, budget.baseline, 'neg'); + } + + if (budget.error) { + thresholds.errorHigh = calculateBytes(budget.error, budget.baseline, 'pos'); + } + + return thresholds; + } +} diff --git a/packages/@angular/cli/plugins/webpack.ts b/packages/@angular/cli/plugins/webpack.ts index f590c5030412..c834b494a23d 100644 --- a/packages/@angular/cli/plugins/webpack.ts +++ b/packages/@angular/cli/plugins/webpack.ts @@ -2,6 +2,7 @@ export { BaseHrefWebpackPlugin } from '../lib/base-href-webpack/base-href-webpack-plugin'; export { CleanCssWebpackPlugin, CleanCssWebpackPluginOptions } from './cleancss-webpack-plugin'; export { GlobCopyWebpackPlugin, GlobCopyWebpackPluginOptions } from './glob-copy-webpack-plugin'; +export { BundleBudgetPlugin, BundleBudgetPluginOptions } from './bundle-budget'; export { NamedLazyChunksWebpackPlugin } from './named-lazy-chunks-webpack-plugin'; export { ScriptsWebpackPlugin, ScriptsWebpackPluginOptions } from './scripts-webpack-plugin'; export { SuppressExtractedTextChunksWebpackPlugin } from './suppress-entry-chunks-webpack-plugin'; diff --git a/packages/@angular/cli/tasks/eject.ts b/packages/@angular/cli/tasks/eject.ts index bd475f3bd0bf..b89b1ee2d0f1 100644 --- a/packages/@angular/cli/tasks/eject.ts +++ b/packages/@angular/cli/tasks/eject.ts @@ -90,6 +90,15 @@ class JsonWebpackSerializer { }; } + private _bundleBudgetPluginSerialize(value: any): any { + console.log('VALUE!!!'); + console.log(value); + let budgets = value.options.budgets; + return { + budgets + }; + } + private _insertConcatAssetsWebpackPluginSerialize(value: any): any { return value.entryNames; } @@ -200,6 +209,10 @@ class JsonWebpackSerializer { args = this._globCopyWebpackPluginSerialize(plugin); this._addImport('@angular/cli/plugins/webpack', 'GlobCopyWebpackPlugin'); break; + case angularCliPlugins.BundleBudgetPlugin: + args = this._bundleBudgetPluginSerialize(plugin); + this._addImport('@angular/cli/plugins/webpack', 'BundleBudgetPlugin'); + break; case angularCliPlugins.InsertConcatAssetsWebpackPlugin: args = this._insertConcatAssetsWebpackPluginSerialize(plugin); this._addImport('@angular/cli/plugins/webpack', 'InsertConcatAssetsWebpackPlugin'); diff --git a/packages/@angular/cli/utilities/bundle-calculator.ts b/packages/@angular/cli/utilities/bundle-calculator.ts new file mode 100644 index 000000000000..6a048f6f51ea --- /dev/null +++ b/packages/@angular/cli/utilities/bundle-calculator.ts @@ -0,0 +1,204 @@ +/** + * @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 + */ +export type BudgetType = 'all' | 'allScript' | 'any' | 'anyScript' | 'bundle' | 'initial'; + +export interface Budget { + /** + * The type of budget + */ + type: BudgetType; + /** + * The name of the bundle + */ + name?: string; + /** + * The baseline size for comparison. + */ + baseline?: string; + /** + * The maximum threshold for warning relative to the baseline. + */ + maximumWarning?: string; + /** + * The maximum threshold for error relative to the baseline. + */ + maximumError?: string; + /** + * The minimum threshold for warning relative to the baseline. + */ + minimumWarning?: string; + /** + * The minimum threshold for error relative to the baseline. + */ + minimumError?: string; + /** + * The threshold for warning relative to the baseline (min & max). + */ + warning?: string; + /** + * The threshold for error relative to the baseline (min & max). + */ + error?: string; +} + +export interface Compilation { + assets: any; + chunks: any[]; + warnings: string[]; + errors: string[]; +} + +export interface Size { + size: number; + label?: string; +} + +export function calculateSizes(budget: Budget, compilation: Compilation): Size[] { + const calculatorMap = { + all: AllCalculator, + allScript: AllScriptCalculator, + any: AnyCalculator, + anyScript: AnyScriptCalculator, + bundle: BundleCalculator, + initial: InitialCalculator, + }; + const ctor = calculatorMap[budget.type]; + const calculator = new ctor(budget, compilation); + return calculator.calculate(); +} + +export abstract class Calculator { + constructor (protected budget: Budget, protected compilation: Compilation) {} + + abstract calculate(): Size[]; +} + +/** + * A named bundle. + */ +class BundleCalculator extends Calculator { + calculate() { + const size: number = this.compilation.chunks + .filter(chunk => chunk.name === this.budget.name) + .reduce((files, chunk) => [...files, ...chunk.files], []) + .map((file: string) => this.compilation.assets[file].size()) + .reduce((total: number, size: number) => total + size, 0); + return [{size, label: this.budget.name}]; + } +} + +/** + * The sum of all initial chunks (marked as initial by webpack). + */ +class InitialCalculator extends Calculator { + calculate() { + const initialChunks = this.compilation.chunks.filter(chunk => chunk.isInitial); + const size: number = initialChunks + .reduce((files, chunk) => [...files, ...chunk.files], []) + .map((file: string) => this.compilation.assets[file].size()) + .reduce((total: number, size: number) => total + size, 0); + return [{size, label: 'initial'}]; + } +} + +/** + * The sum of all the scripts portions. + */ +class AllScriptCalculator extends Calculator { + calculate() { + const size: number = Object.keys(this.compilation.assets) + .filter(key => /\.js$/.test(key)) + .map(key => this.compilation.assets[key]) + .map(asset => asset.size()) + .reduce((total: number, size: number) => total + size, 0); + return [{size, label: 'total scripts'}]; + } +} + +/** + * All scripts and assets added together. + */ +class AllCalculator extends Calculator { + calculate() { + const size: number = Object.keys(this.compilation.assets) + .map(key => this.compilation.assets[key].size()) + .reduce((total: number, size: number) => total + size, 0); + return [{size, label: 'total'}]; + } +} + +/** + * Any script, individually. + */ +class AnyScriptCalculator extends Calculator { + calculate() { + return Object.keys(this.compilation.assets) + .filter(key => /\.js$/.test(key)) + .map(key => { + const asset = this.compilation.assets[key]; + return { + size: asset.size(), + label: key + }; + }); + } +} + +/** + * Any script or asset (images, css, etc). + */ +class AnyCalculator extends Calculator { + calculate() { + return Object.keys(this.compilation.assets) + .map(key => { + const asset = this.compilation.assets[key]; + return { + size: asset.size(), + label: key + }; + }); + } +} + +/** + * Calculate the bytes given a string value. + */ +export function calculateBytes(val: string, baseline?: string, factor?: ('pos' | 'neg')): number { + if (/^\d+$/.test(val)) { + return parseFloat(val); + } + + if (/^(\d+)%$/.test(val)) { + return calculatePercentBytes(val, baseline, factor); + } + + const multiplier = getMultiplier(val); + + const numberVal = parseFloat(val.replace(/((k|m|M|)b?)$/, '')); + const baselineVal = baseline ? parseFloat(baseline.replace(/((k|m|M|)b?)$/, '')) : 0; + const baselineMultiplier = baseline ? getMultiplier(baseline) : 1; + const factorMultiplier = factor ? (factor === 'pos' ? 1 : -1) : 1; + + return numberVal * multiplier + baselineVal * baselineMultiplier * factorMultiplier; +} + +function getMultiplier(val?: string): number { + if (/^(\d+)b?$/.test(val)) { + return 1; + } else if (/^(\d+)kb$/.test(val)) { + return 1000; + } else if (/^(\d+)(m|M)b$/.test(val)) { + return 1000 * 1000; + } +} + +function calculatePercentBytes(val: string, baseline?: string, factor?: ('pos' | 'neg')): number { + const baselineBytes = calculateBytes(baseline); + const percentage = parseFloat(val.replace(/%/g, '')); + return baselineBytes + baselineBytes * percentage / 100 * (factor === 'pos' ? 1 : -1); +} diff --git a/tests/acceptance/bundle-calculator.spec.ts b/tests/acceptance/bundle-calculator.spec.ts new file mode 100644 index 000000000000..d60ac083ac3a --- /dev/null +++ b/tests/acceptance/bundle-calculator.spec.ts @@ -0,0 +1,86 @@ +import * as path from 'path'; +import { calculateBytes, calculateSizes } from '@angular/cli/utilities/bundle-calculator'; +import mockFs = require('mock-fs'); + + +describe('bundle calculator', () => { + describe('calculateBytes', () => { + const kb = (n: number) => n * 1000; + const mb = (n: number) => n * 1000 * 1000; + const scenarios: any[] = [ + { expect: 1, val: '1' }, + { expect: 1, val: '1b' }, + { expect: kb(1), val: '1kb' }, + { expect: mb(1), val: '1mb' }, + { expect: 110, val: '100b', baseline: '10', factor: 'pos' }, + { expect: 110, val: '100b', baseline: '10b', factor: 'pos' }, + { expect: 90, val: '100b', baseline: '10', factor: 'neg' }, + { expect: 90, val: '100b', baseline: '10b', factor: 'neg' }, + { expect: 15, val: '50%', baseline: '10', factor: 'pos' }, + { expect: 5, val: '50%', baseline: '10', factor: 'neg' }, + { expect: kb(50) + mb(1), val: '50kb', baseline: '1mb', factor: 'pos' }, + { expect: mb(1.25), val: '25%', baseline: '1mb', factor: 'pos' }, + { expect: mb(0.75), val: '25%', baseline: '1mb', factor: 'neg' }, + ]; + scenarios.forEach(s => { + const specMsg = `${s.val} => ${s.expect}`; + const baselineMsg = s.baseline ? ` (baseline: ${s.baseline})` : ``; + const factor = s.factor ? ` (factor: ${s.factor})` : ``; + it(`should calculateBytes ${specMsg}${baselineMsg}${factor}`, () => { + const result = calculateBytes(s.val, s.baseline, s.factor); + expect(s.expect).toEqual(result); + }); + }); + }); + + describe('calculateSizes', () => { + let compilation: any; + beforeEach(() => { + compilation = { + assets: { + 'asset1.js': { size: () => 1 }, + 'asset2': { size: () => 2 }, + 'asset3.js': { size: () => 4 }, + 'asset4': { size: () => 8 }, + 'asset5': { size: () => 16 }, + }, + chunks: [ + { name: 'chunk1', files: ['asset1.js'], isInitial: true }, + { name: 'chunk2', files: ['asset2'], isInitial: false }, + { name: 'chunk3', files: ['asset3.js', 'asset4'], isInitial: false } + ] + }; + }); + + const scenarios: any[] = [ + { expect: [{size: 31, label: 'total'}], budget: { type: 'all' } }, + { expect: [{size: 5, label: 'total scripts'}], budget: { type: 'allScript' } }, + { expect: [ + {size: 1, label: 'asset1.js'}, + {size: 2, label: 'asset2'}, + {size: 4, label: 'asset3.js'}, + {size: 8, label: 'asset4'}, + {size: 16, label: 'asset5'}, + ], budget: { type: 'any' } }, + { expect: [ + {size: 1, label: 'asset1.js'}, + {size: 4, label: 'asset3.js'}, + ], budget: { type: 'anyScript' } }, + { expect: [{size: 2, label: 'chunk2'}], budget: { type: 'bundle', name: 'chunk2' } }, + { expect: [{size: 12, label: 'chunk3'}], budget: { type: 'bundle', name: 'chunk3' } }, + { expect: [{size: 1, label: 'initial'}], budget: { type: 'initial' } }, + ]; + + scenarios.forEach(s => { + const budgetName = s.budget.name ? ` (${s.budget.name})` : ''; + it(`should calulate sizes for ${s.budget.type}${budgetName}`, () => { + const sizes = calculateSizes(s.budget, compilation); + expect(sizes.length).toEqual(s.expect.length); + for (let i = 0; i < sizes.length; i++) { + expect(sizes[i].size).toEqual((s.expect[i].size)); + expect(sizes[i].label).toEqual((s.expect[i].label)); + } + }); + }); + }); +}); diff --git a/tests/e2e/tests/build/bundle-budgets.ts b/tests/e2e/tests/build/bundle-budgets.ts new file mode 100644 index 000000000000..92d35a51b557 --- /dev/null +++ b/tests/e2e/tests/build/bundle-budgets.ts @@ -0,0 +1,74 @@ +/** + * @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 { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +// tslint:disable:max-line-length +export default function () { + const budgetConfigs = [ + { + expectation: 'pass', + message: 'BIG max for all, should not error', + budget: { type: 'allScript', maximumError: '100mb' }, + }, + { + expectation: 'error', + message: 'Budget error: all, max error', + budget: { type: 'all', maximumError: '100b' }, + }, + { + expectation: 'warning', + message: 'Budget warning: all, min warning', + budget: { type: 'all', minimumWarning: '100mb' }, + } + ]; + + const promiseFactories = budgetConfigs.map(cfg => { + if (cfg.expectation === 'error') { + return () => { + return updateJsonFile('.angular-cli.json', (json) => { json.apps[0].budgets = [cfg.budget]; }) + .then(() => expectToFail(() => ng('build', '--prod'))) + .then(errorMessage => { + if (!/ERROR in budgets/.test(errorMessage)) { + throw new Error(cfg.message); + } + }); + }; + } else if (cfg.expectation === 'warning') { + return () => { + return updateJsonFile('.angular-cli.json', (json) => { json.apps[0].budgets = [cfg.budget]; }) + .then(() => ng('build', '--prod')) + .then(({ stdout }) => { + if (!/WARNING in budgets/.test(stdout)) { + throw new Error(cfg.message); + } + }); + }; + } else { // pass + return () => { + return updateJsonFile('.angular-cli.json', (json) => { json.apps[0].budgets = [cfg.budget]; }) + .then(() => ng('build', '--prod')) + .then(({ stdout }) => { + if (/(WARNING|ERROR)/.test(stdout)) { + throw new Error(cfg.message); + } + }); + }; + } + }); + + let promiseChain = Promise.resolve(); + for (let i = 0; i < promiseFactories.length; i++) { + promiseChain = promiseChain.then(promiseFactories[i]); + } + + return promiseChain; +}