Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions packages/angular/build/src/builders/application/execute-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { extractLicenses } from '../../tools/esbuild/license-extractor';
import { profileAsync } from '../../tools/esbuild/profiling';
import {
calculateEstimatedTransferSizes,
filterMetafile,
logBuildStats,
transformSupportedBrowsersToTargets,
} from '../../tools/esbuild/utils';
Expand Down Expand Up @@ -209,7 +210,7 @@ export async function executeBuild(
executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]);
}

const { metafile, initialFiles, outputFiles } = bundlingResult;
const { metafile, browserMetafile, serverMetafile, initialFiles, outputFiles } = bundlingResult;

executionResult.outputFiles.push(...outputFiles);

Expand Down Expand Up @@ -301,13 +302,34 @@ export async function executeBuild(
BuildOutputFileType.Root,
);

// Write metafile if stats option is enabled
// Write metafiles if stats option is enabled, split by browser/server and initial/non-initial
if (options.stats) {
const filterInitialFiles = (outputPath: string) => initialFiles.has(outputPath);
const filterNonInitialFiles = (outputPath: string) => !initialFiles.has(outputPath);

executionResult.addOutputFile(
'browser-stats.json',
Comment thread
tsteuwer-accesso marked this conversation as resolved.
JSON.stringify(filterMetafile(browserMetafile, filterNonInitialFiles), null, 2),
BuildOutputFileType.Root,
);
executionResult.addOutputFile(
'stats.json',
JSON.stringify(metafile, null, 2),
'browser-initial-stats.json',
JSON.stringify(filterMetafile(browserMetafile, filterInitialFiles), null, 2),
BuildOutputFileType.Root,
);

if (serverMetafile) {
executionResult.addOutputFile(
'server-stats.json',
JSON.stringify(filterMetafile(serverMetafile, filterNonInitialFiles), null, 2),
BuildOutputFileType.Root,
);
executionResult.addOutputFile(
'server-initial-stats.json',
JSON.stringify(filterMetafile(serverMetafile, filterInitialFiles), null, 2),
BuildOutputFileType.Root,
);
}
}

if (!jsonLogs) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* @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';

/** Minimal subset of an esbuild metafile used by stats assertions. */
interface StatsMetafile {
inputs: Record<string, unknown>;
outputs: Record<string, { inputs: Record<string, unknown> }>;
}

describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
describe('Option: "statsJson"', () => {
describe('browser-only build', () => {
beforeEach(() => {
harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
});
});

it('generates browser-stats.json and browser-initial-stats.json', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();
harness.expectFile('dist/browser-stats.json').toExist();
harness.expectFile('dist/browser-initial-stats.json').toExist();
});

it('does not generate server stats files when SSR is disabled', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();
harness.expectFile('dist/server-stats.json').toNotExist();
harness.expectFile('dist/server-initial-stats.json').toNotExist();
});

it('does not generate the legacy stats.json file', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();
harness.expectFile('dist/stats.json').toNotExist();
});

it('stats files contain valid esbuild metafile structure', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();

for (const filename of ['dist/browser-stats.json', 'dist/browser-initial-stats.json']) {
const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile;
expect(stats.inputs).withContext(`${filename} must have an inputs field`).toBeDefined();
expect(stats.outputs).withContext(`${filename} must have an outputs field`).toBeDefined();
}
});

it('output paths do not overlap between browser-stats.json and browser-initial-stats.json', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();

const nonInitialPaths = new Set(
Object.keys(
(JSON.parse(harness.readFile('dist/browser-stats.json')) as StatsMetafile).outputs,
),
);
const initialPaths = Object.keys(
(JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as StatsMetafile)
.outputs,
);

for (const outputPath of initialPaths) {
expect(nonInitialPaths.has(outputPath))
.withContext(`Output '${outputPath}' must not appear in both stats files`)
.toBeFalse();
}
});

it('inputs in each stats file are only those referenced by included outputs', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();

for (const filename of ['dist/browser-stats.json', 'dist/browser-initial-stats.json']) {
const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile;
const referencedInputs = new Set(
Object.values(stats.outputs).flatMap((output) => Object.keys(output.inputs)),
);

for (const inputPath of Object.keys(stats.inputs)) {
expect(referencedInputs.has(inputPath))
.withContext(
`Input '${inputPath}' in '${filename}' is not referenced by any included output`,
)
.toBeTrue();
}
}
});
});

describe('when statsJson is false', () => {
it('does not generate any stats files', 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/stats.json').toNotExist();
});
});

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);
});

harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
server: 'src/main.server.ts',
ssr: true,
});
});

it('generates all four stats files', async () => {
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 files contain valid esbuild metafile structure', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();

for (const filename of ['dist/server-stats.json', 'dist/server-initial-stats.json']) {
const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile;
expect(stats.inputs).withContext(`${filename} must have an inputs field`).toBeDefined();
expect(stats.outputs).withContext(`${filename} must have an outputs field`).toBeDefined();
}
});

it('server output paths do not overlap between server-stats.json and server-initial-stats.json', async () => {
const { result } = await harness.executeOnce();

expect(result?.success).toBeTrue();

const nonInitialPaths = new Set(
Object.keys(
(JSON.parse(harness.readFile('dist/server-stats.json')) as StatsMetafile).outputs,
),
);
const initialPaths = Object.keys(
(JSON.parse(harness.readFile('dist/server-initial-stats.json')) as StatsMetafile).outputs,
);

for (const outputPath of initialPaths) {
expect(nonInitialPaths.has(outputPath))
.withContext(`Output '${outputPath}' must not appear in both server stats files`)
.toBeFalse();
}
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export class ComponentStylesheetBundler {
contents,
outputFiles,
metafile,
browserMetafile: metafile,
referencedFiles,
externalImports: result.externalImports,
initialFiles: new Map(),
Expand Down
19 changes: 19 additions & 0 deletions packages/angular/build/src/tools/esbuild/bundler-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type BundleContextResult =
errors: undefined;
warnings: Message[];
metafile: Metafile;
Comment thread
tsteuwer-accesso marked this conversation as resolved.
browserMetafile: Metafile;
serverMetafile?: Metafile;
outputFiles: BuildOutputFile[];
initialFiles: Map<string, InitialFileRecord>;
externalImports: {
Expand Down Expand Up @@ -128,6 +130,8 @@ export class BundlerContext {
let errors: Message[] | undefined;
const warnings: Message[] = [];
const metafile: Metafile = { inputs: {}, outputs: {} };
const browserMetafile: Metafile = { inputs: {}, outputs: {} };
let serverMetafile: Metafile | undefined;
const initialFiles = new Map<string, InitialFileRecord>();
const externalImportsBrowser = new Set<string>();
const externalImportsServer = new Set<string>();
Expand All @@ -148,6 +152,17 @@ export class BundlerContext {
Object.assign(metafile.outputs, result.metafile.outputs);
}

// Keep browser and server metafiles isolated for separate stats output
if (result.browserMetafile) {
Object.assign(browserMetafile.inputs, result.browserMetafile.inputs);
Object.assign(browserMetafile.outputs, result.browserMetafile.outputs);
}
if (result.serverMetafile) {
serverMetafile ??= { inputs: {}, 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);
Expand All @@ -170,6 +185,8 @@ export class BundlerContext {
errors,
warnings,
metafile,
browserMetafile,
serverMetafile,
initialFiles,
outputFiles,
externalImports: {
Expand Down Expand Up @@ -414,6 +431,8 @@ export class BundlerContext {
[isPlatformServer ? 'server' : 'browser']: externalImports,
},
externalConfiguration,
browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile,
Comment thread
tsteuwer-accesso marked this conversation as resolved.
serverMetafile: isPlatformServer ? result.metafile : undefined,
errors: undefined,
};
}
Expand Down
30 changes: 30 additions & 0 deletions packages/angular/build/src/tools/esbuild/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ import {
PrerenderedRoutesRecord,
} from './bundler-execution-result';

/**
* Filters a metafile to only include outputs matching a predicate,
* along with the inputs those outputs directly reference.
*/
export function filterMetafile(
metafile: Metafile,
predicate: (outputPath: string) => boolean,
): Metafile {
const filteredOutputs: Metafile['outputs'] = {};
const referencedInputs = new Set<string>();

for (const [path, output] of Object.entries(metafile.outputs)) {
if (predicate(path)) {
filteredOutputs[path] = output;
for (const inputPath of Object.keys(output.inputs)) {
referencedInputs.add(inputPath);
}
}
}

const filteredInputs: Metafile['inputs'] = {};
for (const [inputPath, input] of Object.entries(metafile.inputs)) {
if (referencedInputs.has(inputPath)) {
filteredInputs[inputPath] = input;
}
}

return { inputs: filteredInputs, outputs: filteredOutputs };
}

export function logBuildStats(
metafile: Metafile,
outputFiles: BuildOutputFile[],
Expand Down
Loading
Loading