diff --git a/packages/angular_devkit/build_angular/src/builders/application/build-action.ts b/packages/angular_devkit/build_angular/src/builders/application/build-action.ts index 8c73282e22d8..5c450ef5ae3a 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/build-action.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/build-action.ts @@ -13,12 +13,7 @@ import path from 'node:path'; import { BuildOutputFile } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; import { shutdownSassWorkerPool } from '../../tools/esbuild/stylesheets/sass-language'; -import { - logMessages, - withNoProgress, - withSpinner, - writeResultFiles, -} from '../../tools/esbuild/utils'; +import { withNoProgress, withSpinner, writeResultFiles } from '../../tools/esbuild/utils'; import { deleteOutputDir } from '../../utils/delete-output-dir'; import { shouldWatchRoot } from '../../utils/environment-options'; import { NormalizedCachedOptions } from '../../utils/normalize-cache'; @@ -73,9 +68,6 @@ export async function* runEsBuildBuildAction( try { // Perform the build action result = await withProgress('Building...', () => action()); - - // Log all diagnostic (error/warning) messages from the build - await logMessages(logger, result); } finally { // Ensure Sass workers are shutdown if not watching if (!watch) { @@ -180,9 +172,6 @@ export async function* runEsBuildBuildAction( action(result.createRebuildState(changes)), ); - // Log all diagnostic (error/warning) messages from the rebuild - await logMessages(logger, result); - // Update watched locations provided by the new build result. // Keep watching all previous files if there are any errors; otherwise consider all // files stale until confirmed present in the new result's watch files. diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts index fc1f76867371..ee848a90ff7f 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts @@ -15,7 +15,6 @@ import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { calculateEstimatedTransferSizes, logBuildStats } from '../../tools/esbuild/utils'; import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator'; -import { colors } from '../../utils/color'; import { copyAssets } from '../../utils/copy-assets'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; import { executePostBundleSteps } from './execute-post-bundle'; @@ -38,6 +37,8 @@ export async function executeBuild( prerenderOptions, ssrOptions, verbose, + colors, + jsonLogs, } = options; // TODO: Consider integrating into watch mode. Would require full rebuild on target changes. @@ -143,12 +144,11 @@ export async function executeBuild( } // Perform i18n translation inlining if enabled - let prerenderedRoutes: string[]; if (i18nOptions.shouldInline) { const result = await inlineI18n(options, executionResult, initialFiles); executionResult.addErrors(result.errors); executionResult.addWarnings(result.warnings); - prerenderedRoutes = result.prerenderedRoutes; + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); } else { const result = await executePostBundleSteps( options, @@ -161,39 +161,20 @@ export async function executeBuild( executionResult.addErrors(result.errors); executionResult.addWarnings(result.warnings); - prerenderedRoutes = result.prerenderedRoutes; + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); executionResult.outputFiles.push(...result.additionalOutputFiles); executionResult.assetFiles.push(...result.additionalAssets); } if (prerenderOptions) { + const prerenderedRoutes = executionResult.prerenderedRoutes; executionResult.addOutputFile( 'prerendered-routes.json', - JSON.stringify({ routes: prerenderedRoutes.sort((a, b) => a.localeCompare(b)) }, null, 2), + JSON.stringify({ routes: prerenderedRoutes }, null, 2), BuildOutputFileType.Root, ); - - let prerenderMsg = `Prerendered ${prerenderedRoutes.length} static route`; - if (prerenderedRoutes.length > 1) { - prerenderMsg += 's.'; - } else { - prerenderMsg += '.'; - } - - context.logger.info(colors.magenta(prerenderMsg) + '\n'); } - logBuildStats( - context.logger, - metafile, - initialFiles, - budgetFailures, - changedFiles, - estimatedTransferSizes, - !!ssrOptions, - verbose, - ); - // Write metafile if stats option is enabled if (options.stats) { executionResult.addOutputFile( @@ -203,5 +184,20 @@ export async function executeBuild( ); } + if (!jsonLogs) { + context.logger.info( + logBuildStats( + metafile, + initialFiles, + budgetFailures, + colors, + changedFiles, + estimatedTransferSizes, + !!ssrOptions, + verbose, + ), + ); + } + return executionResult; } diff --git a/packages/angular_devkit/build_angular/src/builders/application/index.ts b/packages/angular_devkit/build_angular/src/builders/application/index.ts index 11863e2b7504..c15561e86e14 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/index.ts @@ -9,6 +9,8 @@ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +import { logMessages } from '../../tools/esbuild/utils'; +import { colors as ansiColors } from '../../utils/color'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { assertCompatibleAngularVersion } from '../../utils/version'; import { runEsBuildBuildAction } from './build-action'; @@ -83,19 +85,33 @@ export async function* buildApplicationInternal( yield* runEsBuildBuildAction( async (rebuildState) => { + const { prerenderOptions, outputOptions, jsonLogs } = normalizedOptions; + const startTime = process.hrtime.bigint(); const result = await executeBuild(normalizedOptions, context, rebuildState); - const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; - const hasError = result.errors.length > 0; + if (!jsonLogs) { + if (prerenderOptions) { + const prerenderedRoutesLength = result.prerenderedRoutes.length; + let prerenderMsg = `Prerendered ${prerenderedRoutesLength} static route`; + prerenderMsg += prerenderedRoutesLength !== 1 ? 's.' : '.'; + + logger.info(ansiColors.magenta(prerenderMsg)); + } + + const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; + const hasError = result.errors.length > 0; + if (writeToFileSystem && !hasError) { + logger.info(`Output location: ${outputOptions.base}\n`); + } - if (writeToFileSystem && !hasError) { - logger.info(`Output location: ${normalizedOptions.outputOptions.base}\n`); + logger.info( + `Application bundle generation ${hasError ? 'failed' : 'complete'}. [${buildTime.toFixed(3)} seconds]`, + ); } - logger.info( - `Application bundle generation ${hasError ? 'failed' : 'complete'}. [${buildTime.toFixed(3)} seconds]`, - ); + // Log all diagnostic (error/warning) messages + await logMessages(logger, result, normalizedOptions); return result; }, diff --git a/packages/angular_devkit/build_angular/src/builders/application/options.ts b/packages/angular_devkit/build_angular/src/builders/application/options.ts index 460e97e661d3..3679e3d5f211 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -17,6 +17,8 @@ import { normalizeGlobalStyles, } from '../../tools/webpack/utils/helpers'; import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils'; +import { colors } from '../../utils/color'; +import { useJSONBuildLogs } from '../../utils/environment-options'; import { I18nOptions, createI18nOptions } from '../../utils/i18n-options'; import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; import { normalizeCacheOptions } from '../../utils/normalize-cache'; @@ -344,6 +346,8 @@ export async function normalizeOptions( publicPath: deployUrl ? deployUrl : undefined, plugins: extensions?.codePlugins?.length ? extensions?.codePlugins : undefined, loaderExtensions, + jsonLogs: useJSONBuildLogs, + colors: colors.enabled, }; } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts index a4f4882392a6..7b5a08cdcd0a 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts @@ -329,6 +329,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu preserveSymlinks, jit, loaderExtensions, + jsonLogs, } = options; // Ensure unique hashes for i18n translation changes when using post-process inlining. @@ -355,7 +356,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu resolveExtensions: ['.ts', '.tsx', '.mjs', '.js'], metafile: true, legalComments: options.extractLicenses ? 'none' : 'eof', - logLevel: options.verbose ? 'debug' : 'silent', + logLevel: options.verbose && !jsonLogs ? 'debug' : 'silent', minifyIdentifiers: optimizationOptions.scripts && allowMangle, minifySyntax: optimizationOptions.scripts, minifyWhitespace: optimizationOptions.scripts, diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts index 66e356b873ab..02674c91d089 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts @@ -38,6 +38,7 @@ export class ExecutionResult { outputFiles: BuildOutputFile[] = []; assetFiles: BuildOutputAsset[] = []; errors: (Message | PartialMessage)[] = []; + prerenderedRoutes: string[] = []; warnings: (Message | PartialMessage)[] = []; externalMetadata?: ExternalResultMetadata; @@ -68,6 +69,12 @@ export class ExecutionResult { } } + addPrerenderedRoutes(routes: string[]): void { + this.prerenderedRoutes.push(...routes); + // Sort the prerendered routes. + this.prerenderedRoutes.sort((a, b) => a.localeCompare(b)); + } + addWarning(error: PartialMessage | string): void { if (typeof error === 'string') { this.warnings.push({ text: error, location: null }); diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/global-scripts.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/global-scripts.ts index e6661a8c0352..c2f1dc5ce260 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/global-scripts.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/global-scripts.ts @@ -34,6 +34,7 @@ export function createGlobalScriptsBundleOptions( outputNames, preserveSymlinks, sourcemapOptions, + jsonLogs, workspaceRoot, } = options; @@ -63,7 +64,7 @@ export function createGlobalScriptsBundleOptions( mainFields: ['script', 'browser', 'main'], conditions: ['script'], resolveExtensions: ['.mjs', '.js'], - logLevel: options.verbose ? 'debug' : 'silent', + logLevel: options.verbose && !jsonLogs ? 'debug' : 'silent', metafile: true, minify: optimizationOptions.scripts, outdir: workspaceRoot, @@ -81,8 +82,9 @@ export function createGlobalScriptsBundleOptions( transformPath: (path) => path.slice(namespace.length + 1) + '.js', loadContent: (args, build) => createCachedLoad(loadCache, async (args) => { - const files = globalScripts.find(({ name }) => name === args.path.slice(0, -3)) - ?.files; + const files = globalScripts.find( + ({ name }) => name === args.path.slice(0, -3), + )?.files; assert(files, `Invalid operation: global scripts name not found [${args.path}]`); // Global scripts are concatenated using magic-string instead of bundled via esbuild. diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts index 5a67f469d118..6fd6100fa6b9 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts @@ -7,30 +7,34 @@ */ import { logging } from '@angular-devkit/core'; -import { BuildOptions, Metafile, OutputFile, PartialMessage, formatMessages } from 'esbuild'; +import { BuildOptions, Metafile, OutputFile, formatMessages } from 'esbuild'; import { createHash } from 'node:crypto'; import { constants as fsConstants } from 'node:fs'; import fs from 'node:fs/promises'; -import path from 'node:path'; +import { basename, dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { brotliCompress } from 'node:zlib'; import { coerce } from 'semver'; -import { NormalizedOutputOptions } from '../../builders/application/options'; +import { + NormalizedApplicationBuildOptions, + NormalizedOutputOptions, +} from '../../builders/application/options'; import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; import { Spinner } from '../../utils/spinner'; import { BundleStats, generateEsbuildBuildStatsTable } from '../webpack/utils/stats'; import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-context'; -import { BuildOutputAsset } from './bundler-execution-result'; +import { BuildOutputAsset, ExecutionResult } from './bundler-execution-result'; export function logBuildStats( - logger: logging.LoggerApi, metafile: Metafile, initial: Map, budgetFailures: BudgetCalculatorResult[] | undefined, + colors: boolean, changedFiles?: Set, estimatedTransferSizes?: Map, ssrOutputEnabled?: boolean, verbose?: boolean, -): void { +): string { const browserStats: BundleStats[] = []; const serverStats: BundleStats[] = []; let unchangedCount = 0; @@ -62,8 +66,7 @@ export function logBuildStats( let name = initial.get(file)?.name; if (name === undefined && output.entryPoint) { - name = path - .basename(output.entryPoint) + name = basename(output.entryPoint) .replace(/\.[cm]?[jt]s$/, '') .replace(/[\\/.]/g, '-'); } @@ -83,20 +86,22 @@ export function logBuildStats( if (browserStats.length > 0 || serverStats.length > 0) { const tableText = generateEsbuildBuildStatsTable( [browserStats, serverStats], - true, + colors, unchangedCount === 0, !!estimatedTransferSizes, budgetFailures, verbose, ); - logger.info(tableText + '\n'); + return tableText + '\n'; } else if (changedFiles !== undefined) { - logger.info('\nNo output file changes.\n'); + return '\nNo output file changes.\n'; } if (unchangedCount > 0) { - logger.info(`Unchanged output files: ${unchangedCount}`); + return `Unchanged output files: ${unchangedCount}`; } + + return ''; } export async function calculateEstimatedTransferSizes( @@ -161,21 +166,6 @@ export async function withNoProgress(text: string, action: () => T | Promise< return action(); } -export async function logMessages( - logger: logging.LoggerApi, - { errors, warnings }: { errors?: PartialMessage[]; warnings?: PartialMessage[] }, -): Promise { - if (warnings?.length) { - const warningMessages = await formatMessages(warnings, { kind: 'warning', color: true }); - logger.warn(warningMessages.join('\n')); - } - - if (errors?.length) { - const errorMessages = await formatMessages(errors, { kind: 'error', color: true }); - logger.error(errorMessages.join('\n')); - } -} - /** * Generates a syntax feature object map for Angular applications based on a list of targets. * A full set of feature names can be found here: https://esbuild.github.io/api/#supported @@ -231,9 +221,9 @@ export async function writeResultFiles( ) { const directoryExists = new Set(); const ensureDirectoryExists = async (destPath: string) => { - const basePath = path.dirname(destPath); + const basePath = dirname(destPath); if (!directoryExists.has(basePath)) { - await fs.mkdir(path.join(base, basePath), { recursive: true }); + await fs.mkdir(join(base, basePath), { recursive: true }); directoryExists.add(basePath); } }; @@ -258,24 +248,24 @@ export async function writeResultFiles( ); } - const destPath = path.join(outputDir, file.path); + const destPath = join(outputDir, file.path); // Ensure output subdirectories exist await ensureDirectoryExists(destPath); // Write file contents - await fs.writeFile(path.join(base, destPath), file.contents); + await fs.writeFile(join(base, destPath), file.contents); }); if (assetFiles?.length) { await emitFilesToDisk(assetFiles, async ({ source, destination }) => { - const destPath = path.join(browser, destination); + const destPath = join(browser, destination); // Ensure output subdirectories exist await ensureDirectoryExists(destPath); // Copy file contents - await fs.copyFile(source, path.join(base, destPath), fsConstants.COPYFILE_FICLONE); + await fs.copyFile(source, join(base, destPath), fsConstants.COPYFILE_FICLONE); }); } } @@ -427,3 +417,58 @@ export function getSupportedNodeTargets(): string[] { return SUPPORTED_NODE_VERSIONS.split('||').map((v) => 'node' + coerce(v)?.version); } + +interface BuildManifest { + errors: string[]; + warnings: string[]; + outputPaths: { + root: URL; + server?: URL | undefined; + browser: URL; + }; + prerenderedRoutes?: string[]; +} + +export async function logMessages( + logger: logging.LoggerApi, + executionResult: ExecutionResult, + options: NormalizedApplicationBuildOptions, +): Promise { + const { + outputOptions: { base, server, browser }, + ssrOptions, + jsonLogs, + colors: color, + } = options; + const { warnings, errors, prerenderedRoutes } = executionResult; + const warningMessages = warnings.length + ? await formatMessages(warnings, { kind: 'warning', color }) + : []; + const errorMessages = errors.length ? await formatMessages(errors, { kind: 'error', color }) : []; + + if (jsonLogs) { + // JSON format output + const manifest: BuildManifest = { + errors: errorMessages, + warnings: warningMessages, + outputPaths: { + root: pathToFileURL(base), + browser: pathToFileURL(join(base, browser)), + server: ssrOptions ? pathToFileURL(join(base, server)) : undefined, + }, + prerenderedRoutes, + }; + + logger.info(JSON.stringify(manifest, undefined, 2)); + + return; + } + + if (warningMessages.length) { + logger.warn(warningMessages.join('\n')); + } + + if (errorMessages.length) { + logger.error(errorMessages.join('\n')); + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/environment-options.ts b/packages/angular_devkit/build_angular/src/utils/environment-options.ts index 4b5e2c354012..ec2b162cacda 100644 --- a/packages/angular_devkit/build_angular/src/utils/environment-options.ts +++ b/packages/angular_devkit/build_angular/src/utils/environment-options.ts @@ -106,3 +106,7 @@ export const shouldWatchRoot = isPresent(watchRootVariable) && isEnabled(watchRo const typeCheckingVariable = process.env['NG_BUILD_TYPE_CHECK']; export const useTypeChecking = !isPresent(typeCheckingVariable) || !isDisabled(typeCheckingVariable); + +const buildLogsJsonVariable = process.env['NG_BUILD_LOGS_JSON']; +export const useJSONBuildLogs = + isPresent(buildLogsJsonVariable) && isEnabled(buildLogsJsonVariable);