Skip to content

Commit 817463c

Browse files
committed
refactor(@angular/build): convert metadata when using experimental chunk optimization
Refactors the chunk optimizer by introducing several helper functions to improve readability and maintainability. The `rolldownToEsbuildMetafile` function now converts rolldown output to an esbuild metafile, and the `optimizeChunks` function has been updated to use it. Additionally, helper functions have been created for generating initial file records and chunk optimization failure messages.
1 parent 9c12bfd commit 817463c

File tree

1 file changed

+170
-26
lines changed

1 file changed

+170
-26
lines changed

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

Lines changed: 170 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,20 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
/**
10+
* @fileoverview This file provides a function to optimize JavaScript chunks using rolldown.
11+
* It is designed to be used after an esbuild build to further optimize the output.
12+
* The main function, `optimizeChunks`, takes the result of an esbuild build,
13+
* identifies the main browser entry point, and then uses rolldown to rebundle
14+
* and optimize the chunks. This process can result in smaller and more efficient
15+
* code by combining and restructuring the original chunks. The file also includes
16+
* helper functions to convert rolldown's output into an esbuild-compatible
17+
* metafile, allowing for consistent analysis and reporting of the build output.
18+
*/
19+
20+
import type { Message, Metafile } from 'esbuild';
921
import assert from 'node:assert';
10-
import { rolldown } from 'rolldown';
22+
import { type OutputAsset, type OutputChunk, rolldown } from 'rolldown';
1123
import {
1224
BuildOutputFile,
1325
BuildOutputFileType,
@@ -17,6 +29,142 @@ import {
1729
import { createOutputFile } from '../../tools/esbuild/utils';
1830
import { assertIsError } from '../../utils/error';
1931

32+
/**
33+
* Converts the output of a rolldown build into an esbuild-compatible metafile.
34+
* @param rolldownOutput The output of a rolldown build.
35+
* @param originalMetafile The original esbuild metafile from the build.
36+
* @returns An esbuild-compatible metafile.
37+
*/
38+
function rolldownToEsbuildMetafile(
39+
rolldownOutput: (OutputChunk | OutputAsset)[],
40+
originalMetafile: Metafile,
41+
): Metafile {
42+
const newMetafile: Metafile = {
43+
inputs: {},
44+
outputs: {},
45+
};
46+
47+
const intermediateChunkSizes: Record<string, number> = {};
48+
for (const [path, output] of Object.entries(originalMetafile.outputs)) {
49+
intermediateChunkSizes[path] = Object.values(output.inputs).reduce(
50+
(s, i) => s + i.bytesInOutput,
51+
0,
52+
);
53+
}
54+
55+
for (const chunk of rolldownOutput) {
56+
if (chunk.type === 'asset') {
57+
newMetafile.outputs[chunk.fileName] = {
58+
bytes:
59+
typeof chunk.source === 'string'
60+
? Buffer.byteLength(chunk.source, 'utf8')
61+
: chunk.source.length,
62+
inputs: {},
63+
imports: [],
64+
exports: [],
65+
};
66+
continue;
67+
}
68+
69+
const newOutputInputs: Record<string, { bytesInOutput: number }> = {};
70+
if (chunk.modules) {
71+
for (const [moduleId, renderedModule] of Object.entries(chunk.modules)) {
72+
const originalOutputEntry = originalMetafile.outputs[moduleId];
73+
if (!originalOutputEntry?.inputs) {
74+
continue;
75+
}
76+
77+
const totalOriginalBytesInModule = intermediateChunkSizes[moduleId];
78+
if (totalOriginalBytesInModule === 0) {
79+
continue;
80+
}
81+
82+
for (const [originalInputPath, originalInputInfo] of Object.entries(
83+
originalOutputEntry.inputs,
84+
)) {
85+
const proportion = originalInputInfo.bytesInOutput / totalOriginalBytesInModule;
86+
const newBytesInOutput = Math.floor(renderedModule.renderedLength * proportion);
87+
88+
const existing = newOutputInputs[originalInputPath];
89+
if (existing) {
90+
existing.bytesInOutput += newBytesInOutput;
91+
} else {
92+
newOutputInputs[originalInputPath] = { bytesInOutput: newBytesInOutput };
93+
}
94+
95+
if (!newMetafile.inputs[originalInputPath]) {
96+
newMetafile.inputs[originalInputPath] = originalMetafile.inputs[originalInputPath];
97+
}
98+
}
99+
}
100+
}
101+
102+
const imports = [
103+
...chunk.imports.map((path) => ({ path, kind: 'import-statement' as const })),
104+
...(chunk.dynamicImports?.map((path) => ({ path, kind: 'dynamic-import' as const })) ?? []),
105+
];
106+
107+
newMetafile.outputs[chunk.fileName] = {
108+
bytes: Buffer.byteLength(chunk.code, 'utf8'),
109+
inputs: newOutputInputs,
110+
imports,
111+
exports: chunk.exports ?? [],
112+
entryPoint: chunk.isEntry ? (chunk.facadeModuleId ?? undefined) : undefined,
113+
};
114+
}
115+
116+
return newMetafile;
117+
}
118+
119+
/**
120+
* Creates an InitialFileRecord object with a specified depth.
121+
* @param depth The depth of the file in the dependency graph.
122+
* @returns An InitialFileRecord object.
123+
*/
124+
function createInitialFileRecord(depth: number): InitialFileRecord {
125+
return {
126+
type: 'script',
127+
entrypoint: false,
128+
external: false,
129+
serverFile: false,
130+
depth,
131+
};
132+
}
133+
134+
/**
135+
* Creates an esbuild message object for a chunk optimization failure.
136+
* @param message The error message detailing the cause of the failure.
137+
* @returns A partial esbuild message object.
138+
*/
139+
function createChunkOptimizationFailureMessage(message: string): Message {
140+
// Most of these fields are not actually needed for printing the error
141+
return {
142+
id: '',
143+
text: 'Chunk optimization failed',
144+
detail: undefined,
145+
pluginName: '',
146+
location: null,
147+
notes: [
148+
{
149+
text: message,
150+
location: null,
151+
},
152+
],
153+
};
154+
}
155+
156+
/**
157+
* Optimizes the chunks of a build result using rolldown.
158+
*
159+
* This function takes the output of an esbuild build, identifies the main browser entry point,
160+
* and uses rolldown to bundle and optimize the JavaScript chunks. The optimized chunks
161+
* replace the original ones in the build result, and the metafile is updated to reflect
162+
* the changes.
163+
*
164+
* @param original The original build result from esbuild.
165+
* @param sourcemap A boolean or 'hidden' to control sourcemap generation.
166+
* @returns A promise that resolves to the updated build result with optimized chunks.
167+
*/
20168
export async function optimizeChunks(
21169
original: BundleContextResult,
22170
sourcemap: boolean | 'hidden',
@@ -40,8 +188,8 @@ export async function optimizeChunks(
40188
}
41189
}
42190

43-
// No action required if no browser main entrypoint
44-
if (!mainFile) {
191+
// No action required if no browser main entrypoint or metafile for stats
192+
if (!mainFile || !original.metafile) {
45193
return original;
46194
}
47195

@@ -110,28 +258,30 @@ export async function optimizeChunks(
110258
assertIsError(e);
111259

112260
return {
113-
errors: [
114-
// Most of these fields are not actually needed for printing the error
115-
{
116-
id: '',
117-
text: 'Chunk optimization failed',
118-
detail: undefined,
119-
pluginName: '',
120-
location: null,
121-
notes: [
122-
{
123-
text: e.message,
124-
location: null,
125-
},
126-
],
127-
},
128-
],
261+
errors: [createChunkOptimizationFailureMessage(e.message)],
129262
warnings: original.warnings,
130263
};
131264
} finally {
132265
await bundle?.close();
133266
}
134267

268+
// Update metafile
269+
const newMetafile = rolldownToEsbuildMetafile(optimizedOutput, original.metafile);
270+
// Add back the outputs that were not part of the optimization
271+
for (const [path, output] of Object.entries(original.metafile.outputs)) {
272+
if (usedChunks.has(path)) {
273+
continue;
274+
}
275+
276+
newMetafile.outputs[path] = output;
277+
for (const inputPath of Object.keys(output.inputs)) {
278+
if (!newMetafile.inputs[inputPath]) {
279+
newMetafile.inputs[inputPath] = original.metafile.inputs[inputPath];
280+
}
281+
}
282+
}
283+
original.metafile = newMetafile;
284+
135285
// Remove used chunks and associated sourcemaps from the original result
136286
original.outputFiles = original.outputFiles.filter(
137287
(file) =>
@@ -192,13 +342,7 @@ export async function optimizeChunks(
192342
continue;
193343
}
194344

195-
const record: InitialFileRecord = {
196-
type: 'script',
197-
entrypoint: false,
198-
external: false,
199-
serverFile: false,
200-
depth: entryRecord.depth + 1,
201-
};
345+
const record = createInitialFileRecord(entryRecord.depth + 1);
202346

203347
entriesToAnalyze.push([importPath, record]);
204348
}

0 commit comments

Comments
 (0)