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,142 @@ 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 : { } ,
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+ */
20168export 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