Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add initial support for bundle b…
Browse files Browse the repository at this point in the history
…udgets 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.
  • Loading branch information
clydin committed Oct 6, 2023
1 parent 1541cfd commit 1fb0350
Show file tree
Hide file tree
Showing 7 changed files with 322 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createBrowserCodeBundleOptions,
createServerCodeBundleOptions,
} from '../../tools/esbuild/application-code-bundle';
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
import { BuildOutputFileType, BundlerContext } from '../../tools/esbuild/bundler-context';
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
Expand All @@ -27,6 +28,7 @@ import {
logMessages,
transformSupportedBrowsersToTargets,
} from '../../tools/esbuild/utils';
import { checkBudgets } from '../../utils/bundle-calculator';
import { copyAssets } from '../../utils/copy-assets';
import { maxWorkers } from '../../utils/environment-options';
import { prerenderPages } from '../../utils/server-rendering/prerender';
Expand Down Expand Up @@ -251,13 +253,27 @@ export async function executeBuild(
}
}

// Analyze files for bundle budget failures if present
let budgetFailures;
if (options.budgets) {
const compatStats = generateBudgetStats(metafile, initialFiles);
budgetFailures = [...checkBudgets(options.budgets, compatStats)];
for (const { severity, message } of budgetFailures) {
if (severity === 'error') {
context.logger.error(message);
} else {
context.logger.warn(message);
}
}
}

// Calculate estimated transfer size if scripts are optimized
let estimatedTransferSizes;
if (optimizationOptions.scripts || optimizationOptions.styles.minify) {
estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles);
}

logBuildStats(context, metafile, initialFiles, estimatedTransferSizes);
logBuildStats(context, metafile, initialFiles, budgetFailures, estimatedTransferSizes);

const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9;
context.logger.info(`Application bundle generation complete. [${buildTime.toFixed(3)} seconds]`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
normalizeGlobalStyles,
} from '../../tools/webpack/utils/helpers';
import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils';
import { calculateThresholds } from '../../utils/bundle-calculator';
import { I18nOptions, createI18nOptions } from '../../utils/i18n-options';
import { normalizeCacheOptions } from '../../utils/normalize-cache';
import { generateEntryPoints } from '../../utils/package-chunk-sort';
Expand Down Expand Up @@ -238,6 +239,7 @@ export async function normalizeOptions(
externalPackages,
deleteOutputPath,
namedChunks,
budgets,
} = options;

// Return all the normalized options
Expand Down Expand Up @@ -286,6 +288,7 @@ export async function normalizeOptions(
tailwindConfiguration,
i18nOptions,
namedChunks,
budgets: budgets?.length ? budgets : undefined,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* @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 { logging } from '@angular-devkit/core';
import { lazyModuleFiles, lazyModuleFnImport } from '../../../../testing/test-utils';
import { buildApplication } from '../../index';
import { Type } from '../../schema';
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';

describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
const CSS_EXTENSIONS = ['css', 'scss', 'less'];
const BUDGET_NOT_MET_REGEXP = /Budget .+ was not met by/;

describe('Option: "bundleBudgets"', () => {
it(`should not warn when size is below threshold`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
optimization: true,
budgets: [{ type: Type.All, maximumWarning: '100mb' }],
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).not.toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'warn',
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
}),
);
});

it(`should error when size is above 'maximumError' threshold`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
optimization: true,
budgets: [{ type: Type.All, maximumError: '100b' }],
});

const { result, logs } = await harness.executeOnce();
expect(logs).toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'error',
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
}),
);
});

it(`should warn when size is above 'maximumWarning' threshold`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
optimization: true,
budgets: [{ type: Type.All, maximumWarning: '100b' }],
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'warn',
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
}),
);
});

it(`should warn when lazy bundle is above 'maximumWarning' threshold`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
optimization: true,
budgets: [{ type: Type.Bundle, name: 'lazy-module', maximumWarning: '100b' }],
});

await harness.writeFiles(lazyModuleFiles);
await harness.writeFiles(lazyModuleFnImport);

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'warn',
message: jasmine.stringMatching('lazy-module exceeded maximum budget'),
}),
);
});

CSS_EXTENSIONS.forEach((ext) => {
xit(`shows warnings for large component ${ext} when using 'anyComponentStyle' when AOT`, async () => {
const cssContent = `
.foo { color: white; padding: 1px; }
.buz { color: white; padding: 2px; }
.bar { color: white; padding: 3px; }
`;

await harness.writeFiles({
[`src/app/app.component.${ext}`]: cssContent,
[`src/assets/foo.${ext}`]: cssContent,
[`src/styles.${ext}`]: cssContent,
});

await harness.modifyFile('src/app/app.component.ts', (content) =>
content.replace('app.component.css', `app.component.${ext}`),
);

harness.useTarget('build', {
...BASE_OPTIONS,
optimization: true,
aot: true,
styles: [`src/styles.${ext}`],
budgets: [{ type: Type.AnyComponentStyle, maximumWarning: '1b' }],
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'warn',
message: jasmine.stringMatching(new RegExp(`Warning.+app.component.${ext}`)),
}),
);
});
});

describe(`should ignore '.map' files`, () => {
it(`when 'bundle' budget`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
sourceMap: true,
optimization: true,
extractLicenses: true,
budgets: [{ type: Type.Bundle, name: 'main', maximumError: '1mb' }],
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).not.toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'error',
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
}),
);
});

it(`when 'intial' budget`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
sourceMap: true,
optimization: true,
extractLicenses: true,
budgets: [{ type: Type.Initial, name: 'main', maximumError: '1mb' }],
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).not.toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'error',
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
}),
);
});

it(`when 'all' budget`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
sourceMap: true,
optimization: true,
extractLicenses: true,
budgets: [{ type: Type.All, maximumError: '1mb' }],
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).not.toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'error',
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
}),
);
});

it(`when 'any' budget`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
sourceMap: true,
optimization: true,
extractLicenses: true,
budgets: [{ type: Type.Any, maximumError: '1mb' }],
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBe(true);
expect(logs).not.toContain(
jasmine.objectContaining<logging.LogEntry>({
level: 'error',
message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP),
}),
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import { BuilderContext } from '@angular-devkit/architect';
import { Schema as BrowserBuilderOptions } from './schema';

const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
'budgets',

// * Deprecated
'deployUrl',

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @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 type { Metafile } from 'esbuild';
import { basename } from 'node:path';
import type { BudgetStats } from '../../utils/bundle-calculator';
import type { InitialFileRecord } from './bundler-context';

/**
* Generates a bundle budget calculator compatible stats object that provides
* the necessary information for the Webpack-based bundle budget code to
* interoperate with the esbuild-based builders.
* @param metafile The esbuild metafile of a build to use.
* @param initialFiles The records of all initial files of a build.
* @returns A bundle budget compatible stats object.
*/
export function generateBudgetStats(
metafile: Metafile,
initialFiles: Map<string, InitialFileRecord>,
): BudgetStats {
const stats: Required<BudgetStats> = {
chunks: [],
assets: [],
};

for (const [file, entry] of Object.entries(metafile.outputs)) {
if (!file.endsWith('.js') && !file.endsWith('.css')) {
continue;
}

const initialRecord = initialFiles.get(file);
let name = initialRecord?.name;
if (name === undefined && entry.entryPoint) {
// For non-initial lazy modules, convert the entry point file into a Webpack compatible name
name = basename(entry.entryPoint)
.replace(/\.[cm]?[jt]s$/, '')
.replace(/[\\/.]/g, '-');
}

stats.chunks.push({
files: [file],
initial: !!initialRecord,
names: name ? [name] : undefined,
});
stats.assets.push({ name: file, size: entry.bytes });
}

return stats;
}
25 changes: 18 additions & 7 deletions packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import path, { join } from 'node:path';
import { promisify } from 'node:util';
import { brotliCompress } from 'node:zlib';
import { coerce } from 'semver';
import { BudgetCalculatorResult } from '../../utils/bundle-calculator';
import { Spinner } from '../../utils/spinner';
import { BundleStats, generateBuildStatsTable } from '../webpack/utils/stats';
import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-context';
Expand All @@ -25,6 +26,7 @@ export function logBuildStats(
context: BuilderContext,
metafile: Metafile,
initial: Map<string, InitialFileRecord>,
budgetFailures: BudgetCalculatorResult[] | undefined,
estimatedTransferSizes?: Map<string, number>,
): void {
const stats: BundleStats[] = [];
Expand All @@ -39,18 +41,27 @@ export function logBuildStats(
continue;
}

let name = initial.get(file)?.name;
if (name === undefined && output.entryPoint) {
name = path
.basename(output.entryPoint)
.replace(/\.[cm]?[jt]s$/, '')
.replace(/[\\/.]/g, '-');
}

stats.push({
initial: initial.has(file),
stats: [
file,
initial.get(file)?.name ?? '-',
output.bytes,
estimatedTransferSizes?.get(file) ?? '-',
],
stats: [file, name ?? '-', output.bytes, estimatedTransferSizes?.get(file) ?? '-'],
});
}

const tableText = generateBuildStatsTable(stats, true, true, !!estimatedTransferSizes, undefined);
const tableText = generateBuildStatsTable(
stats,
true,
true,
!!estimatedTransferSizes,
budgetFailures,
);

context.logger.info('\n' + tableText + '\n');
}
Expand Down
Loading

0 comments on commit 1fb0350

Please sign in to comment.