7
7
*/
8
8
9
9
import assert from 'node:assert' ;
10
+ import { createHash } from 'node:crypto' ;
11
+ import { extname , join } from 'node:path' ;
10
12
import { WorkerPool } from '../../utils/worker-pool' ;
11
13
import { BuildOutputFile , BuildOutputFileType } from './bundler-context' ;
14
+ import type { LmbdCacheStore } from './lmdb-cache-store' ;
12
15
import { createOutputFile } from './utils' ;
13
16
14
17
/**
@@ -24,6 +27,7 @@ export interface I18nInlinerOptions {
24
27
missingTranslation : 'error' | 'warning' | 'ignore' ;
25
28
outputFiles : BuildOutputFile [ ] ;
26
29
shouldOptimize ?: boolean ;
30
+ persistentCachePath ?: string ;
27
31
}
28
32
29
33
/**
@@ -33,42 +37,42 @@ export interface I18nInlinerOptions {
33
37
* localize function (`$localize`).
34
38
*/
35
39
export class I18nInliner {
40
+ #cacheInitFailed = false ;
36
41
#workerPool: WorkerPool ;
37
- readonly #localizeFiles: ReadonlyMap < string , Blob > ;
42
+ #cache: LmbdCacheStore | undefined ;
43
+ readonly #localizeFiles: ReadonlyMap < string , BuildOutputFile > ;
38
44
readonly #unmodifiedFiles: Array < BuildOutputFile > ;
39
- readonly #fileToType = new Map < string , BuildOutputFileType > ( ) ;
40
45
41
- constructor ( options : I18nInlinerOptions , maxThreads ?: number ) {
46
+ constructor (
47
+ private readonly options : I18nInlinerOptions ,
48
+ maxThreads ?: number ,
49
+ ) {
42
50
this . #unmodifiedFiles = [ ] ;
51
+ const { outputFiles, shouldOptimize, missingTranslation } = options ;
52
+ const files = new Map < string , BuildOutputFile > ( ) ;
43
53
44
- const files = new Map < string , Blob > ( ) ;
45
54
const pendingMaps = [ ] ;
46
- for ( const file of options . outputFiles ) {
55
+ for ( const file of outputFiles ) {
47
56
if ( file . type === BuildOutputFileType . Root || file . type === BuildOutputFileType . ServerRoot ) {
48
57
// Skip also the server entry-point.
49
58
// Skip stats and similar files.
50
59
continue ;
51
60
}
52
61
53
- this . #fileToType. set ( file . path , file . type ) ;
54
-
55
- if ( file . path . endsWith ( '.js' ) || file . path . endsWith ( '.mjs' ) ) {
62
+ const fileExtension = extname ( file . path ) ;
63
+ if ( fileExtension === '.js' || fileExtension === '.mjs' ) {
56
64
// Check if localizations are present
57
65
const contentBuffer = Buffer . isBuffer ( file . contents )
58
66
? file . contents
59
67
: Buffer . from ( file . contents . buffer , file . contents . byteOffset , file . contents . byteLength ) ;
60
68
const hasLocalize = contentBuffer . includes ( LOCALIZE_KEYWORD ) ;
61
69
62
70
if ( hasLocalize ) {
63
- // A Blob is an immutable data structure that allows sharing the data between workers
64
- // without copying until the data is actually used within a Worker. This is useful here
65
- // since each file may not actually be processed in each Worker and the Blob avoids
66
- // unneeded repeat copying of potentially large JavaScript files.
67
- files . set ( file . path , new Blob ( [ file . contents ] ) ) ;
71
+ files . set ( file . path , file ) ;
68
72
69
73
continue ;
70
74
}
71
- } else if ( file . path . endsWith ( '.js. map') ) {
75
+ } else if ( fileExtension === '. map') {
72
76
// The related JS file may not have been checked yet. To ensure that map files are not
73
77
// missed, store any pending map files and check them after all output files.
74
78
pendingMaps . push ( file ) ;
@@ -81,7 +85,7 @@ export class I18nInliner {
81
85
// Check if any pending map files should be processed by checking if the parent JS file is present
82
86
for ( const file of pendingMaps ) {
83
87
if ( files . has ( file . path . slice ( 0 , - 4 ) ) ) {
84
- files . set ( file . path , new Blob ( [ file . contents ] ) ) ;
88
+ files . set ( file . path , file ) ;
85
89
} else {
86
90
this . #unmodifiedFiles. push ( file ) ;
87
91
}
@@ -94,9 +98,15 @@ export class I18nInliner {
94
98
maxThreads,
95
99
// Extract options to ensure only the named options are serialized and sent to the worker
96
100
workerData : {
97
- missingTranslation : options . missingTranslation ,
98
- shouldOptimize : options . shouldOptimize ,
99
- files,
101
+ missingTranslation,
102
+ shouldOptimize,
103
+ // A Blob is an immutable data structure that allows sharing the data between workers
104
+ // without copying until the data is actually used within a Worker. This is useful here
105
+ // since each file may not actually be processed in each Worker and the Blob avoids
106
+ // unneeded repeat copying of potentially large JavaScript files.
107
+ files : new Map < string , Blob > (
108
+ Array . from ( files , ( [ name , file ] ) => [ name , new Blob ( [ file . contents ] ) ] ) ,
109
+ ) ,
100
110
} ,
101
111
} ) ;
102
112
}
@@ -113,19 +123,54 @@ export class I18nInliner {
113
123
locale : string ,
114
124
translation : Record < string , unknown > | undefined ,
115
125
) : Promise < { outputFiles : BuildOutputFile [ ] ; errors : string [ ] ; warnings : string [ ] } > {
126
+ await this . initCache ( ) ;
127
+
128
+ const { shouldOptimize, missingTranslation } = this . options ;
116
129
// Request inlining for each file that contains localize calls
117
130
const requests = [ ] ;
118
- for ( const filename of this . #localizeFiles. keys ( ) ) {
131
+
132
+ let fileCacheKeyBase : Uint8Array | undefined ;
133
+
134
+ for ( const [ filename , file ] of this . #localizeFiles) {
135
+ let cacheKey : string | undefined ;
119
136
if ( filename . endsWith ( '.map' ) ) {
120
137
continue ;
121
138
}
122
139
123
- const fileRequest = this . #workerPool. run ( {
124
- filename,
125
- locale,
126
- translation,
140
+ let cacheResultPromise = Promise . resolve ( null ) ;
141
+ if ( this . #cache) {
142
+ fileCacheKeyBase ??= Buffer . from (
143
+ JSON . stringify ( { locale, translation, missingTranslation, shouldOptimize } ) ,
144
+ 'utf-8' ,
145
+ ) ;
146
+
147
+ // NOTE: If additional options are added, this may need to be updated.
148
+ // TODO: Consider xxhash or similar instead of SHA256
149
+ cacheKey = createHash ( 'sha256' )
150
+ . update ( file . hash )
151
+ . update ( filename )
152
+ . update ( fileCacheKeyBase )
153
+ . digest ( 'hex' ) ;
154
+
155
+ // Failure to get the value should not fail the transform
156
+ cacheResultPromise = this . #cache. get ( cacheKey ) . catch ( ( ) => null ) ;
157
+ }
158
+
159
+ const fileResult = cacheResultPromise . then ( async ( cachedResult ) => {
160
+ if ( cachedResult ) {
161
+ return cachedResult ;
162
+ }
163
+
164
+ const result = await this . #workerPool. run ( { filename, locale, translation } ) ;
165
+ if ( this . #cache && cacheKey ) {
166
+ // Failure to set the value should not fail the transform
167
+ await this . #cache. set ( cacheKey , result ) . catch ( ( ) => { } ) ;
168
+ }
169
+
170
+ return result ;
127
171
} ) ;
128
- requests . push ( fileRequest ) ;
172
+
173
+ requests . push ( fileResult ) ;
129
174
}
130
175
131
176
// Wait for all file requests to complete
@@ -136,7 +181,7 @@ export class I18nInliner {
136
181
const warnings : string [ ] = [ ] ;
137
182
const outputFiles = [
138
183
...rawResults . flatMap ( ( { file, code, map, messages } ) => {
139
- const type = this . #fileToType . get ( file ) ;
184
+ const type = this . #localizeFiles . get ( file ) ?. type ;
140
185
assert ( type !== undefined , 'localized file should always have a type' + file ) ;
141
186
142
187
const resultFiles = [ createOutputFile ( file , code , type ) ] ;
@@ -171,4 +216,37 @@ export class I18nInliner {
171
216
close ( ) : Promise < void > {
172
217
return this . #workerPool. destroy ( ) ;
173
218
}
219
+
220
+ /**
221
+ * Initializes the cache for storing translated bundles.
222
+ * If the cache is already initialized, it does nothing.
223
+ *
224
+ * @returns A promise that resolves once the cache initialization process is complete.
225
+ */
226
+ private async initCache ( ) : Promise < void > {
227
+ if ( this . #cache || this . #cacheInitFailed) {
228
+ return ;
229
+ }
230
+
231
+ const { persistentCachePath } = this . options ;
232
+ // Webcontainers currently do not support this persistent cache store.
233
+ if ( ! persistentCachePath || process . versions . webcontainer ) {
234
+ return ;
235
+ }
236
+
237
+ // Initialize a persistent cache for i18n transformations.
238
+ try {
239
+ const { LmbdCacheStore } = await import ( './lmdb-cache-store' ) ;
240
+
241
+ this . #cache = new LmbdCacheStore ( join ( persistentCachePath , 'angular-i18n.db' ) ) ;
242
+ } catch {
243
+ this . #cacheInitFailed = true ;
244
+
245
+ // eslint-disable-next-line no-console
246
+ console . warn (
247
+ 'Unable to initialize JavaScript cache storage.\n' +
248
+ 'This will not affect the build output content but may result in slower builds.' ,
249
+ ) ;
250
+ }
251
+ }
174
252
}
0 commit comments