Skip to content

Commit d2138b2

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 d2138b2

File tree

1 file changed

+173
-26
lines changed

1 file changed

+173
-26
lines changed

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

Lines changed: 173 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,145 @@ 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: originalMetafile.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:
113+
chunk.isEntry && chunk.facadeModuleId
114+
? originalMetafile.outputs[chunk.facadeModuleId]?.entryPoint
115+
: undefined,
116+
};
117+
}
118+
119+
return newMetafile;
120+
}
121+
122+
/**
123+
* Creates an InitialFileRecord object with a specified depth.
124+
* @param depth The depth of the file in the dependency graph.
125+
* @returns An InitialFileRecord object.
126+
*/
127+
function createInitialFileRecord(depth: number): InitialFileRecord {
128+
return {
129+
type: 'script',
130+
entrypoint: false,
131+
external: false,
132+
serverFile: false,
133+
depth,
134+
};
135+
}
136+
137+
/**
138+
* Creates an esbuild message object for a chunk optimization failure.
139+
* @param message The error message detailing the cause of the failure.
140+
* @returns A partial esbuild message object.
141+
*/
142+
function createChunkOptimizationFailureMessage(message: string): Message {
143+
// Most of these fields are not actually needed for printing the error
144+
return {
145+
id: '',
146+
text: 'Chunk optimization failed',
147+
detail: undefined,
148+
pluginName: '',
149+
location: null,
150+
notes: [
151+
{
152+
text: message,
153+
location: null,
154+
},
155+
],
156+
};
157+
}
158+
159+
/**
160+
* Optimizes the chunks of a build result using rolldown.
161+
*
162+
* This function takes the output of an esbuild build, identifies the main browser entry point,
163+
* and uses rolldown to bundle and optimize the JavaScript chunks. The optimized chunks
164+
* replace the original ones in the build result, and the metafile is updated to reflect
165+
* the changes.
166+
*
167+
* @param original The original build result from esbuild.
168+
* @param sourcemap A boolean or 'hidden' to control sourcemap generation.
169+
* @returns A promise that resolves to the updated build result with optimized chunks.
170+
*/
20171
export async function optimizeChunks(
21172
original: BundleContextResult,
22173
sourcemap: boolean | 'hidden',
@@ -40,8 +191,8 @@ export async function optimizeChunks(
40191
}
41192
}
42193

43-
// No action required if no browser main entrypoint
44-
if (!mainFile) {
194+
// No action required if no browser main entrypoint or metafile for stats
195+
if (!mainFile || !original.metafile) {
45196
return original;
46197
}
47198

@@ -110,28 +261,30 @@ export async function optimizeChunks(
110261
assertIsError(e);
111262

112263
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-
],
264+
errors: [createChunkOptimizationFailureMessage(e.message)],
129265
warnings: original.warnings,
130266
};
131267
} finally {
132268
await bundle?.close();
133269
}
134270

271+
// Update metafile
272+
const newMetafile = rolldownToEsbuildMetafile(optimizedOutput, original.metafile);
273+
// Add back the outputs that were not part of the optimization
274+
for (const [path, output] of Object.entries(original.metafile.outputs)) {
275+
if (usedChunks.has(path)) {
276+
continue;
277+
}
278+
279+
newMetafile.outputs[path] = output;
280+
for (const inputPath of Object.keys(output.inputs)) {
281+
if (!newMetafile.inputs[inputPath]) {
282+
newMetafile.inputs[inputPath] = original.metafile.inputs[inputPath];
283+
}
284+
}
285+
}
286+
original.metafile = newMetafile;
287+
135288
// Remove used chunks and associated sourcemaps from the original result
136289
original.outputFiles = original.outputFiles.filter(
137290
(file) =>
@@ -192,13 +345,7 @@ export async function optimizeChunks(
192345
continue;
193346
}
194347

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

203350
entriesToAnalyze.push([importPath, record]);
204351
}

0 commit comments

Comments
 (0)