mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-21 05:52:41 +08:00
refactor: add build and rebuild related statistics and analytics
The new build and rebuild statistics are used by the CLI to submit build related information to GA.
This commit is contained in:
parent
c969152de6
commit
124be1cc9c
docs/design
goldens/public-api/angular_devkit
packages
angular/cli/src
analytics
command-builder
angular_devkit
build_angular/src
builders
webpack
build_webpack/src/webpack-dev-server
@ -45,7 +45,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.**
|
||||
| User-scoped custom dimensions | 25 |
|
||||
| All custom metrics | 50 |
|
||||
|
||||
### List Of User Custom Dimensions
|
||||
### List of User Custom Dimensions
|
||||
|
||||
<!--USER_DIMENSIONS_TABLE_BEGIN-->
|
||||
| Name | Parameter | Type |
|
||||
@ -63,7 +63,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.**
|
||||
| Optimization | `ep.ng_optimization` | `string` |
|
||||
<!--USER_DIMENSIONS_TABLE_END-->
|
||||
|
||||
### List Of Event Custom Dimensions
|
||||
### List of Event Custom Dimensions
|
||||
|
||||
<!--DIMENSIONS_TABLE_BEGIN-->
|
||||
| Name | Parameter | Type |
|
||||
@ -81,7 +81,7 @@ PROJECT NAME TO BUILD OR A MODULE NAME.**
|
||||
| Optimization | `ep.ng_optimization` | `string` |
|
||||
<!--DIMENSIONS_TABLE_END-->
|
||||
|
||||
### List Of Event Custom Metrics
|
||||
### List of Event Custom Metrics
|
||||
|
||||
<!--METRICS_TABLE_BEGIN-->
|
||||
| Name | Parameter | Type |
|
||||
@ -91,6 +91,9 @@ PROJECT NAME TO BUILD OR A MODULE NAME.**
|
||||
| InitialChunksCount | `epn.ng_initial_chunks_count` | `number` |
|
||||
| ChangedChunksCount | `epn.ng_changed_chunks_count` | `number` |
|
||||
| DurationInMs | `epn.ng_duration_ms` | `number` |
|
||||
| CssSizeInBytes | `epn.ng_css_size_bytes` | `number` |
|
||||
| JsSizeInBytes | `epn.ng_js_size_bytes` | `number` |
|
||||
| NgComponentCount | `epn.ng_component_count` | `number` |
|
||||
<!--METRICS_TABLE_END-->
|
||||
|
||||
## Debugging
|
||||
|
@ -73,6 +73,7 @@ export interface BrowserBuilderOptions {
|
||||
|
||||
// @public
|
||||
export type BrowserBuilderOutput = BuilderOutput & {
|
||||
stats: BuildEventStats;
|
||||
baseOutputPath: string;
|
||||
outputPaths: string[];
|
||||
outputPath: string;
|
||||
@ -112,6 +113,7 @@ export type DevServerBuilderOptions = Schema;
|
||||
// @public
|
||||
export type DevServerBuilderOutput = DevServerBuildOutput & {
|
||||
baseUrl: string;
|
||||
stats: BuildEventStats;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
@ -49,6 +49,7 @@ export function runWebpack(config: webpack.Configuration, context: BuilderContex
|
||||
|
||||
// @public (undocumented)
|
||||
export function runWebpackDevServer(config: webpack.Configuration, context: BuilderContext, options?: {
|
||||
shouldProvideStats?: boolean;
|
||||
devServerConfig?: WebpackDevServer.Configuration;
|
||||
logging?: WebpackLoggingCallback;
|
||||
webpackFactory?: WebpackFactory;
|
||||
|
@ -34,6 +34,7 @@ export class AnalyticsCollector {
|
||||
const requestParameters: Partial<Record<RequestParameter, PrimitiveTypes>> = {
|
||||
[RequestParameter.ProtocolVersion]: 2,
|
||||
[RequestParameter.ClientId]: userId,
|
||||
[RequestParameter.UserId]: userId,
|
||||
[RequestParameter.TrackingId]:
|
||||
/^\d+\.\d+\.\d+$/.test(VERSION.full) && VERSION.full !== '0.0.0'
|
||||
? TRACKING_ID_PROD
|
||||
|
@ -94,4 +94,7 @@ export enum EventCustomMetric {
|
||||
InitialChunksCount = 'epn.ng_initial_chunks_count',
|
||||
ChangedChunksCount = 'epn.ng_changed_chunks_count',
|
||||
DurationInMs = 'epn.ng_duration_ms',
|
||||
CssSizeInBytes = 'epn.ng_css_size_bytes',
|
||||
JsSizeInBytes = 'epn.ng_js_size_bytes',
|
||||
NgComponentCount = 'epn.ng_component_count',
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ import { spawnSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { isPackageNameSafeForAnalytics } from '../analytics/analytics';
|
||||
import { EventCustomDimension } from '../analytics/analytics-collector';
|
||||
import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters';
|
||||
import { assertIsError } from '../utilities/error';
|
||||
import { askConfirmation, askQuestion } from '../utilities/prompt';
|
||||
import { isTTY } from '../utilities/tty';
|
||||
@ -62,18 +62,79 @@ export abstract class ArchitectBaseCommandModule<T extends object>
|
||||
? await this.getAnalytics()
|
||||
: undefined;
|
||||
|
||||
analytics?.reportArchitectRunEvent({
|
||||
[EventCustomDimension.BuilderTarget]: builderName,
|
||||
});
|
||||
let outputSubscription;
|
||||
if (analytics) {
|
||||
analytics.reportArchitectRunEvent({
|
||||
[EventCustomDimension.BuilderTarget]: builderName,
|
||||
});
|
||||
|
||||
const { error, success } = await run.output.toPromise();
|
||||
await run.stop();
|
||||
let firstRun = true;
|
||||
outputSubscription = run.output.subscribe(({ stats }) => {
|
||||
const parameters = this.builderStatsToAnalyticsParameters(stats, builderName);
|
||||
if (!parameters) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
if (firstRun) {
|
||||
firstRun = false;
|
||||
analytics.reportBuildRunEvent(parameters);
|
||||
} else {
|
||||
analytics.reportRebuildRunEvent(parameters);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return success ? 0 : 1;
|
||||
try {
|
||||
const { error, success } = await run.output.toPromise();
|
||||
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
return success ? 0 : 1;
|
||||
} finally {
|
||||
await run.stop();
|
||||
outputSubscription?.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
private builderStatsToAnalyticsParameters(
|
||||
stats: json.JsonValue,
|
||||
builderName: string,
|
||||
): Partial<
|
||||
| Record<EventCustomDimension & EventCustomMetric, string | number | undefined | boolean>
|
||||
| undefined
|
||||
> {
|
||||
if (!stats || typeof stats !== 'object' || !('durationInMs' in stats)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const {
|
||||
optimization,
|
||||
allChunksCount,
|
||||
aot,
|
||||
lazyChunksCount,
|
||||
initialChunksCount,
|
||||
durationInMs,
|
||||
changedChunksCount,
|
||||
cssSizeInBytes,
|
||||
jsSizeInBytes,
|
||||
ngComponentCount,
|
||||
} = stats;
|
||||
|
||||
return {
|
||||
[EventCustomDimension.BuilderTarget]: builderName,
|
||||
[EventCustomDimension.Aot]: aot,
|
||||
[EventCustomDimension.Optimization]: optimization,
|
||||
[EventCustomMetric.AllChunksCount]: allChunksCount,
|
||||
[EventCustomMetric.LazyChunksCount]: lazyChunksCount,
|
||||
[EventCustomMetric.InitialChunksCount]: initialChunksCount,
|
||||
[EventCustomMetric.ChangedChunksCount]: changedChunksCount,
|
||||
[EventCustomMetric.DurationInMs]: durationInMs,
|
||||
[EventCustomMetric.JsSizeInBytes]: jsSizeInBytes,
|
||||
[EventCustomMetric.CssSizeInBytes]: cssSizeInBytes,
|
||||
[EventCustomMetric.NgComponentCount]: ngComponentCount,
|
||||
};
|
||||
}
|
||||
|
||||
private _architectHost: WorkspaceNodeModulesArchitectHost | undefined;
|
||||
|
@ -142,6 +142,7 @@
|
||||
"optimization": {
|
||||
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-configuration.",
|
||||
"default": true,
|
||||
"x-user-analytics": "ep.ng_optimization",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
|
@ -12,7 +12,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Observable, from } from 'rxjs';
|
||||
import { concatMap, map, switchMap } from 'rxjs/operators';
|
||||
import webpack from 'webpack';
|
||||
import webpack, { StatsCompilation } from 'webpack';
|
||||
import { ExecutionTransformer } from '../../transforms';
|
||||
import {
|
||||
deleteOutputDir,
|
||||
@ -51,6 +51,8 @@ import { getCommonConfig, getStylesConfig } from '../../webpack/configs';
|
||||
import { markAsyncChunksNonInitial } from '../../webpack/utils/async-chunks';
|
||||
import { normalizeExtraEntryPoints } from '../../webpack/utils/helpers';
|
||||
import {
|
||||
BuildEventStats,
|
||||
generateBuildEventStats,
|
||||
statsErrorsToString,
|
||||
statsHasErrors,
|
||||
statsHasWarnings,
|
||||
@ -63,6 +65,8 @@ import { Schema as BrowserBuilderSchema } from './schema';
|
||||
* @experimental Direct usage of this type is considered experimental.
|
||||
*/
|
||||
export type BrowserBuilderOutput = BuilderOutput & {
|
||||
stats: BuildEventStats;
|
||||
|
||||
baseOutputPath: string;
|
||||
/**
|
||||
* @deprecated in version 14. Use 'outputs' instead.
|
||||
@ -198,196 +202,231 @@ export function buildWebpackBrowser(
|
||||
}
|
||||
}),
|
||||
}).pipe(
|
||||
concatMap(async (buildEvent) => {
|
||||
const spinner = new Spinner();
|
||||
spinner.enabled = options.progress !== false;
|
||||
concatMap(
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
async (
|
||||
buildEvent,
|
||||
): Promise<{ output: BuilderOutput; webpackStats: StatsCompilation }> => {
|
||||
const spinner = new Spinner();
|
||||
spinner.enabled = options.progress !== false;
|
||||
|
||||
const { success, emittedFiles = [], outputPath: webpackOutputPath } = buildEvent;
|
||||
const webpackRawStats = buildEvent.webpackStats;
|
||||
if (!webpackRawStats) {
|
||||
throw new Error('Webpack stats build result is required.');
|
||||
}
|
||||
|
||||
// Fix incorrectly set `initial` value on chunks.
|
||||
const extraEntryPoints = [
|
||||
...normalizeExtraEntryPoints(options.styles || [], 'styles'),
|
||||
...normalizeExtraEntryPoints(options.scripts || [], 'scripts'),
|
||||
];
|
||||
|
||||
const webpackStats = {
|
||||
...webpackRawStats,
|
||||
chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints),
|
||||
};
|
||||
|
||||
if (!success) {
|
||||
// If using bundle downleveling then there is only one build
|
||||
// If it fails show any diagnostic messages and bail
|
||||
if (statsHasWarnings(webpackStats)) {
|
||||
context.logger.warn(statsWarningsToString(webpackStats, { colors: true }));
|
||||
}
|
||||
if (statsHasErrors(webpackStats)) {
|
||||
context.logger.error(statsErrorsToString(webpackStats, { colors: true }));
|
||||
const { success, emittedFiles = [], outputPath: webpackOutputPath } = buildEvent;
|
||||
const webpackRawStats = buildEvent.webpackStats;
|
||||
if (!webpackRawStats) {
|
||||
throw new Error('Webpack stats build result is required.');
|
||||
}
|
||||
|
||||
return { success };
|
||||
} else {
|
||||
outputPaths = ensureOutputPaths(baseOutputPath, i18n);
|
||||
// Fix incorrectly set `initial` value on chunks.
|
||||
const extraEntryPoints = [
|
||||
...normalizeExtraEntryPoints(options.styles || [], 'styles'),
|
||||
...normalizeExtraEntryPoints(options.scripts || [], 'scripts'),
|
||||
];
|
||||
|
||||
const scriptsEntryPointName = normalizeExtraEntryPoints(
|
||||
options.scripts || [],
|
||||
'scripts',
|
||||
).map((x) => x.bundleName);
|
||||
const webpackStats = {
|
||||
...webpackRawStats,
|
||||
chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints),
|
||||
};
|
||||
|
||||
if (i18n.shouldInline) {
|
||||
const success = await i18nInlineEmittedFiles(
|
||||
context,
|
||||
emittedFiles,
|
||||
i18n,
|
||||
baseOutputPath,
|
||||
Array.from(outputPaths.values()),
|
||||
scriptsEntryPointName,
|
||||
webpackOutputPath,
|
||||
options.i18nMissingTranslation,
|
||||
);
|
||||
if (!success) {
|
||||
return { success: false };
|
||||
if (!success) {
|
||||
// If using bundle downleveling then there is only one build
|
||||
// If it fails show any diagnostic messages and bail
|
||||
if (statsHasWarnings(webpackStats)) {
|
||||
context.logger.warn(statsWarningsToString(webpackStats, { colors: true }));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for budget errors and display them to the user.
|
||||
const budgets = options.budgets;
|
||||
let budgetFailures: BudgetCalculatorResult[] | undefined;
|
||||
if (budgets?.length) {
|
||||
budgetFailures = [...checkBudgets(budgets, webpackStats)];
|
||||
for (const { severity, message } of budgetFailures) {
|
||||
switch (severity) {
|
||||
case ThresholdSeverity.Warning:
|
||||
webpackStats.warnings?.push({ message });
|
||||
break;
|
||||
case ThresholdSeverity.Error:
|
||||
webpackStats.errors?.push({ message });
|
||||
break;
|
||||
default:
|
||||
assertNever(severity);
|
||||
}
|
||||
if (statsHasErrors(webpackStats)) {
|
||||
context.logger.error(statsErrorsToString(webpackStats, { colors: true }));
|
||||
}
|
||||
}
|
||||
|
||||
const buildSuccess = success && !statsHasErrors(webpackStats);
|
||||
if (buildSuccess) {
|
||||
// Copy assets
|
||||
if (!options.watch && options.assets?.length) {
|
||||
spinner.start('Copying assets...');
|
||||
try {
|
||||
await copyAssets(
|
||||
normalizeAssetPatterns(
|
||||
options.assets,
|
||||
context.workspaceRoot,
|
||||
projectRoot,
|
||||
projectSourceRoot,
|
||||
),
|
||||
Array.from(outputPaths.values()),
|
||||
context.workspaceRoot,
|
||||
);
|
||||
spinner.succeed('Copying assets complete.');
|
||||
} catch (err) {
|
||||
spinner.fail(colors.redBright('Copying of assets failed.'));
|
||||
assertIsError(err);
|
||||
return {
|
||||
webpackStats: webpackRawStats,
|
||||
output: { success: false },
|
||||
};
|
||||
} else {
|
||||
outputPaths = ensureOutputPaths(baseOutputPath, i18n);
|
||||
|
||||
return { success: false, error: 'Unable to copy assets: ' + err.message };
|
||||
const scriptsEntryPointName = normalizeExtraEntryPoints(
|
||||
options.scripts || [],
|
||||
'scripts',
|
||||
).map((x) => x.bundleName);
|
||||
|
||||
if (i18n.shouldInline) {
|
||||
const success = await i18nInlineEmittedFiles(
|
||||
context,
|
||||
emittedFiles,
|
||||
i18n,
|
||||
baseOutputPath,
|
||||
Array.from(outputPaths.values()),
|
||||
scriptsEntryPointName,
|
||||
webpackOutputPath,
|
||||
options.i18nMissingTranslation,
|
||||
);
|
||||
if (!success) {
|
||||
return {
|
||||
webpackStats: webpackRawStats,
|
||||
output: { success: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (options.index) {
|
||||
spinner.start('Generating index html...');
|
||||
// Check for budget errors and display them to the user.
|
||||
const budgets = options.budgets;
|
||||
let budgetFailures: BudgetCalculatorResult[] | undefined;
|
||||
if (budgets?.length) {
|
||||
budgetFailures = [...checkBudgets(budgets, webpackStats)];
|
||||
for (const { severity, message } of budgetFailures) {
|
||||
switch (severity) {
|
||||
case ThresholdSeverity.Warning:
|
||||
webpackStats.warnings?.push({ message });
|
||||
break;
|
||||
case ThresholdSeverity.Error:
|
||||
webpackStats.errors?.push({ message });
|
||||
break;
|
||||
default:
|
||||
assertNever(severity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entrypoints = generateEntryPoints({
|
||||
scripts: options.scripts ?? [],
|
||||
styles: options.styles ?? [],
|
||||
});
|
||||
|
||||
const indexHtmlGenerator = new IndexHtmlGenerator({
|
||||
cache: cacheOptions,
|
||||
indexPath: path.join(context.workspaceRoot, getIndexInputFile(options.index)),
|
||||
entrypoints,
|
||||
deployUrl: options.deployUrl,
|
||||
sri: options.subresourceIntegrity,
|
||||
optimization: normalizedOptimization,
|
||||
crossOrigin: options.crossOrigin,
|
||||
postTransform: transforms.indexHtml,
|
||||
});
|
||||
|
||||
let hasErrors = false;
|
||||
for (const [locale, outputPath] of outputPaths.entries()) {
|
||||
const buildSuccess = success && !statsHasErrors(webpackStats);
|
||||
if (buildSuccess) {
|
||||
// Copy assets
|
||||
if (!options.watch && options.assets?.length) {
|
||||
spinner.start('Copying assets...');
|
||||
try {
|
||||
const { content, warnings, errors } = await indexHtmlGenerator.process({
|
||||
baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref,
|
||||
// i18nLocale is used when Ivy is disabled
|
||||
lang: locale || undefined,
|
||||
outputPath,
|
||||
files: mapEmittedFilesToFileInfo(emittedFiles),
|
||||
});
|
||||
await copyAssets(
|
||||
normalizeAssetPatterns(
|
||||
options.assets,
|
||||
context.workspaceRoot,
|
||||
projectRoot,
|
||||
projectSourceRoot,
|
||||
),
|
||||
Array.from(outputPaths.values()),
|
||||
context.workspaceRoot,
|
||||
);
|
||||
spinner.succeed('Copying assets complete.');
|
||||
} catch (err) {
|
||||
spinner.fail(colors.redBright('Copying of assets failed.'));
|
||||
assertIsError(err);
|
||||
|
||||
if (warnings.length || errors.length) {
|
||||
spinner.stop();
|
||||
warnings.forEach((m) => context.logger.warn(m));
|
||||
errors.forEach((m) => {
|
||||
context.logger.error(m);
|
||||
hasErrors = true;
|
||||
return {
|
||||
output: {
|
||||
success: false,
|
||||
error: 'Unable to copy assets: ' + err.message,
|
||||
},
|
||||
webpackStats: webpackRawStats,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (options.index) {
|
||||
spinner.start('Generating index html...');
|
||||
|
||||
const entrypoints = generateEntryPoints({
|
||||
scripts: options.scripts ?? [],
|
||||
styles: options.styles ?? [],
|
||||
});
|
||||
|
||||
const indexHtmlGenerator = new IndexHtmlGenerator({
|
||||
cache: cacheOptions,
|
||||
indexPath: path.join(context.workspaceRoot, getIndexInputFile(options.index)),
|
||||
entrypoints,
|
||||
deployUrl: options.deployUrl,
|
||||
sri: options.subresourceIntegrity,
|
||||
optimization: normalizedOptimization,
|
||||
crossOrigin: options.crossOrigin,
|
||||
postTransform: transforms.indexHtml,
|
||||
});
|
||||
|
||||
let hasErrors = false;
|
||||
for (const [locale, outputPath] of outputPaths.entries()) {
|
||||
try {
|
||||
const { content, warnings, errors } = await indexHtmlGenerator.process({
|
||||
baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref,
|
||||
// i18nLocale is used when Ivy is disabled
|
||||
lang: locale || undefined,
|
||||
outputPath,
|
||||
files: mapEmittedFilesToFileInfo(emittedFiles),
|
||||
});
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
const indexOutput = path.join(outputPath, getIndexOutputFile(options.index));
|
||||
await fs.promises.mkdir(path.dirname(indexOutput), { recursive: true });
|
||||
await fs.promises.writeFile(indexOutput, content);
|
||||
} catch (error) {
|
||||
if (warnings.length || errors.length) {
|
||||
spinner.stop();
|
||||
warnings.forEach((m) => context.logger.warn(m));
|
||||
errors.forEach((m) => {
|
||||
context.logger.error(m);
|
||||
hasErrors = true;
|
||||
});
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
const indexOutput = path.join(
|
||||
outputPath,
|
||||
getIndexOutputFile(options.index),
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(indexOutput), { recursive: true });
|
||||
await fs.promises.writeFile(indexOutput, content);
|
||||
} catch (error) {
|
||||
spinner.fail('Index html generation failed.');
|
||||
assertIsError(error);
|
||||
|
||||
return {
|
||||
webpackStats: webpackRawStats,
|
||||
output: { success: false, error: error.message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
spinner.fail('Index html generation failed.');
|
||||
|
||||
return { success: false, error: mapErrorToMessage(error) };
|
||||
return {
|
||||
webpackStats: webpackRawStats,
|
||||
output: { success: false },
|
||||
};
|
||||
} else {
|
||||
spinner.succeed('Index html generation complete.');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
spinner.fail('Index html generation failed.');
|
||||
if (options.serviceWorker) {
|
||||
spinner.start('Generating service worker...');
|
||||
for (const [locale, outputPath] of outputPaths.entries()) {
|
||||
try {
|
||||
await augmentAppWithServiceWorker(
|
||||
projectRoot,
|
||||
context.workspaceRoot,
|
||||
outputPath,
|
||||
getLocaleBaseHref(i18n, locale) ?? options.baseHref ?? '/',
|
||||
options.ngswConfigPath,
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Service worker generation failed.');
|
||||
assertIsError(error);
|
||||
|
||||
return { success: false };
|
||||
} else {
|
||||
spinner.succeed('Index html generation complete.');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.serviceWorker) {
|
||||
spinner.start('Generating service worker...');
|
||||
for (const [locale, outputPath] of outputPaths.entries()) {
|
||||
try {
|
||||
await augmentAppWithServiceWorker(
|
||||
projectRoot,
|
||||
context.workspaceRoot,
|
||||
outputPath,
|
||||
getLocaleBaseHref(i18n, locale) ?? options.baseHref ?? '/',
|
||||
options.ngswConfigPath,
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Service worker generation failed.');
|
||||
|
||||
return { success: false, error: mapErrorToMessage(error) };
|
||||
return {
|
||||
webpackStats: webpackRawStats,
|
||||
output: { success: false, error: error.message },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed('Service worker generation complete.');
|
||||
spinner.succeed('Service worker generation complete.');
|
||||
}
|
||||
}
|
||||
|
||||
webpackStatsLogger(context.logger, webpackStats, config, budgetFailures);
|
||||
|
||||
return {
|
||||
webpackStats: webpackRawStats,
|
||||
output: { success: buildSuccess },
|
||||
};
|
||||
}
|
||||
|
||||
webpackStatsLogger(context.logger, webpackStats, config, budgetFailures);
|
||||
|
||||
return { success: buildSuccess };
|
||||
}
|
||||
}),
|
||||
},
|
||||
),
|
||||
map(
|
||||
(event) =>
|
||||
({ output: event, webpackStats }) =>
|
||||
({
|
||||
...event,
|
||||
stats: generateBuildEventStats(webpackStats, options),
|
||||
baseOutputPath,
|
||||
outputPath: baseOutputPath,
|
||||
outputPaths: (outputPaths && Array.from(outputPaths.values())) || [baseOutputPath],
|
||||
@ -416,18 +455,6 @@ export function buildWebpackBrowser(
|
||||
}
|
||||
}
|
||||
|
||||
function mapErrorToMessage(error: unknown): string | undefined {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assertNever(input: never): never {
|
||||
throw new Error(
|
||||
`Unexpected call to assertNever() with input: ${JSON.stringify(
|
||||
|
@ -134,6 +134,7 @@
|
||||
"optimization": {
|
||||
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-configuration.",
|
||||
"default": true,
|
||||
"x-user-analytics": "ep.ng_optimization",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
|
@ -39,7 +39,11 @@ import { addError, addWarning } from '../../utils/webpack-diagnostics';
|
||||
import { getCommonConfig, getDevServerConfig, getStylesConfig } from '../../webpack/configs';
|
||||
import { IndexHtmlWebpackPlugin } from '../../webpack/plugins/index-html-webpack-plugin';
|
||||
import { ServiceWorkerPlugin } from '../../webpack/plugins/service-worker-plugin';
|
||||
import { createWebpackLoggingCallback } from '../../webpack/utils/stats';
|
||||
import {
|
||||
BuildEventStats,
|
||||
createWebpackLoggingCallback,
|
||||
generateBuildEventStats,
|
||||
} from '../../webpack/utils/stats';
|
||||
import { Schema as BrowserBuilderSchema, OutputHashing } from '../browser/schema';
|
||||
import { Schema } from './schema';
|
||||
|
||||
@ -50,6 +54,7 @@ export type DevServerBuilderOptions = Schema;
|
||||
*/
|
||||
export type DevServerBuilderOutput = DevServerBuildOutput & {
|
||||
baseUrl: string;
|
||||
stats: BuildEventStats;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -252,6 +257,11 @@ export function serveWebpackBrowser(
|
||||
webpackDevServerFactory: require('webpack-dev-server') as typeof webpackDevServer,
|
||||
}).pipe(
|
||||
concatMap(async (buildEvent, index) => {
|
||||
const webpackRawStats = buildEvent.webpackStats;
|
||||
if (!webpackRawStats) {
|
||||
throw new Error('Webpack stats build result is required.');
|
||||
}
|
||||
|
||||
// Resolve serve address.
|
||||
const publicPath = webpackConfig.devServer?.devMiddleware?.publicPath;
|
||||
|
||||
@ -286,7 +296,11 @@ export function serveWebpackBrowser(
|
||||
logger.info(`\n${colors.redBright(colors.symbols.cross)} Failed to compile.`);
|
||||
}
|
||||
|
||||
return { ...buildEvent, baseUrl: serverAddress } as DevServerBuilderOutput;
|
||||
return {
|
||||
...buildEvent,
|
||||
baseUrl: serverAddress,
|
||||
stats: generateBuildEventStats(webpackRawStats, browserOptions),
|
||||
} as DevServerBuilderOutput;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
@ -30,6 +30,7 @@ import {
|
||||
} from '../plugins';
|
||||
import { DevToolsIgnorePlugin } from '../plugins/devtools-ignore-plugin';
|
||||
import { NamedChunksPlugin } from '../plugins/named-chunks-plugin';
|
||||
import { OccurrencesPlugin } from '../plugins/occurrences-plugin';
|
||||
import { ProgressPlugin } from '../plugins/progress-plugin';
|
||||
import { TransferSizePlugin } from '../plugins/transfer-size-plugin';
|
||||
import { createIvyPlugin } from '../plugins/typescript';
|
||||
@ -449,7 +450,15 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise<Config
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [new NamedChunksPlugin(), new DedupeModuleResolvePlugin({ verbose }), ...extraPlugins],
|
||||
plugins: [
|
||||
new NamedChunksPlugin(),
|
||||
new OccurrencesPlugin({
|
||||
aot,
|
||||
scriptsOptimization,
|
||||
}),
|
||||
new DedupeModuleResolvePlugin({ verbose }),
|
||||
...extraPlugins,
|
||||
],
|
||||
node: false,
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @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 { Compiler } from 'webpack';
|
||||
|
||||
const PLUGIN_NAME = 'angular-occurrences-plugin';
|
||||
|
||||
export interface OccurrencesPluginOptions {
|
||||
aot?: boolean;
|
||||
scriptsOptimization?: boolean;
|
||||
}
|
||||
|
||||
export class OccurrencesPlugin {
|
||||
constructor(private options: OccurrencesPluginOptions) {}
|
||||
|
||||
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) => {
|
||||
for (const assetName of Object.keys(compilationAssets)) {
|
||||
if (!assetName.endsWith('.js')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scriptAsset = compilation.getAsset(assetName);
|
||||
if (!scriptAsset || scriptAsset.source.size() <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const src = scriptAsset.source.source().toString('utf-8');
|
||||
|
||||
let ngComponentCount = 0;
|
||||
|
||||
if (!this.options.aot) {
|
||||
// Count the number of `Component({` strings (case sensitive), which happens in __decorate().
|
||||
ngComponentCount += this.countOccurrences(src, 'Component({');
|
||||
}
|
||||
|
||||
if (this.options.scriptsOptimization) {
|
||||
// for ascii_only true
|
||||
ngComponentCount += this.countOccurrences(src, '.\\u0275cmp', false);
|
||||
} else {
|
||||
// For Ivy we just count ɵcmp.src
|
||||
ngComponentCount += this.countOccurrences(src, '.ɵcmp', true);
|
||||
}
|
||||
|
||||
compilation.updateAsset(
|
||||
assetName,
|
||||
(s) => s,
|
||||
(assetInfo) => ({
|
||||
...assetInfo,
|
||||
ngComponentCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private countOccurrences(source: string, match: string, wordBreak = false): number {
|
||||
let count = 0;
|
||||
|
||||
// We condition here so branch prediction happens out of the loop, not in it.
|
||||
if (wordBreak) {
|
||||
const re = /\w/;
|
||||
for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) {
|
||||
if (!(re.test(source[pos - 1] || '') || re.test(source[pos + match.length] || ''))) {
|
||||
count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH!
|
||||
}
|
||||
|
||||
pos -= match.length;
|
||||
if (pos < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) {
|
||||
count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH!
|
||||
pos -= match.length;
|
||||
if (pos < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
@ -8,10 +8,12 @@
|
||||
|
||||
import { WebpackLoggingCallback } from '@angular-devkit/build-webpack';
|
||||
import { logging, tags } from '@angular-devkit/core';
|
||||
import assert from 'assert';
|
||||
import * as path from 'path';
|
||||
import textTable from 'text-table';
|
||||
import { Configuration, StatsCompilation } from 'webpack';
|
||||
import { Schema as BrowserBuilderOptions } from '../../builders/browser/schema';
|
||||
import { normalizeOptimization } from '../../utils';
|
||||
import { BudgetCalculatorResult } from '../../utils/bundle-calculator';
|
||||
import { colors as ansiColors, removeColor } from '../../utils/color';
|
||||
import { markAsyncChunksNonInitial } from './async-chunks';
|
||||
@ -42,7 +44,14 @@ export interface BundleStats {
|
||||
stats: BundleStatsData;
|
||||
}
|
||||
|
||||
export function generateBundleStats(info: {
|
||||
function getBuildDuration(webpackStats: StatsCompilation): number {
|
||||
assert(webpackStats.builtAt, 'buildAt cannot be undefined');
|
||||
assert(webpackStats.time, 'time cannot be undefined');
|
||||
|
||||
return Date.now() - webpackStats.builtAt + webpackStats.time;
|
||||
}
|
||||
|
||||
function generateBundleStats(info: {
|
||||
rawSize?: number;
|
||||
estimatedTransferSize?: number;
|
||||
files?: string[];
|
||||
@ -289,10 +298,8 @@ function statsToString(
|
||||
// In some cases we do things outside of webpack context
|
||||
// Such us index generation, service worker augmentation etc...
|
||||
// This will correct the time and include these.
|
||||
let time = 0;
|
||||
if (json.builtAt !== undefined && json.time !== undefined) {
|
||||
time = Date.now() - json.builtAt + json.time;
|
||||
}
|
||||
|
||||
const time = getBuildDuration(json);
|
||||
|
||||
if (unchangedChunkNumber > 0) {
|
||||
return (
|
||||
@ -442,6 +449,76 @@ export function createWebpackLoggingCallback(
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildEventStats {
|
||||
aot: boolean;
|
||||
optimization: boolean;
|
||||
allChunksCount: number;
|
||||
lazyChunksCount: number;
|
||||
initialChunksCount: number;
|
||||
changedChunksCount?: number;
|
||||
durationInMs: number;
|
||||
cssSizeInBytes: number;
|
||||
jsSizeInBytes: number;
|
||||
ngComponentCount: number;
|
||||
}
|
||||
|
||||
export function generateBuildEventStats(
|
||||
webpackStats: StatsCompilation,
|
||||
browserBuilderOptions: BrowserBuilderOptions,
|
||||
): BuildEventStats {
|
||||
const { chunks = [], assets = [] } = webpackStats;
|
||||
|
||||
let jsSizeInBytes = 0;
|
||||
let cssSizeInBytes = 0;
|
||||
let initialChunksCount = 0;
|
||||
let ngComponentCount = 0;
|
||||
let changedChunksCount = 0;
|
||||
|
||||
const allChunksCount = chunks.length;
|
||||
const isFirstRun = !runsCache.has(webpackStats.outputPath || '');
|
||||
|
||||
const chunkFiles = new Set<string>();
|
||||
for (const chunk of chunks) {
|
||||
if (!isFirstRun && chunk.rendered) {
|
||||
changedChunksCount++;
|
||||
}
|
||||
|
||||
if (chunk.initial) {
|
||||
initialChunksCount++;
|
||||
}
|
||||
|
||||
for (const file of chunk.files ?? []) {
|
||||
chunkFiles.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
if (asset.name.endsWith('.map') || !chunkFiles.has(asset.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (asset.name.endsWith('.js')) {
|
||||
jsSizeInBytes += asset.size;
|
||||
ngComponentCount += asset.info.ngComponentCount ?? 0;
|
||||
} else if (asset.name.endsWith('.css')) {
|
||||
cssSizeInBytes += asset.size;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
optimization: !!normalizeOptimization(browserBuilderOptions.optimization).scripts,
|
||||
aot: browserBuilderOptions.aot !== false,
|
||||
allChunksCount,
|
||||
lazyChunksCount: allChunksCount - initialChunksCount,
|
||||
initialChunksCount,
|
||||
changedChunksCount,
|
||||
durationInMs: getBuildDuration(webpackStats),
|
||||
cssSizeInBytes,
|
||||
jsSizeInBytes,
|
||||
ngComponentCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function webpackStatsLogger(
|
||||
logger: logging.LoggerApi,
|
||||
json: StatsCompilation,
|
||||
|
@ -28,6 +28,7 @@ export function runWebpackDevServer(
|
||||
config: webpack.Configuration,
|
||||
context: BuilderContext,
|
||||
options: {
|
||||
shouldProvideStats?: boolean;
|
||||
devServerConfig?: WebpackDevServer.Configuration;
|
||||
logging?: WebpackLoggingCallback;
|
||||
webpackFactory?: WebpackFactory;
|
||||
@ -61,6 +62,8 @@ export function runWebpackDevServer(
|
||||
const log: WebpackLoggingCallback =
|
||||
options.logging || ((stats, config) => context.logger.info(stats.toString(config.stats)));
|
||||
|
||||
const shouldProvideStats = options.shouldProvideStats ?? true;
|
||||
|
||||
return createWebpack({ ...config, watch: false }).pipe(
|
||||
switchMap(
|
||||
(webpackCompiler) =>
|
||||
@ -70,11 +73,14 @@ export function runWebpackDevServer(
|
||||
|
||||
let result: Partial<DevServerBuildOutput>;
|
||||
|
||||
const statsOptions = typeof config.stats === 'boolean' ? undefined : config.stats;
|
||||
|
||||
webpackCompiler.hooks.done.tap('build-webpack', (stats) => {
|
||||
// Log stats.
|
||||
log(stats, config);
|
||||
obs.next({
|
||||
...result,
|
||||
webpackStats: shouldProvideStats ? stats.toJson(statsOptions) : undefined,
|
||||
emittedFiles: getEmittedFiles(stats.compilation),
|
||||
success: !stats.hasErrors(),
|
||||
outputPath: stats.compilation.outputOptions.path,
|
||||
|
Loading…
x
Reference in New Issue
Block a user