From da297a946f621defbec1ac0d7c91a1425a9b499c Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 31 Jan 2024 08:15:34 +0000 Subject: [PATCH 1/2] fix(@angular-devkit/build-angular): add output location in build stats This can be used by users to determine where the output path is located without needing to read the angular.json. --- .../build_angular/src/builders/application/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 aeea29448cec..11863e2b7504 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/index.ts @@ -87,8 +87,15 @@ export async function* buildApplicationInternal( const result = await executeBuild(normalizedOptions, context, rebuildState); const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; - const status = result.errors.length > 0 ? 'failed' : 'complete'; - logger.info(`Application bundle generation ${status}. [${buildTime.toFixed(3)} seconds]`); + const hasError = result.errors.length > 0; + + if (writeToFileSystem && !hasError) { + logger.info(`Output location: ${normalizedOptions.outputOptions.base}\n`); + } + + logger.info( + `Application bundle generation ${hasError ? 'failed' : 'complete'}. [${buildTime.toFixed(3)} seconds]`, + ); return result; }, From 763b2a90cf94c4bf4f3d8ba6d143fe82b84c0e78 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 31 Jan 2024 16:42:11 +0000 Subject: [PATCH 2/2] feat(@angular-devkit/build-angular): add JSON build logs when using the application builder This change implements the capability to display JSON build logs in the terminal instead of a format readable by humans. This is particularly useful for hosting providers, as it allows them to effortlessly access the necessary information without having to parse the JSON configuration. To enable this output, set the `NG_BUILD_LOGS_JSON=1` environment variable. Additionally, warnings, errors, and logs are automatically colorized when the standard output is a WritableStream. You can disable the colors by using the `FORCE_COLOR=0` environment variable. ``` FORCE_COLOR=0 NG_BUILD_LOGS_JSON=1 ng b { "errors": [], "warnings": [], "outputPaths": { "root": "file:///usr/local/test/home//test-project/dist/test-project", "browser": "file:///usr/local/test/home//test-project/dist/test-project/browser", "server": "file:///usr/local/test/home//test-project/dist/test-project/server" }, "prerenderedRoutes": [ "/" ] } ``` ``` NG_BUILD_LOGS_JSON=1 ng b { "errors": [], "warnings": [], "outputPaths": { "root": "file:///usr/local/test/home//test-project/dist/test-project", "browser": "file:///usr/local/test/home//test-project/dist/test-project/browser", "server": "file:///usr/local/test/home//test-project/dist/test-project/server" }, "prerenderedRoutes": [ "/" ] } ``` --- .../src/builders/application/build-action.ts | 13 +- .../src/builders/application/execute-build.ts | 46 ++++---- .../src/builders/application/index.ts | 30 +++-- .../src/builders/application/options.ts | 4 + .../tools/esbuild/application-code-bundle.ts | 3 +- .../tools/esbuild/bundler-execution-result.ts | 7 ++ .../src/tools/esbuild/global-scripts.ts | 8 +- .../build_angular/src/tools/esbuild/utils.ts | 111 ++++++++++++------ .../src/utils/environment-options.ts | 4 + 9 files changed, 145 insertions(+), 81 deletions(-) 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 d8e01a490dfd..d78289fa2758 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'; @@ -336,6 +338,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);