From 05030ef1d07ffcc1b74f11e7fbe3c907112163bd Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Sun, 17 May 2026 08:42:50 -0400 Subject: [PATCH 1/2] feat(@angular/build): Support splitting browser and server stats jsonfiles for easier consumption This feature supports splitting out the browser and server stats json files so it's easier to inspect the bundle in various analyzers and addresses #28185 #28671. Today, everything gets dumped into a single file and it's nearly impossible to use without hours of `fix -> remove unused browser/server chunks -> analyze` and starting the loop all over again. This feature implements the feature request I made in #28185, along with another developers request to see a stats json file for just the initial page bundle. I've tested this out in my own repository and it's already helped an incredible amount. This will be required to be in the next Major version as it will break any existing build pipeline that relies on a single stats.json file. --- .../builders/application/chunk-optimizer.ts | 20 +++ .../src/builders/application/execute-build.ts | 61 ++++++- .../tests/options/stats-json_spec.ts | 165 ++++++++++++++++++ .../esbuild/angular/component-stylesheets.ts | 4 +- .../src/tools/esbuild/bundler-context.ts | 15 +- 5 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index 2241a4204999..7859355f8a84 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -423,5 +423,25 @@ export async function optimizeChunks( } } + // Rebuild browserMetafile from the updated combined metafile and output files. + // Chunk optimization only affects browser chunks, so serverMetafile is unchanged. + const browserOutputPaths = new Set( + original.outputFiles.filter((f) => f.type === BuildOutputFileType.Browser).map((f) => f.path), + ); + const newBrowserMetafile: Metafile = { inputs: {}, outputs: {} }; + for (const [path, output] of Object.entries(original.metafile.outputs)) { + if (!browserOutputPaths.has(path)) { + continue; + } + newBrowserMetafile.outputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + const input = original.metafile.inputs[inputPath]; + if (input) { + newBrowserMetafile.inputs[inputPath] ??= input; + } + } + } + original.browserMetafile = newBrowserMetafile; + return original; } diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 53aaec882cbf..cea2a4a3dc56 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,13 +7,14 @@ */ import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; import { createAngularCompilation } from '../../tools/angular/compilation'; import { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; -import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { BuildOutputFileType, type InitialFileRecord } from '../../tools/esbuild/bundler-files'; import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { LOCALE_DATA_BASE_MODULE } from '../../tools/esbuild/i18n-locale-plugin'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; @@ -33,6 +34,38 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; +/** + * Returns a copy of the given metafile containing only outputs that appear in the + * provided initial-files map, with inputs filtered to those referenced by those outputs. + */ +function createInitialMetafile( + metafile: Metafile, + initialFiles: Map, +): Metafile { + const filteredOutputs: Metafile['outputs'] = {}; + const referencedInputs = new Set(); + + for (const [path, output] of Object.entries(metafile.outputs)) { + if (!initialFiles.has(path)) { + continue; + } + filteredOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const path of referencedInputs) { + const input = metafile.inputs[path]; + if (input) { + filteredInputs[path] = input; + } + } + + return { inputs: filteredInputs, outputs: filteredOutputs }; +} + // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -352,13 +385,33 @@ export async function executeBuild( BuildOutputFileType.Root, ); - // Write metafile if stats option is enabled + // Write metafiles if stats option is enabled if (options.stats) { + const { browserMetafile, serverMetafile } = bundlingResult; + + executionResult.addOutputFile( + 'browser-stats.json', + JSON.stringify(browserMetafile, null, 2), + BuildOutputFileType.Root, + ); executionResult.addOutputFile( - 'stats.json', - JSON.stringify(metafile, null, 2), + 'browser-initial-stats.json', + JSON.stringify(createInitialMetafile(browserMetafile, initialFiles), null, 2), BuildOutputFileType.Root, ); + + if (ssrOptions) { + executionResult.addOutputFile( + 'server-stats.json', + JSON.stringify(serverMetafile, null, 2), + BuildOutputFileType.Root, + ); + executionResult.addOutputFile( + 'server-initial-stats.json', + JSON.stringify(createInitialMetafile(serverMetafile, initialFiles), null, 2), + BuildOutputFileType.Root, + ); + } } if (!jsonLogs && !options.quiet) { diff --git a/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts new file mode 100644 index 000000000000..8ed22a52d8b3 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts @@ -0,0 +1,165 @@ +/** + * @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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "statsJson"', () => { + describe('browser-only build', () => { + it('generates only browser stats files when statsJson is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toExist(); + harness.expectFile('dist/browser-initial-stats.json').toExist(); + harness.expectFile('dist/server-stats.json').toNotExist(); + harness.expectFile('dist/server-initial-stats.json').toNotExist(); + }); + + it('does not generate any stats files when statsJson is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toNotExist(); + harness.expectFile('dist/browser-initial-stats.json').toNotExist(); + harness.expectFile('dist/server-stats.json').toNotExist(); + harness.expectFile('dist/server-initial-stats.json').toNotExist(); + }); + + it('does not generate legacy stats.json when statsJson is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/stats.json').toNotExist(); + }); + + it('browser-stats.json contains valid esbuild metafile with inputs and outputs', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const content = harness.readFile('dist/browser-stats.json'); + const parsed = JSON.parse(content) as { inputs: unknown; outputs: unknown }; + expect(parsed.inputs).toBeDefined(); + expect(parsed.outputs).toBeDefined(); + }); + + it('browser-initial-stats.json contains only a subset of browser-stats.json outputs', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const allStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as { + outputs: Record; + }; + const initialStats = JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as { + outputs: Record; + }; + + const allOutputCount = Object.keys(allStats.outputs).length; + const initialOutputCount = Object.keys(initialStats.outputs).length; + + expect(allOutputCount).toBeGreaterThanOrEqual(initialOutputCount); + for (const path of Object.keys(initialStats.outputs)) { + expect(allStats.outputs[path]).toBeDefined(); + } + }); + }); + + describe('SSR build', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content) as { files?: string[] }; + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + }); + + it('generates all four stats files for an SSR build', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toExist(); + harness.expectFile('dist/browser-initial-stats.json').toExist(); + harness.expectFile('dist/server-stats.json').toExist(); + harness.expectFile('dist/server-initial-stats.json').toExist(); + }); + + it('server-stats.json has non-empty outputs for an SSR build', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const content = harness.readFile('dist/server-stats.json'); + const parsed = JSON.parse(content) as { outputs: Record }; + expect(Object.keys(parsed.outputs).length).toBeGreaterThan(0); + }); + + it('browser-stats.json does not contain server output paths for an SSR build', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as { + outputs: Record; + }; + const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')) as { + outputs: Record; + }; + + const browserPaths = new Set(Object.keys(browserStats.outputs)); + for (const path of Object.keys(serverStats.outputs)) { + expect(browserPaths.has(path)) + .withContext(`Server output '${path}' should not appear in browser-stats.json`) + .toBeFalse(); + } + }); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index 60c80ce057c6..121c25e530dd 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -258,7 +258,7 @@ export class ComponentStylesheetBundler { } } - const metafile = result.metafile; + const { metafile, browserMetafile, serverMetafile } = result; // Remove entryPoint fields from outputs to prevent the internal component styles from being // treated as initial files. Also mark the entry as a component resource for stat reporting. Object.values(metafile.outputs).forEach((output) => { @@ -273,6 +273,8 @@ export class ComponentStylesheetBundler { contents, outputFiles, metafile, + browserMetafile, + serverMetafile, referencedFiles, externalImports: result.externalImports, initialFiles: new Map(), diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index d3f3ca567a0f..26de48b021dc 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -33,6 +33,8 @@ export type BundleContextResult = errors: undefined; warnings: Message[]; metafile: Metafile; + browserMetafile: Metafile; + serverMetafile: Metafile; outputFiles: BuildOutputFile[]; initialFiles: Map; externalImports: { @@ -112,6 +114,8 @@ export class BundlerContext { let errors: Message[] | undefined; const warnings: Message[] = []; const metafile: Metafile = { inputs: {}, outputs: {} }; + const browserMetafile: Metafile = { inputs: {}, outputs: {} }; + const serverMetafile: Metafile = { inputs: {}, outputs: {} }; const initialFiles = new Map(); const externalImportsBrowser = new Set(); const externalImportsServer = new Set(); @@ -126,12 +130,17 @@ export class BundlerContext { continue; } - // Combine metafiles used for the stats option as well as bundle budgets and console output + // Combine metafiles used for the bundle budgets and console output if (result.metafile) { Object.assign(metafile.inputs, result.metafile.inputs); Object.assign(metafile.outputs, result.metafile.outputs); } + Object.assign(browserMetafile.inputs, result.browserMetafile.inputs); + Object.assign(browserMetafile.outputs, result.browserMetafile.outputs); + Object.assign(serverMetafile.inputs, result.serverMetafile.inputs); + Object.assign(serverMetafile.outputs, result.serverMetafile.outputs); + result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); outputFiles.push(...result.outputFiles); @@ -154,6 +163,8 @@ export class BundlerContext { errors, warnings, metafile, + browserMetafile, + serverMetafile, initialFiles, outputFiles, externalImports: { @@ -416,6 +427,8 @@ export class BundlerContext { ...result, outputFiles, initialFiles, + browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile, + serverMetafile: isPlatformServer ? result.metafile : { inputs: {}, outputs: {} }, externalImports: { [isPlatformServer ? 'server' : 'browser']: externalImports, }, From 875edff9e785b198778da992bbd80ebb3e0f1725 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:59:08 +0000 Subject: [PATCH 2/2] refactor(@angular/build): track and separate platform metafiles in bundler context Track the target platform (`browser` or `server`) on individual bundler context results and introduce `BundleMergedContextResult` to maintain separate `browser` and `server` metafiles when merging. This simplifies downstream consumers: - Enables direct usage of `metafiles.browser` for browser-specific steps (budgets, i18n, chunk optimization, CommonJS checks, and post-bundle processing). - Allows `extractLicenses` and `logBuildStats` to process all platform metafiles directly without merging them into a single structure. - Removes redundant filtering of server bundles in budget calculations. - Streamlines the `statsJson` file emission for browser and server targets. --- .../builders/application/chunk-optimizer.ts | 36 +--- .../src/builders/application/execute-build.ts | 95 +++----- .../application/execute-post-bundle.ts | 8 +- .../tests/options/stats-json_spec.ts | 154 ++++--------- .../esbuild/angular/component-stylesheets.ts | 5 +- .../build/src/tools/esbuild/budget-stats.ts | 6 +- .../src/tools/esbuild/bundler-context.ts | 64 +++--- .../src/tools/esbuild/license-extractor.ts | 202 +++++++++--------- .../angular/build/src/tools/esbuild/utils.ts | 18 +- 9 files changed, 243 insertions(+), 345 deletions(-) diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index 7859355f8a84..ea4c2f8076f9 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -20,7 +20,7 @@ import type { Message, Metafile } from 'esbuild'; import assert from 'node:assert'; import type { Plugin } from 'rollup'; -import { BundleContextResult } from '../../tools/esbuild/bundler-context'; +import { BundleMergedContextResult } from '../../tools/esbuild/bundler-context'; import { type BuildOutputFile, BuildOutputFileType, @@ -212,9 +212,9 @@ function createChunkOptimizationFailureMessage(message: string): Message { */ // eslint-disable-next-line max-lines-per-function export async function optimizeChunks( - original: BundleContextResult, + original: BundleMergedContextResult, sourcemap: boolean | 'hidden', -): Promise { +): Promise { // Failed builds cannot be optimized if (original.errors) { return original; @@ -235,7 +235,7 @@ export async function optimizeChunks( } // No action required if no browser main entrypoint or metafile for stats - if (!mainFile || !original.metafile) { + if (!mainFile || !original.metafiles.browser) { return original; } @@ -340,9 +340,9 @@ export async function optimizeChunks( } // Update metafile - const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafile); + const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafiles.browser); // Add back the outputs that were not part of the optimization - for (const [path, output] of Object.entries(original.metafile.outputs)) { + for (const [path, output] of Object.entries(original.metafiles.browser.outputs)) { if (usedChunks.has(path)) { continue; } @@ -350,11 +350,11 @@ export async function optimizeChunks( newMetafile.outputs[path] = output; for (const inputPath of Object.keys(output.inputs)) { if (!newMetafile.inputs[inputPath]) { - newMetafile.inputs[inputPath] = original.metafile.inputs[inputPath]; + newMetafile.inputs[inputPath] = original.metafiles.browser.inputs[inputPath]; } } } - original.metafile = newMetafile; + original.metafiles.browser = newMetafile; // Remove used chunks and associated sourcemaps from the original result original.outputFiles = original.outputFiles.filter( @@ -423,25 +423,5 @@ export async function optimizeChunks( } } - // Rebuild browserMetafile from the updated combined metafile and output files. - // Chunk optimization only affects browser chunks, so serverMetafile is unchanged. - const browserOutputPaths = new Set( - original.outputFiles.filter((f) => f.type === BuildOutputFileType.Browser).map((f) => f.path), - ); - const newBrowserMetafile: Metafile = { inputs: {}, outputs: {} }; - for (const [path, output] of Object.entries(original.metafile.outputs)) { - if (!browserOutputPaths.has(path)) { - continue; - } - newBrowserMetafile.outputs[path] = output; - for (const inputPath of Object.keys(output.inputs)) { - const input = original.metafile.inputs[inputPath]; - if (input) { - newBrowserMetafile.inputs[inputPath] ??= input; - } - } - } - original.browserMetafile = newBrowserMetafile; - return original; } diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index cea2a4a3dc56..dc235d73bb8d 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,7 +7,6 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import type { Metafile } from 'esbuild'; import { createAngularCompilation } from '../../tools/angular/compilation'; import { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; @@ -34,38 +33,6 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; -/** - * Returns a copy of the given metafile containing only outputs that appear in the - * provided initial-files map, with inputs filtered to those referenced by those outputs. - */ -function createInitialMetafile( - metafile: Metafile, - initialFiles: Map, -): Metafile { - const filteredOutputs: Metafile['outputs'] = {}; - const referencedInputs = new Set(); - - for (const [path, output] of Object.entries(metafile.outputs)) { - if (!initialFiles.has(path)) { - continue; - } - filteredOutputs[path] = output; - for (const inputPath of Object.keys(output.inputs)) { - referencedInputs.add(inputPath); - } - } - - const filteredInputs: Metafile['inputs'] = {}; - for (const path of referencedInputs) { - const input = metafile.inputs[path]; - if (input) { - filteredInputs[path] = input; - } - } - - return { inputs: filteredInputs, outputs: filteredOutputs }; -} - // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -101,7 +68,7 @@ export async function executeBuild( let bundlerContexts; let componentStyleBundler; let codeBundleCache; - let bundlingResult: BundleContextResult; + let bundlingIndividualResults: BundleContextResult[]; let templateUpdates: Map | undefined; let angularCompilationContext: AngularCompilationContext | undefined; let executionResult: ExecutionResult | undefined; @@ -124,7 +91,7 @@ export async function executeBuild( // Bundle all contexts that do not require TypeScript changed file checks. // These will automatically use cached results based on the changed files. - bundlingResult = await BundlerContext.bundleAll( + bundlingIndividualResults = await BundlerContext.bundleAll( bundlerContexts.otherContexts, allFileChanges, ); @@ -138,7 +105,8 @@ export async function executeBuild( const result = await typescriptContext.bundle(forceTypeScriptRebuild); typescriptResults.push(result); } - bundlingResult = BundlerContext.mergeResults([bundlingResult, ...typescriptResults]); + + bundlingIndividualResults.push(...typescriptResults); } else { const target = transformSupportedBrowsersToTargets(browsers); codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); @@ -167,7 +135,7 @@ export async function executeBuild( ); // Bundle everything on initial build - bundlingResult = await BundlerContext.bundleAll([ + bundlingIndividualResults = await BundlerContext.bundleAll([ ...bundlerContexts.typescriptContexts, ...bundlerContexts.otherContexts, ]); @@ -179,9 +147,11 @@ export async function executeBuild( componentStyleBundler.invalidate(rebuildState.fileChanges.all); const componentResults = await componentStyleBundler.bundleAllFiles(true, true); - bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]); + bundlingIndividualResults.push(...componentResults); } + let bundlingResult = BundlerContext.mergeResults(bundlingIndividualResults); + executionResult.addWarnings(bundlingResult.warnings); // Add used external component style referenced files to be watched @@ -213,8 +183,8 @@ export async function executeBuild( if (options.optimizationOptions.scripts) { // Count lazy chunks (files not needed for initial load). // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. - const { metafile, initialFiles } = bundlingResult; - const lazyChunksCount = Object.keys(metafile.outputs).filter( + const { metafiles, initialFiles } = bundlingResult; + const lazyChunksCount = Object.keys(metafiles.browser.outputs || {}).filter( (path) => path.endsWith('.js') && !initialFiles.has(path), ).length; @@ -293,14 +263,19 @@ export async function executeBuild( executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); } - const { metafile, initialFiles, outputFiles } = bundlingResult; + const { + metafiles: { browser: browserMetafile, server: serverMetafile }, + initialFiles, + outputFiles, + } = bundlingResult; + const metafiles = [browserMetafile, serverMetafile]; executionResult.outputFiles.push(...outputFiles); // Analyze files for bundle budget failures if present let budgetFailures: BudgetCalculatorResult[] | undefined; if (options.budgets) { - const compatStats = generateBudgetStats(metafile, outputFiles, initialFiles); + const compatStats = generateBudgetStats(browserMetafile, outputFiles, initialFiles); budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; for (const { message, severity } of budgetFailures) { if (severity === 'error') { @@ -319,7 +294,7 @@ export async function executeBuild( // Check metafile for CommonJS module usage if optimizing scripts if (optimizationOptions.scripts) { - const messages = checkCommonJSModules(metafile, options.allowedCommonJsDependencies); + const messages = checkCommonJSModules(browserMetafile, options.allowedCommonJsDependencies); executionResult.addWarnings(messages); } @@ -332,7 +307,7 @@ export async function executeBuild( if (options.extractLicenses) { executionResult.addOutputFile( '3rdpartylicenses.txt', - await extractLicenses(metafile, workspaceRoot), + await extractLicenses(metafiles, workspaceRoot), BuildOutputFileType.Root, ); } @@ -355,13 +330,13 @@ export async function executeBuild( // Perform i18n translation inlining if enabled if (i18nOptions.shouldInline) { - const result = await inlineI18n(metafile, options, executionResult, initialFiles); + const result = await inlineI18n(browserMetafile, options, executionResult, initialFiles); executionResult.addErrors(result.errors); executionResult.addWarnings(result.warnings); executionResult.addPrerenderedRoutes(result.prerenderedRoutes); } else { const result = await executePostBundleSteps( - metafile, + browserMetafile, options, executionResult.outputFiles, executionResult.assetFiles, @@ -379,38 +354,28 @@ export async function executeBuild( executionResult.assetFiles.push(...result.additionalAssets); } - executionResult.addOutputFile( - 'prerendered-routes.json', - JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), - BuildOutputFileType.Root, - ); + if (serverEntryPoint) { + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), + BuildOutputFileType.Root, + ); + } // Write metafiles if stats option is enabled if (options.stats) { - const { browserMetafile, serverMetafile } = bundlingResult; - executionResult.addOutputFile( 'browser-stats.json', JSON.stringify(browserMetafile, null, 2), BuildOutputFileType.Root, ); - executionResult.addOutputFile( - 'browser-initial-stats.json', - JSON.stringify(createInitialMetafile(browserMetafile, initialFiles), null, 2), - BuildOutputFileType.Root, - ); - if (ssrOptions) { + if (serverEntryPoint) { executionResult.addOutputFile( 'server-stats.json', JSON.stringify(serverMetafile, null, 2), BuildOutputFileType.Root, ); - executionResult.addOutputFile( - 'server-initial-stats.json', - JSON.stringify(createInitialMetafile(serverMetafile, initialFiles), null, 2), - BuildOutputFileType.Root, - ); } } @@ -419,7 +384,7 @@ export async function executeBuild( rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); executionResult.addLog( logBuildStats( - metafile, + metafiles, outputFiles, initialFiles, budgetFailures, diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 198071c5a280..cf76a14d9030 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -36,7 +36,7 @@ import { OutputMode } from './schema'; /** * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. - * @param metafile An esbuild metafile object. + * @param browserMetafile An esbuild metafile object. * @param options The normalized application builder options used to create the build. * @param outputFiles The output files of an executed build. * @param assetFiles The assets of an executed build. @@ -45,7 +45,7 @@ import { OutputMode } from './schema'; */ // eslint-disable-next-line max-lines-per-function export async function executePostBundleSteps( - metafile: Metafile, + browserMetafile: Metafile, options: NormalizedApplicationBuildOptions, outputFiles: BuildOutputFile[], assetFiles: BuildOutputAsset[], @@ -131,7 +131,7 @@ export async function executePostBundleSteps( locale, baseHref, initialFilesPaths, - metafile, + browserMetafile, publicPath, ); @@ -214,7 +214,7 @@ export async function executePostBundleSteps( locale, baseHref, initialFilesPaths, - metafile, + browserMetafile, publicPath, ); diff --git a/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts index 8ed22a52d8b3..f38ae4996d16 100644 --- a/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts @@ -11,91 +11,51 @@ import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setu describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { describe('Option: "statsJson"', () => { - describe('browser-only build', () => { - it('generates only browser stats files when statsJson is true', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); - harness.expectFile('dist/browser-stats.json').toExist(); - harness.expectFile('dist/browser-initial-stats.json').toExist(); - harness.expectFile('dist/server-stats.json').toNotExist(); - harness.expectFile('dist/server-initial-stats.json').toNotExist(); + it('generates only browser stats file containing valid metafile data when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, }); - it('does not generate any stats files when statsJson is false', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: false, - }); + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); - harness.expectFile('dist/browser-stats.json').toNotExist(); - harness.expectFile('dist/browser-initial-stats.json').toNotExist(); - harness.expectFile('dist/server-stats.json').toNotExist(); - harness.expectFile('dist/server-initial-stats.json').toNotExist(); - }); + harness.expectFile('dist/browser-stats.json').toExist(); + harness.expectFile('dist/server-stats.json').toNotExist(); - it('does not generate legacy stats.json when statsJson is true', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: true, - }); + const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')); + expect(browserStats.inputs).toBeDefined(); + expect(browserStats.outputs).toBeDefined(); + expect(Object.keys(browserStats.outputs).length).toBeGreaterThan(0); + }); - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); - harness.expectFile('dist/stats.json').toNotExist(); + it('does not generate stats files when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: false, }); - it('browser-stats.json contains valid esbuild metafile with inputs and outputs', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toNotExist(); + harness.expectFile('dist/server-stats.json').toNotExist(); + }); - const content = harness.readFile('dist/browser-stats.json'); - const parsed = JSON.parse(content) as { inputs: unknown; outputs: unknown }; - expect(parsed.inputs).toBeDefined(); - expect(parsed.outputs).toBeDefined(); + it('does not generate stats files when not set', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, }); - it('browser-initial-stats.json contains only a subset of browser-stats.json outputs', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); - - const allStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as { - outputs: Record; - }; - const initialStats = JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as { - outputs: Record; - }; - - const allOutputCount = Object.keys(allStats.outputs).length; - const initialOutputCount = Object.keys(initialStats.outputs).length; - - expect(allOutputCount).toBeGreaterThanOrEqual(initialOutputCount); - for (const path of Object.keys(initialStats.outputs)) { - expect(allStats.outputs[path]).toBeDefined(); - } - }); + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toNotExist(); + harness.expectFile('dist/server-stats.json').toNotExist(); }); - describe('SSR build', () => { + describe('server build', () => { beforeEach(async () => { await harness.modifyFile('src/tsconfig.app.json', (content) => { - const tsConfig = JSON.parse(content) as { files?: string[] }; + const tsConfig = JSON.parse(content); tsConfig.files ??= []; tsConfig.files.push('main.server.ts'); @@ -103,7 +63,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { }); }); - it('generates all four stats files for an SSR build', async () => { + it('generates separated browser and server stats files for an SSR build', async () => { harness.useTarget('build', { ...BASE_OPTIONS, server: 'src/main.server.ts', @@ -113,52 +73,30 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toExist(); - harness.expectFile('dist/browser-initial-stats.json').toExist(); harness.expectFile('dist/server-stats.json').toExist(); - harness.expectFile('dist/server-initial-stats.json').toExist(); - }); - - it('server-stats.json has non-empty outputs for an SSR build', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - server: 'src/main.server.ts', - ssr: true, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); - const content = harness.readFile('dist/server-stats.json'); - const parsed = JSON.parse(content) as { outputs: Record }; - expect(Object.keys(parsed.outputs).length).toBeGreaterThan(0); - }); + const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')); + const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')); - it('browser-stats.json does not contain server output paths for an SSR build', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - server: 'src/main.server.ts', - ssr: true, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); + const browserPaths = new Set(Object.keys(browserStats.outputs)); + const serverPaths = new Set(Object.keys(serverStats.outputs)); - const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as { - outputs: Record; - }; - const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')) as { - outputs: Record; - }; + expect(serverPaths.size).toBeGreaterThan(0); + expect(browserPaths.size).toBeGreaterThan(0); - const browserPaths = new Set(Object.keys(browserStats.outputs)); - for (const path of Object.keys(serverStats.outputs)) { + for (const path of serverPaths) { expect(browserPaths.has(path)) .withContext(`Server output '${path}' should not appear in browser-stats.json`) .toBeFalse(); } + + for (const path of browserPaths) { + expect(serverPaths.has(path)) + .withContext(`Browser output '${path}' should not appear in server-stats.json`) + .toBeFalse(); + } }); }); }); diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index 121c25e530dd..f636e91fce4b 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -258,7 +258,7 @@ export class ComponentStylesheetBundler { } } - const { metafile, browserMetafile, serverMetafile } = result; + const { metafile } = result; // Remove entryPoint fields from outputs to prevent the internal component styles from being // treated as initial files. Also mark the entry as a component resource for stat reporting. Object.values(metafile.outputs).forEach((output) => { @@ -273,10 +273,9 @@ export class ComponentStylesheetBundler { contents, outputFiles, metafile, - browserMetafile, - serverMetafile, referencedFiles, externalImports: result.externalImports, + platform: result.platform, initialFiles: new Map(), }; } diff --git a/packages/angular/build/src/tools/esbuild/budget-stats.ts b/packages/angular/build/src/tools/esbuild/budget-stats.ts index a9c32778b3db..a14d9a24f1b6 100644 --- a/packages/angular/build/src/tools/esbuild/budget-stats.ts +++ b/packages/angular/build/src/tools/esbuild/budget-stats.ts @@ -30,12 +30,12 @@ export function generateBudgetStats( }; for (const { path: file, size, type } of outputFiles) { - if (!file.endsWith('.js') && !file.endsWith('.css')) { + // Exclude server bundles + if (type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot) { continue; } - // Exclude server bundles - if (type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot) { + if (!file.endsWith('.js') && !file.endsWith('.css')) { continue; } diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 26de48b021dc..38a82d944e32 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -33,13 +33,27 @@ export type BundleContextResult = errors: undefined; warnings: Message[]; metafile: Metafile; - browserMetafile: Metafile; - serverMetafile: Metafile; + platform: 'browser' | 'server'; + outputFiles: BuildOutputFile[]; + initialFiles: Map; + externalImports: Set; + externalConfiguration?: string[]; + }; + +export type BundleMergedContextResult = + | { errors: Message[]; warnings: Message[] } + | { + errors: undefined; + warnings: Message[]; + metafiles: { + browser: Metafile; + server: Metafile; + }; outputFiles: BuildOutputFile[]; initialFiles: Map; externalImports: { - server?: Set; - browser?: Set; + server: Set; + browser: Set; }; externalConfiguration?: string[]; }; @@ -88,11 +102,11 @@ export class BundlerContext { }; } - static async bundleAll( + static bundleAll( contexts: Iterable, changedFiles?: Iterable, - ): Promise { - const individualResults = await Promise.all( + ): Promise { + return Promise.all( [...contexts].map((context) => { if (changedFiles) { context.invalidate(changedFiles); @@ -101,19 +115,11 @@ export class BundlerContext { return context.bundle(); }), ); - - return BundlerContext.mergeResults(individualResults); } - static mergeResults(results: BundleContextResult[]): BundleContextResult { - // Return directly if only one result - if (results.length === 1) { - return results[0]; - } - + static mergeResults(results: BundleContextResult[]): BundleMergedContextResult { let errors: Message[] | undefined; const warnings: Message[] = []; - const metafile: Metafile = { inputs: {}, outputs: {} }; const browserMetafile: Metafile = { inputs: {}, outputs: {} }; const serverMetafile: Metafile = { inputs: {}, outputs: {} }; const initialFiles = new Map(); @@ -130,22 +136,20 @@ export class BundlerContext { continue; } + const platformIsBrowser = result.platform === 'browser'; + // Combine metafiles used for the bundle budgets and console output if (result.metafile) { + const metafile = platformIsBrowser ? browserMetafile : serverMetafile; Object.assign(metafile.inputs, result.metafile.inputs); Object.assign(metafile.outputs, result.metafile.outputs); } - Object.assign(browserMetafile.inputs, result.browserMetafile.inputs); - Object.assign(browserMetafile.outputs, result.browserMetafile.outputs); - Object.assign(serverMetafile.inputs, result.serverMetafile.inputs); - Object.assign(serverMetafile.outputs, result.serverMetafile.outputs); + const externalImports = platformIsBrowser ? externalImportsBrowser : externalImportsServer; + result.externalImports?.forEach((value) => externalImports.add(value)); result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); - outputFiles.push(...result.outputFiles); - result.externalImports.browser?.forEach((value) => externalImportsBrowser.add(value)); - result.externalImports.server?.forEach((value) => externalImportsServer.add(value)); if (result.externalConfiguration) { externalConfiguration ??= new Set(); @@ -162,15 +166,16 @@ export class BundlerContext { return { errors, warnings, - metafile, - browserMetafile, - serverMetafile, initialFiles, outputFiles, externalImports: { browser: externalImportsBrowser, server: externalImportsServer, }, + metafiles: { + browser: browserMetafile, + server: serverMetafile, + }, externalConfiguration: externalConfiguration ? [...externalConfiguration] : undefined, }; } @@ -427,11 +432,8 @@ export class BundlerContext { ...result, outputFiles, initialFiles, - browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile, - serverMetafile: isPlatformServer ? result.metafile : { inputs: {}, outputs: {} }, - externalImports: { - [isPlatformServer ? 'server' : 'browser']: externalImports, - }, + externalImports, + platform: isPlatformServer ? 'server' : 'browser', externalConfiguration, errors: undefined, }; diff --git a/packages/angular/build/src/tools/esbuild/license-extractor.ts b/packages/angular/build/src/tools/esbuild/license-extractor.ts index 890ebdd9826f..4c73012033bb 100644 --- a/packages/angular/build/src/tools/esbuild/license-extractor.ts +++ b/packages/angular/build/src/tools/esbuild/license-extractor.ts @@ -60,124 +60,132 @@ const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n'; * @param rootDirectory The root directory of the workspace. * @returns A string containing the content of the output licenses file. */ -export async function extractLicenses(metafile: Metafile, rootDirectory: string) { +export async function extractLicenses( + metafiles: Metafile[], + rootDirectory: string, +): Promise { let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`; const seenPaths = new Set(); const seenPackageDirectories = new Set(); const seenPackages = new Set(); - for (const entry of Object.values(metafile.outputs)) { - for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) { - // Skip if not included in output - if (bytesInOutput <= 0) { - continue; - } - - // Skip already processed paths - if (seenPaths.has(inputPath)) { - continue; - } - seenPaths.add(inputPath); + for (const metafile of metafiles) { + for (const entry of Object.values(metafile.outputs)) { + for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) { + // Skip if not included in output + if (bytesInOutput <= 0) { + continue; + } - // Skip non-package paths - if (!inputPath.includes(NODE_MODULE_SEGMENT)) { - continue; - } + // Skip already processed paths + if (seenPaths.has(inputPath)) { + continue; + } + seenPaths.add(inputPath); - // Extract the package name from the path - let baseDirectory = path.join(rootDirectory, inputPath); - let nameOrScope, nameOrFile; - let found = false; - while (baseDirectory !== path.dirname(baseDirectory)) { - const segment = path.basename(baseDirectory); - if (segment === NODE_MODULE_SEGMENT) { - found = true; - break; + // Skip non-package paths + if (!inputPath.includes(NODE_MODULE_SEGMENT)) { + continue; } - nameOrFile = nameOrScope; - nameOrScope = segment; - baseDirectory = path.dirname(baseDirectory); - } + // Extract the package name from the path + let baseDirectory = path.join(rootDirectory, inputPath); + let nameOrScope, nameOrFile; + let found = false; + while (baseDirectory !== path.dirname(baseDirectory)) { + const segment = path.basename(baseDirectory); + if (segment === NODE_MODULE_SEGMENT) { + found = true; + break; + } - // Skip non-package path edge cases that are not caught in the includes check above - if (!found || !nameOrScope) { - continue; - } + nameOrFile = nameOrScope; + nameOrScope = segment; + baseDirectory = path.dirname(baseDirectory); + } - const packageName = nameOrScope.startsWith('@') - ? `${nameOrScope}/${nameOrFile}` - : nameOrScope; - const packageDirectory = path.join(baseDirectory, packageName); + // Skip non-package path edge cases that are not caught in the includes check above + if (!found || !nameOrScope) { + continue; + } - if (seenPackageDirectories.has(packageDirectory)) { - continue; - } - seenPackageDirectories.add(packageDirectory); - - // Load the package's metadata to find the package's name, version, and license type - const packageJsonPath = path.join(packageDirectory, 'package.json'); - let packageJson; - try { - packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as { - name: string; - version: string; - // The object form is deprecated and should only be present in old packages - license?: string | { type: string }; - }; - } catch { - // Invalid package - continue; - } + const packageName = nameOrScope.startsWith('@') + ? `${nameOrScope}/${nameOrFile}` + : nameOrScope; + const packageDirectory = path.join(baseDirectory, packageName); - // Skip already processed packages - const packageId = `${packageName}@${packageJson.version}`; - if (seenPackages.has(packageId)) { - continue; - } - seenPackages.add(packageId); - - // Attempt to find license text inside package - let licenseText = ''; - if ( - typeof packageJson.license === 'string' && - packageJson.license.toUpperCase().startsWith(CUSTOM_LICENSE_TEXT) - ) { - // Attempt to load the package's custom license - let customLicensePath; - const customLicenseFile = path.normalize( - packageJson.license.slice(CUSTOM_LICENSE_TEXT.length).trim(), - ); - if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) { - // Path is attempting to access files outside of the package - // TODO: Issue warning? - } else { - customLicensePath = path.join(packageDirectory, customLicenseFile); - try { - licenseText = await readFile(customLicensePath, 'utf-8'); - } catch {} + if (seenPackageDirectories.has(packageDirectory)) { + continue; + } + seenPackageDirectories.add(packageDirectory); + + // Load the package's metadata to find the package's name, version, and license type + const packageJsonPath = path.join(packageDirectory, 'package.json'); + let packageJson; + try { + packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as { + name: string; + version: string; + // The object form is deprecated and should only be present in old packages + license?: string | { type: string }; + }; + } catch { + // Invalid package + continue; } - } else { - // Search for a license file within the root of the package - const entries = await readdir(packageDirectory, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - if ((entry.isFile() || entry.isSymbolicLink()) && LICENSE_FILE_REGEXP.test(entry.name)) { - const packageLicensePath = path.join(packageDirectory, entry.name); + // Skip already processed packages + const packageId = `${packageName}@${packageJson.version}`; + if (seenPackages.has(packageId)) { + continue; + } + seenPackages.add(packageId); + + // Attempt to find license text inside package + let licenseText = ''; + if ( + typeof packageJson.license === 'string' && + packageJson.license.toUpperCase().startsWith(CUSTOM_LICENSE_TEXT) + ) { + // Attempt to load the package's custom license + let customLicensePath; + const customLicenseFile = path.normalize( + packageJson.license.slice(CUSTOM_LICENSE_TEXT.length).trim(), + ); + if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) { + // Path is attempting to access files outside of the package + // TODO: Issue warning? + } else { + customLicensePath = path.join(packageDirectory, customLicenseFile); try { - licenseText = await readFile(packageLicensePath, 'utf-8'); - break; + licenseText = await readFile(customLicensePath, 'utf-8'); } catch {} } + } else { + // Search for a license file within the root of the package + const entries = await readdir(packageDirectory, { withFileTypes: true }).catch(() => []); + + for (const entry of entries) { + if ( + (entry.isFile() || entry.isSymbolicLink()) && + LICENSE_FILE_REGEXP.test(entry.name) + ) { + const packageLicensePath = path.join(packageDirectory, entry.name); + try { + licenseText = await readFile(packageLicensePath, 'utf-8'); + break; + } catch {} + } + } } - } - // Generate the package's license entry in the output content - extractedLicenseContent += `Package: ${packageJson.name}\n`; - extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`; - extractedLicenseContent += `\n${licenseText}\n`; - extractedLicenseContent += EXTRACTION_FILE_SEPARATOR; + // Generate the package's license entry in the output content + extractedLicenseContent += `Package: ${packageJson.name}\n`; + extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`; + extractedLicenseContent += `\n${licenseText}\n`; + extractedLicenseContent += EXTRACTION_FILE_SEPARATOR; + } } } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index e4881e184862..7d9dce4a5522 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -25,7 +25,7 @@ import { import { type BuildOutputFile, BuildOutputFileType, type InitialFileRecord } from './bundler-files'; export function logBuildStats( - metafile: Metafile, + metafiles: Metafile[], outputFiles: BuildOutputFile[], initial: Map, budgetFailures: BudgetCalculatorResult[] | undefined, @@ -65,12 +65,12 @@ export function logBuildStats( } // Skip logging external component stylesheets used for HMR - if (metafile.outputs[file] && 'ng-component' in metafile.outputs[file]) { + if (metafiles.some((mf) => mf.outputs[file] && 'ng-component' in mf.outputs[file])) { componentStyleChange = true; continue; } - const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafile, file); + const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafiles, file); const stat: BundleStats = { initial: initial.has(file), stats: [file, name ?? '-', size, estimatedTransferSizes?.get(file) ?? '-'], @@ -108,9 +108,15 @@ export function logBuildStats( return ''; } -export function getChunkNameFromMetafile(metafile: Metafile, file: string): string | undefined { - if (metafile.outputs[file]?.entryPoint) { - return getEntryPointName(metafile.outputs[file].entryPoint); +export function getChunkNameFromMetafile( + metafiles: Metafile[] | Metafile, + file: string, +): string | undefined { + const metafileArray = Array.isArray(metafiles) ? metafiles : [metafiles]; + for (const metafile of metafileArray) { + if (metafile.outputs[file]?.entryPoint) { + return getEntryPointName(metafile.outputs[file].entryPoint); + } } }