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' ;
921import assert from 'node:assert' ;
10- import { rolldown } from 'rolldown' ;
22+ import { type OutputAsset , type OutputChunk , rolldown } from 'rolldown' ;
1123import {
1224 BuildOutputFile ,
1325 BuildOutputFileType ,
@@ -17,6 +29,145 @@ import {
1729import { createOutputFile } from '../../tools/esbuild/utils' ;
1830import { 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+ */
20171export 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