Skip to content

Commit 1fb0350

Browse files
committed
feat(@angular-devkit/build-angular): add initial support for bundle budgets to esbuild builders
The bundle budget functionality (`budgets` option) is not available when using the esbuild-based builders (`browser-esbuild`/`application`). The existing option format from the Webpack-based builder can continue to be used. All budget types except `anyComponentStyle` are implemented. Any usage of the `anyComponentStyle` type will be ignored when calculating budget failures. This type will be implemented in a future change.
1 parent 1541cfd commit 1fb0350

File tree

7 files changed

+322
-20
lines changed

7 files changed

+322
-20
lines changed

packages/angular_devkit/build_angular/src/builders/application/execute-build.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createBrowserCodeBundleOptions,
1414
createServerCodeBundleOptions,
1515
} from '../../tools/esbuild/application-code-bundle';
16+
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
1617
import { BuildOutputFileType, BundlerContext } from '../../tools/esbuild/bundler-context';
1718
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
1819
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
@@ -27,6 +28,7 @@ import {
2728
logMessages,
2829
transformSupportedBrowsersToTargets,
2930
} from '../../tools/esbuild/utils';
31+
import { checkBudgets } from '../../utils/bundle-calculator';
3032
import { copyAssets } from '../../utils/copy-assets';
3133
import { maxWorkers } from '../../utils/environment-options';
3234
import { prerenderPages } from '../../utils/server-rendering/prerender';
@@ -251,13 +253,27 @@ export async function executeBuild(
251253
}
252254
}
253255

256+
// Analyze files for bundle budget failures if present
257+
let budgetFailures;
258+
if (options.budgets) {
259+
const compatStats = generateBudgetStats(metafile, initialFiles);
260+
budgetFailures = [...checkBudgets(options.budgets, compatStats)];
261+
for (const { severity, message } of budgetFailures) {
262+
if (severity === 'error') {
263+
context.logger.error(message);
264+
} else {
265+
context.logger.warn(message);
266+
}
267+
}
268+
}
269+
254270
// Calculate estimated transfer size if scripts are optimized
255271
let estimatedTransferSizes;
256272
if (optimizationOptions.scripts || optimizationOptions.styles.minify) {
257273
estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles);
258274
}
259275

260-
logBuildStats(context, metafile, initialFiles, estimatedTransferSizes);
276+
logBuildStats(context, metafile, initialFiles, budgetFailures, estimatedTransferSizes);
261277

262278
const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9;
263279
context.logger.info(`Application bundle generation complete. [${buildTime.toFixed(3)} seconds]`);

packages/angular_devkit/build_angular/src/builders/application/options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
normalizeGlobalStyles,
1515
} from '../../tools/webpack/utils/helpers';
1616
import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils';
17+
import { calculateThresholds } from '../../utils/bundle-calculator';
1718
import { I18nOptions, createI18nOptions } from '../../utils/i18n-options';
1819
import { normalizeCacheOptions } from '../../utils/normalize-cache';
1920
import { generateEntryPoints } from '../../utils/package-chunk-sort';
@@ -238,6 +239,7 @@ export async function normalizeOptions(
238239
externalPackages,
239240
deleteOutputPath,
240241
namedChunks,
242+
budgets,
241243
} = options;
242244

243245
// Return all the normalized options
@@ -286,6 +288,7 @@ export async function normalizeOptions(
286288
tailwindConfiguration,
287289
i18nOptions,
288290
namedChunks,
291+
budgets: budgets?.length ? budgets : undefined,
289292
};
290293
}
291294

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { logging } from '@angular-devkit/core';
10+
import { lazyModuleFiles, lazyModuleFnImport } from '../../../../testing/test-utils';
11+
import { buildApplication } from '../../index';
12+
import { Type } from '../../schema';
13+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
14+
15+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
16+
const CSS_EXTENSIONS = ['css', 'scss', 'less'];
17+
const BUDGET_NOT_MET_REGEXP = /Budget .+ was not met by/;
18+
19+
describe('Option: "bundleBudgets"', () => {
20+
it(`should not warn when size is below threshold`, async () => {
21+
harness.useTarget('build', {
22+
...BASE_OPTIONS,
23+
optimization: true,
24+
budgets: [{ type: Type.All, maximumWarning: '100mb' }],
25+
});
26+
27+
const { result, logs } = await harness.executeOnce();
28+
expect(result?.success).toBe(true);
29+
expect(logs).not.toContain(
30+
jasmine.objectContaining<logging.LogEntry>({
31+
level: 'warn',
32+
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
33+
}),
34+
);
35+
});
36+
37+
it(`should error when size is above 'maximumError' threshold`, async () => {
38+
harness.useTarget('build', {
39+
...BASE_OPTIONS,
40+
optimization: true,
41+
budgets: [{ type: Type.All, maximumError: '100b' }],
42+
});
43+
44+
const { result, logs } = await harness.executeOnce();
45+
expect(logs).toContain(
46+
jasmine.objectContaining<logging.LogEntry>({
47+
level: 'error',
48+
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
49+
}),
50+
);
51+
});
52+
53+
it(`should warn when size is above 'maximumWarning' threshold`, async () => {
54+
harness.useTarget('build', {
55+
...BASE_OPTIONS,
56+
optimization: true,
57+
budgets: [{ type: Type.All, maximumWarning: '100b' }],
58+
});
59+
60+
const { result, logs } = await harness.executeOnce();
61+
expect(result?.success).toBe(true);
62+
expect(logs).toContain(
63+
jasmine.objectContaining<logging.LogEntry>({
64+
level: 'warn',
65+
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
66+
}),
67+
);
68+
});
69+
70+
it(`should warn when lazy bundle is above 'maximumWarning' threshold`, async () => {
71+
harness.useTarget('build', {
72+
...BASE_OPTIONS,
73+
optimization: true,
74+
budgets: [{ type: Type.Bundle, name: 'lazy-module', maximumWarning: '100b' }],
75+
});
76+
77+
await harness.writeFiles(lazyModuleFiles);
78+
await harness.writeFiles(lazyModuleFnImport);
79+
80+
const { result, logs } = await harness.executeOnce();
81+
expect(result?.success).toBe(true);
82+
expect(logs).toContain(
83+
jasmine.objectContaining<logging.LogEntry>({
84+
level: 'warn',
85+
message: jasmine.stringMatching('lazy-module exceeded maximum budget'),
86+
}),
87+
);
88+
});
89+
90+
CSS_EXTENSIONS.forEach((ext) => {
91+
xit(`shows warnings for large component ${ext} when using 'anyComponentStyle' when AOT`, async () => {
92+
const cssContent = `
93+
.foo { color: white; padding: 1px; }
94+
.buz { color: white; padding: 2px; }
95+
.bar { color: white; padding: 3px; }
96+
`;
97+
98+
await harness.writeFiles({
99+
[`src/app/app.component.${ext}`]: cssContent,
100+
[`src/assets/foo.${ext}`]: cssContent,
101+
[`src/styles.${ext}`]: cssContent,
102+
});
103+
104+
await harness.modifyFile('src/app/app.component.ts', (content) =>
105+
content.replace('app.component.css', `app.component.${ext}`),
106+
);
107+
108+
harness.useTarget('build', {
109+
...BASE_OPTIONS,
110+
optimization: true,
111+
aot: true,
112+
styles: [`src/styles.${ext}`],
113+
budgets: [{ type: Type.AnyComponentStyle, maximumWarning: '1b' }],
114+
});
115+
116+
const { result, logs } = await harness.executeOnce();
117+
expect(result?.success).toBe(true);
118+
expect(logs).toContain(
119+
jasmine.objectContaining<logging.LogEntry>({
120+
level: 'warn',
121+
message: jasmine.stringMatching(new RegExp(`Warning.+app.component.${ext}`)),
122+
}),
123+
);
124+
});
125+
});
126+
127+
describe(`should ignore '.map' files`, () => {
128+
it(`when 'bundle' budget`, async () => {
129+
harness.useTarget('build', {
130+
...BASE_OPTIONS,
131+
sourceMap: true,
132+
optimization: true,
133+
extractLicenses: true,
134+
budgets: [{ type: Type.Bundle, name: 'main', maximumError: '1mb' }],
135+
});
136+
137+
const { result, logs } = await harness.executeOnce();
138+
expect(result?.success).toBe(true);
139+
expect(logs).not.toContain(
140+
jasmine.objectContaining<logging.LogEntry>({
141+
level: 'error',
142+
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
143+
}),
144+
);
145+
});
146+
147+
it(`when 'intial' budget`, async () => {
148+
harness.useTarget('build', {
149+
...BASE_OPTIONS,
150+
sourceMap: true,
151+
optimization: true,
152+
extractLicenses: true,
153+
budgets: [{ type: Type.Initial, name: 'main', maximumError: '1mb' }],
154+
});
155+
156+
const { result, logs } = await harness.executeOnce();
157+
expect(result?.success).toBe(true);
158+
expect(logs).not.toContain(
159+
jasmine.objectContaining<logging.LogEntry>({
160+
level: 'error',
161+
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
162+
}),
163+
);
164+
});
165+
166+
it(`when 'all' budget`, async () => {
167+
harness.useTarget('build', {
168+
...BASE_OPTIONS,
169+
sourceMap: true,
170+
optimization: true,
171+
extractLicenses: true,
172+
budgets: [{ type: Type.All, maximumError: '1mb' }],
173+
});
174+
175+
const { result, logs } = await harness.executeOnce();
176+
expect(result?.success).toBe(true);
177+
expect(logs).not.toContain(
178+
jasmine.objectContaining<logging.LogEntry>({
179+
level: 'error',
180+
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
181+
}),
182+
);
183+
});
184+
185+
it(`when 'any' budget`, async () => {
186+
harness.useTarget('build', {
187+
...BASE_OPTIONS,
188+
sourceMap: true,
189+
optimization: true,
190+
extractLicenses: true,
191+
budgets: [{ type: Type.Any, maximumError: '1mb' }],
192+
});
193+
194+
const { result, logs } = await harness.executeOnce();
195+
expect(result?.success).toBe(true);
196+
expect(logs).not.toContain(
197+
jasmine.objectContaining<logging.LogEntry>({
198+
level: 'error',
199+
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
200+
}),
201+
);
202+
});
203+
});
204+
});
205+
});

packages/angular_devkit/build_angular/src/builders/browser-esbuild/builder-status-warnings.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import { BuilderContext } from '@angular-devkit/architect';
1010
import { Schema as BrowserBuilderOptions } from './schema';
1111

1212
const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
13-
'budgets',
14-
1513
// * Deprecated
1614
'deployUrl',
1715

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import type { Metafile } from 'esbuild';
10+
import { basename } from 'node:path';
11+
import type { BudgetStats } from '../../utils/bundle-calculator';
12+
import type { InitialFileRecord } from './bundler-context';
13+
14+
/**
15+
* Generates a bundle budget calculator compatible stats object that provides
16+
* the necessary information for the Webpack-based bundle budget code to
17+
* interoperate with the esbuild-based builders.
18+
* @param metafile The esbuild metafile of a build to use.
19+
* @param initialFiles The records of all initial files of a build.
20+
* @returns A bundle budget compatible stats object.
21+
*/
22+
export function generateBudgetStats(
23+
metafile: Metafile,
24+
initialFiles: Map<string, InitialFileRecord>,
25+
): BudgetStats {
26+
const stats: Required<BudgetStats> = {
27+
chunks: [],
28+
assets: [],
29+
};
30+
31+
for (const [file, entry] of Object.entries(metafile.outputs)) {
32+
if (!file.endsWith('.js') && !file.endsWith('.css')) {
33+
continue;
34+
}
35+
36+
const initialRecord = initialFiles.get(file);
37+
let name = initialRecord?.name;
38+
if (name === undefined && entry.entryPoint) {
39+
// For non-initial lazy modules, convert the entry point file into a Webpack compatible name
40+
name = basename(entry.entryPoint)
41+
.replace(/\.[cm]?[jt]s$/, '')
42+
.replace(/[\\/.]/g, '-');
43+
}
44+
45+
stats.chunks.push({
46+
files: [file],
47+
initial: !!initialRecord,
48+
names: name ? [name] : undefined,
49+
});
50+
stats.assets.push({ name: file, size: entry.bytes });
51+
}
52+
53+
return stats;
54+
}

packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import path, { join } from 'node:path';
1515
import { promisify } from 'node:util';
1616
import { brotliCompress } from 'node:zlib';
1717
import { coerce } from 'semver';
18+
import { BudgetCalculatorResult } from '../../utils/bundle-calculator';
1819
import { Spinner } from '../../utils/spinner';
1920
import { BundleStats, generateBuildStatsTable } from '../webpack/utils/stats';
2021
import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-context';
@@ -25,6 +26,7 @@ export function logBuildStats(
2526
context: BuilderContext,
2627
metafile: Metafile,
2728
initial: Map<string, InitialFileRecord>,
29+
budgetFailures: BudgetCalculatorResult[] | undefined,
2830
estimatedTransferSizes?: Map<string, number>,
2931
): void {
3032
const stats: BundleStats[] = [];
@@ -39,18 +41,27 @@ export function logBuildStats(
3941
continue;
4042
}
4143

44+
let name = initial.get(file)?.name;
45+
if (name === undefined && output.entryPoint) {
46+
name = path
47+
.basename(output.entryPoint)
48+
.replace(/\.[cm]?[jt]s$/, '')
49+
.replace(/[\\/.]/g, '-');
50+
}
51+
4252
stats.push({
4353
initial: initial.has(file),
44-
stats: [
45-
file,
46-
initial.get(file)?.name ?? '-',
47-
output.bytes,
48-
estimatedTransferSizes?.get(file) ?? '-',
49-
],
54+
stats: [file, name ?? '-', output.bytes, estimatedTransferSizes?.get(file) ?? '-'],
5055
});
5156
}
5257

53-
const tableText = generateBuildStatsTable(stats, true, true, !!estimatedTransferSizes, undefined);
58+
const tableText = generateBuildStatsTable(
59+
stats,
60+
true,
61+
true,
62+
!!estimatedTransferSizes,
63+
budgetFailures,
64+
);
5465

5566
context.logger.info('\n' + tableText + '\n');
5667
}

0 commit comments

Comments
 (0)