Skip to content

Commit 05030ef

Browse files
tsteuwer-accessoalan-agius4
authored andcommitted
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.
1 parent d827ba9 commit 05030ef

5 files changed

Lines changed: 259 additions & 6 deletions

File tree

packages/angular/build/src/builders/application/chunk-optimizer.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,5 +423,25 @@ export async function optimizeChunks(
423423
}
424424
}
425425

426+
// Rebuild browserMetafile from the updated combined metafile and output files.
427+
// Chunk optimization only affects browser chunks, so serverMetafile is unchanged.
428+
const browserOutputPaths = new Set(
429+
original.outputFiles.filter((f) => f.type === BuildOutputFileType.Browser).map((f) => f.path),
430+
);
431+
const newBrowserMetafile: Metafile = { inputs: {}, outputs: {} };
432+
for (const [path, output] of Object.entries(original.metafile.outputs)) {
433+
if (!browserOutputPaths.has(path)) {
434+
continue;
435+
}
436+
newBrowserMetafile.outputs[path] = output;
437+
for (const inputPath of Object.keys(output.inputs)) {
438+
const input = original.metafile.inputs[inputPath];
439+
if (input) {
440+
newBrowserMetafile.inputs[inputPath] ??= input;
441+
}
442+
}
443+
}
444+
original.browserMetafile = newBrowserMetafile;
445+
426446
return original;
427447
}

packages/angular/build/src/builders/application/execute-build.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10+
import type { Metafile } from 'esbuild';
1011
import { createAngularCompilation } from '../../tools/angular/compilation';
1112
import { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state';
1213
import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
1314
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
1415
import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context';
1516
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
16-
import { BuildOutputFileType } from '../../tools/esbuild/bundler-files';
17+
import { BuildOutputFileType, type InitialFileRecord } from '../../tools/esbuild/bundler-files';
1718
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
1819
import { LOCALE_DATA_BASE_MODULE } from '../../tools/esbuild/i18n-locale-plugin';
1920
import { extractLicenses } from '../../tools/esbuild/license-extractor';
@@ -33,6 +34,38 @@ import { inlineI18n, loadActiveTranslations } from './i18n';
3334
import { NormalizedApplicationBuildOptions } from './options';
3435
import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling';
3536

37+
/**
38+
* Returns a copy of the given metafile containing only outputs that appear in the
39+
* provided initial-files map, with inputs filtered to those referenced by those outputs.
40+
*/
41+
function createInitialMetafile(
42+
metafile: Metafile,
43+
initialFiles: Map<string, InitialFileRecord>,
44+
): Metafile {
45+
const filteredOutputs: Metafile['outputs'] = {};
46+
const referencedInputs = new Set<string>();
47+
48+
for (const [path, output] of Object.entries(metafile.outputs)) {
49+
if (!initialFiles.has(path)) {
50+
continue;
51+
}
52+
filteredOutputs[path] = output;
53+
for (const inputPath of Object.keys(output.inputs)) {
54+
referencedInputs.add(inputPath);
55+
}
56+
}
57+
58+
const filteredInputs: Metafile['inputs'] = {};
59+
for (const path of referencedInputs) {
60+
const input = metafile.inputs[path];
61+
if (input) {
62+
filteredInputs[path] = input;
63+
}
64+
}
65+
66+
return { inputs: filteredInputs, outputs: filteredOutputs };
67+
}
68+
3669
// eslint-disable-next-line max-lines-per-function
3770
export async function executeBuild(
3871
options: NormalizedApplicationBuildOptions,
@@ -352,13 +385,33 @@ export async function executeBuild(
352385
BuildOutputFileType.Root,
353386
);
354387

355-
// Write metafile if stats option is enabled
388+
// Write metafiles if stats option is enabled
356389
if (options.stats) {
390+
const { browserMetafile, serverMetafile } = bundlingResult;
391+
392+
executionResult.addOutputFile(
393+
'browser-stats.json',
394+
JSON.stringify(browserMetafile, null, 2),
395+
BuildOutputFileType.Root,
396+
);
357397
executionResult.addOutputFile(
358-
'stats.json',
359-
JSON.stringify(metafile, null, 2),
398+
'browser-initial-stats.json',
399+
JSON.stringify(createInitialMetafile(browserMetafile, initialFiles), null, 2),
360400
BuildOutputFileType.Root,
361401
);
402+
403+
if (ssrOptions) {
404+
executionResult.addOutputFile(
405+
'server-stats.json',
406+
JSON.stringify(serverMetafile, null, 2),
407+
BuildOutputFileType.Root,
408+
);
409+
executionResult.addOutputFile(
410+
'server-initial-stats.json',
411+
JSON.stringify(createInitialMetafile(serverMetafile, initialFiles), null, 2),
412+
BuildOutputFileType.Root,
413+
);
414+
}
362415
}
363416

364417
if (!jsonLogs && !options.quiet) {
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
11+
12+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
13+
describe('Option: "statsJson"', () => {
14+
describe('browser-only build', () => {
15+
it('generates only browser stats files when statsJson is true', async () => {
16+
harness.useTarget('build', {
17+
...BASE_OPTIONS,
18+
statsJson: true,
19+
});
20+
21+
const { result } = await harness.executeOnce();
22+
expect(result?.success).toBeTrue();
23+
harness.expectFile('dist/browser-stats.json').toExist();
24+
harness.expectFile('dist/browser-initial-stats.json').toExist();
25+
harness.expectFile('dist/server-stats.json').toNotExist();
26+
harness.expectFile('dist/server-initial-stats.json').toNotExist();
27+
});
28+
29+
it('does not generate any stats files when statsJson is false', async () => {
30+
harness.useTarget('build', {
31+
...BASE_OPTIONS,
32+
statsJson: false,
33+
});
34+
35+
const { result } = await harness.executeOnce();
36+
expect(result?.success).toBeTrue();
37+
harness.expectFile('dist/browser-stats.json').toNotExist();
38+
harness.expectFile('dist/browser-initial-stats.json').toNotExist();
39+
harness.expectFile('dist/server-stats.json').toNotExist();
40+
harness.expectFile('dist/server-initial-stats.json').toNotExist();
41+
});
42+
43+
it('does not generate legacy stats.json when statsJson is true', async () => {
44+
harness.useTarget('build', {
45+
...BASE_OPTIONS,
46+
statsJson: true,
47+
});
48+
49+
const { result } = await harness.executeOnce();
50+
expect(result?.success).toBeTrue();
51+
harness.expectFile('dist/stats.json').toNotExist();
52+
});
53+
54+
it('browser-stats.json contains valid esbuild metafile with inputs and outputs', async () => {
55+
harness.useTarget('build', {
56+
...BASE_OPTIONS,
57+
statsJson: true,
58+
});
59+
60+
const { result } = await harness.executeOnce();
61+
expect(result?.success).toBeTrue();
62+
63+
const content = harness.readFile('dist/browser-stats.json');
64+
const parsed = JSON.parse(content) as { inputs: unknown; outputs: unknown };
65+
expect(parsed.inputs).toBeDefined();
66+
expect(parsed.outputs).toBeDefined();
67+
});
68+
69+
it('browser-initial-stats.json contains only a subset of browser-stats.json outputs', async () => {
70+
harness.useTarget('build', {
71+
...BASE_OPTIONS,
72+
statsJson: true,
73+
});
74+
75+
const { result } = await harness.executeOnce();
76+
expect(result?.success).toBeTrue();
77+
78+
const allStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as {
79+
outputs: Record<string, unknown>;
80+
};
81+
const initialStats = JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as {
82+
outputs: Record<string, unknown>;
83+
};
84+
85+
const allOutputCount = Object.keys(allStats.outputs).length;
86+
const initialOutputCount = Object.keys(initialStats.outputs).length;
87+
88+
expect(allOutputCount).toBeGreaterThanOrEqual(initialOutputCount);
89+
for (const path of Object.keys(initialStats.outputs)) {
90+
expect(allStats.outputs[path]).toBeDefined();
91+
}
92+
});
93+
});
94+
95+
describe('SSR build', () => {
96+
beforeEach(async () => {
97+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
98+
const tsConfig = JSON.parse(content) as { files?: string[] };
99+
tsConfig.files ??= [];
100+
tsConfig.files.push('main.server.ts');
101+
102+
return JSON.stringify(tsConfig);
103+
});
104+
});
105+
106+
it('generates all four stats files for an SSR build', async () => {
107+
harness.useTarget('build', {
108+
...BASE_OPTIONS,
109+
server: 'src/main.server.ts',
110+
ssr: true,
111+
statsJson: true,
112+
});
113+
114+
const { result } = await harness.executeOnce();
115+
expect(result?.success).toBeTrue();
116+
harness.expectFile('dist/browser-stats.json').toExist();
117+
harness.expectFile('dist/browser-initial-stats.json').toExist();
118+
harness.expectFile('dist/server-stats.json').toExist();
119+
harness.expectFile('dist/server-initial-stats.json').toExist();
120+
});
121+
122+
it('server-stats.json has non-empty outputs for an SSR build', async () => {
123+
harness.useTarget('build', {
124+
...BASE_OPTIONS,
125+
server: 'src/main.server.ts',
126+
ssr: true,
127+
statsJson: true,
128+
});
129+
130+
const { result } = await harness.executeOnce();
131+
expect(result?.success).toBeTrue();
132+
133+
const content = harness.readFile('dist/server-stats.json');
134+
const parsed = JSON.parse(content) as { outputs: Record<string, unknown> };
135+
expect(Object.keys(parsed.outputs).length).toBeGreaterThan(0);
136+
});
137+
138+
it('browser-stats.json does not contain server output paths for an SSR build', async () => {
139+
harness.useTarget('build', {
140+
...BASE_OPTIONS,
141+
server: 'src/main.server.ts',
142+
ssr: true,
143+
statsJson: true,
144+
});
145+
146+
const { result } = await harness.executeOnce();
147+
expect(result?.success).toBeTrue();
148+
149+
const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as {
150+
outputs: Record<string, unknown>;
151+
};
152+
const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')) as {
153+
outputs: Record<string, unknown>;
154+
};
155+
156+
const browserPaths = new Set(Object.keys(browserStats.outputs));
157+
for (const path of Object.keys(serverStats.outputs)) {
158+
expect(browserPaths.has(path))
159+
.withContext(`Server output '${path}' should not appear in browser-stats.json`)
160+
.toBeFalse();
161+
}
162+
});
163+
});
164+
});
165+
});

packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ export class ComponentStylesheetBundler {
258258
}
259259
}
260260

261-
const metafile = result.metafile;
261+
const { metafile, browserMetafile, serverMetafile } = result;
262262
// Remove entryPoint fields from outputs to prevent the internal component styles from being
263263
// treated as initial files. Also mark the entry as a component resource for stat reporting.
264264
Object.values(metafile.outputs).forEach((output) => {
@@ -273,6 +273,8 @@ export class ComponentStylesheetBundler {
273273
contents,
274274
outputFiles,
275275
metafile,
276+
browserMetafile,
277+
serverMetafile,
276278
referencedFiles,
277279
externalImports: result.externalImports,
278280
initialFiles: new Map(),

packages/angular/build/src/tools/esbuild/bundler-context.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type BundleContextResult =
3333
errors: undefined;
3434
warnings: Message[];
3535
metafile: Metafile;
36+
browserMetafile: Metafile;
37+
serverMetafile: Metafile;
3638
outputFiles: BuildOutputFile[];
3739
initialFiles: Map<string, InitialFileRecord>;
3840
externalImports: {
@@ -112,6 +114,8 @@ export class BundlerContext {
112114
let errors: Message[] | undefined;
113115
const warnings: Message[] = [];
114116
const metafile: Metafile = { inputs: {}, outputs: {} };
117+
const browserMetafile: Metafile = { inputs: {}, outputs: {} };
118+
const serverMetafile: Metafile = { inputs: {}, outputs: {} };
115119
const initialFiles = new Map<string, InitialFileRecord>();
116120
const externalImportsBrowser = new Set<string>();
117121
const externalImportsServer = new Set<string>();
@@ -126,12 +130,17 @@ export class BundlerContext {
126130
continue;
127131
}
128132

129-
// Combine metafiles used for the stats option as well as bundle budgets and console output
133+
// Combine metafiles used for the bundle budgets and console output
130134
if (result.metafile) {
131135
Object.assign(metafile.inputs, result.metafile.inputs);
132136
Object.assign(metafile.outputs, result.metafile.outputs);
133137
}
134138

139+
Object.assign(browserMetafile.inputs, result.browserMetafile.inputs);
140+
Object.assign(browserMetafile.outputs, result.browserMetafile.outputs);
141+
Object.assign(serverMetafile.inputs, result.serverMetafile.inputs);
142+
Object.assign(serverMetafile.outputs, result.serverMetafile.outputs);
143+
135144
result.initialFiles.forEach((value, key) => initialFiles.set(key, value));
136145

137146
outputFiles.push(...result.outputFiles);
@@ -154,6 +163,8 @@ export class BundlerContext {
154163
errors,
155164
warnings,
156165
metafile,
166+
browserMetafile,
167+
serverMetafile,
157168
initialFiles,
158169
outputFiles,
159170
externalImports: {
@@ -416,6 +427,8 @@ export class BundlerContext {
416427
...result,
417428
outputFiles,
418429
initialFiles,
430+
browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile,
431+
serverMetafile: isPlatformServer ? result.metafile : { inputs: {}, outputs: {} },
419432
externalImports: {
420433
[isPlatformServer ? 'server' : 'browser']: externalImports,
421434
},

0 commit comments

Comments
 (0)