Skip to content

Commit 054b97d

Browse files
feat(@angular/build): split browser and server stats JSON files for easier consumption
Adds separate `browser-stats.json` and `server-stats.json` output files alongside the existing `stats.json`, making it easier for consumers to work with browser-only or server-only bundle metadata. Includes unit tests for the new filtering utility and the updated stats-json builder option.
1 parent c6dd57a commit 054b97d

6 files changed

Lines changed: 395 additions & 4 deletions

File tree

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { extractLicenses } from '../../tools/esbuild/license-extractor';
2121
import { profileAsync } from '../../tools/esbuild/profiling';
2222
import {
2323
calculateEstimatedTransferSizes,
24+
filterMetafile,
2425
logBuildStats,
2526
transformSupportedBrowsersToTargets,
2627
} from '../../tools/esbuild/utils';
@@ -230,7 +231,7 @@ export async function executeBuild(
230231
executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]);
231232
}
232233

233-
const { metafile, initialFiles, outputFiles } = bundlingResult;
234+
const { metafile, browserMetafile, serverMetafile, initialFiles, outputFiles } = bundlingResult;
234235

235236
executionResult.outputFiles.push(...outputFiles);
236237

@@ -322,13 +323,34 @@ export async function executeBuild(
322323
BuildOutputFileType.Root,
323324
);
324325

325-
// Write metafile if stats option is enabled
326+
// Write metafiles if stats option is enabled, split by browser/server and initial/non-initial
326327
if (options.stats) {
328+
const filterInitialFiles = (outputPath: string) => initialFiles.has(outputPath);
329+
const filterNonInitialFiles = (outputPath: string) => !initialFiles.has(outputPath);
330+
331+
executionResult.addOutputFile(
332+
'browser-stats.json',
333+
JSON.stringify(filterMetafile(browserMetafile, filterNonInitialFiles), null, 2),
334+
BuildOutputFileType.Root,
335+
);
327336
executionResult.addOutputFile(
328-
'stats.json',
329-
JSON.stringify(metafile, null, 2),
337+
'browser-initial-stats.json',
338+
JSON.stringify(filterMetafile(browserMetafile, filterInitialFiles), null, 2),
330339
BuildOutputFileType.Root,
331340
);
341+
342+
if (serverMetafile) {
343+
executionResult.addOutputFile(
344+
'server-stats.json',
345+
JSON.stringify(filterMetafile(serverMetafile, filterNonInitialFiles), null, 2),
346+
BuildOutputFileType.Root,
347+
);
348+
executionResult.addOutputFile(
349+
'server-initial-stats.json',
350+
JSON.stringify(filterMetafile(serverMetafile, filterInitialFiles), null, 2),
351+
BuildOutputFileType.Root,
352+
);
353+
}
332354
}
333355

334356
if (!jsonLogs && !options.quiet) {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
/** Minimal subset of an esbuild metafile used by stats assertions. */
13+
interface StatsMetafile {
14+
inputs: Record<string, unknown>;
15+
outputs: Record<string, { inputs: Record<string, unknown> }>;
16+
}
17+
18+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
19+
describe('Option: "statsJson"', () => {
20+
describe('browser-only build', () => {
21+
beforeEach(() => {
22+
harness.useTarget('build', {
23+
...BASE_OPTIONS,
24+
statsJson: true,
25+
});
26+
});
27+
28+
it('generates browser-stats.json and browser-initial-stats.json', async () => {
29+
const { result } = await harness.executeOnce();
30+
31+
expect(result?.success).toBeTrue();
32+
harness.expectFile('dist/browser-stats.json').toExist();
33+
harness.expectFile('dist/browser-initial-stats.json').toExist();
34+
});
35+
36+
it('does not generate server stats files when SSR is disabled', async () => {
37+
const { result } = await harness.executeOnce();
38+
39+
expect(result?.success).toBeTrue();
40+
harness.expectFile('dist/server-stats.json').toNotExist();
41+
harness.expectFile('dist/server-initial-stats.json').toNotExist();
42+
});
43+
44+
it('does not generate the legacy stats.json file', async () => {
45+
const { result } = await harness.executeOnce();
46+
47+
expect(result?.success).toBeTrue();
48+
harness.expectFile('dist/stats.json').toNotExist();
49+
});
50+
51+
it('stats files contain valid esbuild metafile structure', async () => {
52+
const { result } = await harness.executeOnce();
53+
54+
expect(result?.success).toBeTrue();
55+
56+
for (const filename of ['dist/browser-stats.json', 'dist/browser-initial-stats.json']) {
57+
const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile;
58+
expect(stats.inputs).withContext(`${filename} must have an inputs field`).toBeDefined();
59+
expect(stats.outputs).withContext(`${filename} must have an outputs field`).toBeDefined();
60+
}
61+
});
62+
63+
it('output paths do not overlap between browser-stats.json and browser-initial-stats.json', async () => {
64+
const { result } = await harness.executeOnce();
65+
66+
expect(result?.success).toBeTrue();
67+
68+
const nonInitialPaths = new Set(
69+
Object.keys(
70+
(JSON.parse(harness.readFile('dist/browser-stats.json')) as StatsMetafile).outputs,
71+
),
72+
);
73+
const initialPaths = Object.keys(
74+
(JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as StatsMetafile)
75+
.outputs,
76+
);
77+
78+
for (const outputPath of initialPaths) {
79+
expect(nonInitialPaths.has(outputPath))
80+
.withContext(`Output '${outputPath}' must not appear in both stats files`)
81+
.toBeFalse();
82+
}
83+
});
84+
85+
it('inputs in each stats file are only those referenced by included outputs', async () => {
86+
const { result } = await harness.executeOnce();
87+
88+
expect(result?.success).toBeTrue();
89+
90+
for (const filename of ['dist/browser-stats.json', 'dist/browser-initial-stats.json']) {
91+
const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile;
92+
const referencedInputs = new Set(
93+
Object.values(stats.outputs).flatMap((output) => Object.keys(output.inputs)),
94+
);
95+
96+
for (const inputPath of Object.keys(stats.inputs)) {
97+
expect(referencedInputs.has(inputPath))
98+
.withContext(
99+
`Input '${inputPath}' in '${filename}' is not referenced by any included output`,
100+
)
101+
.toBeTrue();
102+
}
103+
}
104+
});
105+
});
106+
107+
describe('when statsJson is false', () => {
108+
it('does not generate any stats files', async () => {
109+
harness.useTarget('build', {
110+
...BASE_OPTIONS,
111+
statsJson: false,
112+
});
113+
114+
const { result } = await harness.executeOnce();
115+
116+
expect(result?.success).toBeTrue();
117+
harness.expectFile('dist/browser-stats.json').toNotExist();
118+
harness.expectFile('dist/browser-initial-stats.json').toNotExist();
119+
harness.expectFile('dist/stats.json').toNotExist();
120+
});
121+
});
122+
123+
describe('SSR build', () => {
124+
beforeEach(async () => {
125+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
126+
const tsConfig = JSON.parse(content) as { files?: string[] };
127+
tsConfig.files ??= [];
128+
tsConfig.files.push('main.server.ts');
129+
130+
return JSON.stringify(tsConfig);
131+
});
132+
133+
harness.useTarget('build', {
134+
...BASE_OPTIONS,
135+
statsJson: true,
136+
server: 'src/main.server.ts',
137+
ssr: true,
138+
});
139+
});
140+
141+
it('generates all four stats files', async () => {
142+
const { result } = await harness.executeOnce();
143+
144+
expect(result?.success).toBeTrue();
145+
harness.expectFile('dist/browser-stats.json').toExist();
146+
harness.expectFile('dist/browser-initial-stats.json').toExist();
147+
harness.expectFile('dist/server-stats.json').toExist();
148+
harness.expectFile('dist/server-initial-stats.json').toExist();
149+
});
150+
151+
it('server stats files contain valid esbuild metafile structure', async () => {
152+
const { result } = await harness.executeOnce();
153+
154+
expect(result?.success).toBeTrue();
155+
156+
for (const filename of ['dist/server-stats.json', 'dist/server-initial-stats.json']) {
157+
const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile;
158+
expect(stats.inputs).withContext(`${filename} must have an inputs field`).toBeDefined();
159+
expect(stats.outputs).withContext(`${filename} must have an outputs field`).toBeDefined();
160+
}
161+
});
162+
163+
it('server output paths do not overlap between server-stats.json and server-initial-stats.json', async () => {
164+
const { result } = await harness.executeOnce();
165+
166+
expect(result?.success).toBeTrue();
167+
168+
const nonInitialPaths = new Set(
169+
Object.keys(
170+
(JSON.parse(harness.readFile('dist/server-stats.json')) as StatsMetafile).outputs,
171+
),
172+
);
173+
const initialPaths = Object.keys(
174+
(JSON.parse(harness.readFile('dist/server-initial-stats.json')) as StatsMetafile).outputs,
175+
);
176+
177+
for (const outputPath of initialPaths) {
178+
expect(nonInitialPaths.has(outputPath))
179+
.withContext(`Output '${outputPath}' must not appear in both server stats files`)
180+
.toBeFalse();
181+
}
182+
});
183+
});
184+
});
185+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ export class ComponentStylesheetBundler {
278278
contents,
279279
outputFiles,
280280
metafile,
281+
browserMetafile: metafile,
281282
referencedFiles,
282283
externalImports: result.externalImports,
283284
initialFiles: new Map(),

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export type BundleContextResult =
2929
errors: undefined;
3030
warnings: Message[];
3131
metafile: Metafile;
32+
browserMetafile: Metafile;
33+
serverMetafile?: Metafile;
3234
outputFiles: BuildOutputFile[];
3335
initialFiles: Map<string, InitialFileRecord>;
3436
externalImports: {
@@ -128,6 +130,8 @@ export class BundlerContext {
128130
let errors: Message[] | undefined;
129131
const warnings: Message[] = [];
130132
const metafile: Metafile = { inputs: {}, outputs: {} };
133+
const browserMetafile: Metafile = { inputs: {}, outputs: {} };
134+
let serverMetafile: Metafile | undefined;
131135
const initialFiles = new Map<string, InitialFileRecord>();
132136
const externalImportsBrowser = new Set<string>();
133137
const externalImportsServer = new Set<string>();
@@ -148,6 +152,17 @@ export class BundlerContext {
148152
Object.assign(metafile.outputs, result.metafile.outputs);
149153
}
150154

155+
// Keep browser and server metafiles isolated for separate stats output
156+
if (result.browserMetafile) {
157+
Object.assign(browserMetafile.inputs, result.browserMetafile.inputs);
158+
Object.assign(browserMetafile.outputs, result.browserMetafile.outputs);
159+
}
160+
if (result.serverMetafile) {
161+
serverMetafile ??= { inputs: {}, outputs: {} };
162+
Object.assign(serverMetafile.inputs, result.serverMetafile.inputs);
163+
Object.assign(serverMetafile.outputs, result.serverMetafile.outputs);
164+
}
165+
151166
result.initialFiles.forEach((value, key) => initialFiles.set(key, value));
152167

153168
outputFiles.push(...result.outputFiles);
@@ -170,6 +185,8 @@ export class BundlerContext {
170185
errors,
171186
warnings,
172187
metafile,
188+
browserMetafile,
189+
serverMetafile,
173190
initialFiles,
174191
outputFiles,
175192
externalImports: {
@@ -414,6 +431,8 @@ export class BundlerContext {
414431
[isPlatformServer ? 'server' : 'browser']: externalImports,
415432
},
416433
externalConfiguration,
434+
browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile,
435+
serverMetafile: isPlatformServer ? result.metafile : undefined,
417436
errors: undefined,
418437
};
419438
}

packages/angular/build/src/tools/esbuild/utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,36 @@ import {
2929
PrerenderedRoutesRecord,
3030
} from './bundler-execution-result';
3131

32+
/**
33+
* Filters a metafile to only include outputs matching a predicate,
34+
* along with the inputs those outputs directly reference.
35+
*/
36+
export function filterMetafile(
37+
metafile: Metafile,
38+
predicate: (outputPath: string) => boolean,
39+
): Metafile {
40+
const filteredOutputs: Metafile['outputs'] = {};
41+
const referencedInputs = new Set<string>();
42+
43+
for (const [path, output] of Object.entries(metafile.outputs)) {
44+
if (predicate(path)) {
45+
filteredOutputs[path] = output;
46+
for (const inputPath of Object.keys(output.inputs)) {
47+
referencedInputs.add(inputPath);
48+
}
49+
}
50+
}
51+
52+
const filteredInputs: Metafile['inputs'] = {};
53+
for (const [inputPath, input] of Object.entries(metafile.inputs)) {
54+
if (referencedInputs.has(inputPath)) {
55+
filteredInputs[inputPath] = input;
56+
}
57+
}
58+
59+
return { inputs: filteredInputs, outputs: filteredOutputs };
60+
}
61+
3262
export function logBuildStats(
3363
metafile: Metafile,
3464
outputFiles: BuildOutputFile[],

0 commit comments

Comments
 (0)