6
6
* found in the LICENSE file at https://angular.dev/license
7
7
*/
8
8
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' ;
9
21
import assert from 'node:assert' ;
10
- import { rolldown } from 'rolldown' ;
22
+ import { type OutputAsset , type OutputChunk , rolldown } from 'rolldown' ;
11
23
import {
12
24
BuildOutputFile ,
13
25
BuildOutputFileType ,
@@ -17,6 +29,142 @@ import {
17
29
import { createOutputFile } from '../../tools/esbuild/utils' ;
18
30
import { assertIsError } from '../../utils/error' ;
19
31
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
+ */
20
168
export async function optimizeChunks (
21
169
original : BundleContextResult ,
22
170
sourcemap : boolean | 'hidden' ,
@@ -40,8 +188,8 @@ export async function optimizeChunks(
40
188
}
41
189
}
42
190
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 ) {
45
193
return original ;
46
194
}
47
195
@@ -110,28 +258,30 @@ export async function optimizeChunks(
110
258
assertIsError ( e ) ;
111
259
112
260
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 ) ] ,
129
262
warnings : original . warnings ,
130
263
} ;
131
264
} finally {
132
265
await bundle ?. close ( ) ;
133
266
}
134
267
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
+
135
285
// Remove used chunks and associated sourcemaps from the original result
136
286
original . outputFiles = original . outputFiles . filter (
137
287
( file ) =>
@@ -192,13 +342,7 @@ export async function optimizeChunks(
192
342
continue ;
193
343
}
194
344
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 ) ;
202
346
203
347
entriesToAnalyze . push ( [ importPath , record ] ) ;
204
348
}
0 commit comments