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,50 @@ 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 cacheKey : string | undefined ;
133
+ let fileCacheKeyBase : Uint8Array | undefined ;
134
+
135
+ for ( const [ filename , file ] of this . #localizeFiles) {
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' ) . update ( file . hash ) . update ( fileCacheKeyBase ) . digest ( 'hex' ) ;
150
+
151
+ // Failure to get the value should not fail the transform
152
+ cacheResultPromise = this . #cache. get ( cacheKey ) . catch ( ( ) => null ) ;
153
+ }
154
+
155
+ const fileResult = cacheResultPromise . then ( async ( cachedResult ) => {
156
+ if ( cachedResult ) {
157
+ return cachedResult ; // Return cached result directly
158
+ }
159
+
160
+ const result = await this . #workerPool. run ( { filename, locale, translation } ) ;
161
+ if ( this . #cache && cacheKey ) {
162
+ // Failure to settung the value should not fail the transform
163
+ await this . #cache. set ( cacheKey , result ) . catch ( ( ) => { } ) ;
164
+ }
165
+
166
+ return result ;
127
167
} ) ;
128
- requests . push ( fileRequest ) ;
168
+
169
+ requests . push ( fileResult ) ;
129
170
}
130
171
131
172
// Wait for all file requests to complete
@@ -136,7 +177,7 @@ export class I18nInliner {
136
177
const warnings : string [ ] = [ ] ;
137
178
const outputFiles = [
138
179
...rawResults . flatMap ( ( { file, code, map, messages } ) => {
139
- const type = this . #fileToType . get ( file ) ;
180
+ const type = this . #localizeFiles . get ( file ) ?. type ;
140
181
assert ( type !== undefined , 'localized file should always have a type' + file ) ;
141
182
142
183
const resultFiles = [ createOutputFile ( file , code , type ) ] ;
@@ -171,4 +212,37 @@ export class I18nInliner {
171
212
close ( ) : Promise < void > {
172
213
return this . #workerPool. destroy ( ) ;
173
214
}
215
+
216
+ /**
217
+ * Initializes the cache for storing translated bundles.
218
+ * If the cache is already initialized, it does nothing.
219
+ *
220
+ * @returns A promise that resolves once the cache initialization process is complete.
221
+ */
222
+ private async initCache ( ) : Promise < void > {
223
+ if ( this . #cache || this . #cacheInitFailed) {
224
+ return ;
225
+ }
226
+
227
+ const { persistentCachePath } = this . options ;
228
+ // Webcontainers currently do not support this persistent cache store.
229
+ if ( ! persistentCachePath || process . versions . webcontainer ) {
230
+ return ;
231
+ }
232
+
233
+ // Initialize a worker pool for i18n transformations.
234
+ try {
235
+ const { LmbdCacheStore } = await import ( './lmdb-cache-store' ) ;
236
+
237
+ this . #cache = new LmbdCacheStore ( join ( persistentCachePath , 'angular-i18n.db' ) ) ;
238
+ } catch {
239
+ this . #cacheInitFailed = true ;
240
+
241
+ // eslint-disable-next-line no-console
242
+ console . warn (
243
+ 'Unable to initialize JavaScript cache storage.\n' +
244
+ 'This will not affect the build output content but may result in slower builds.' ,
245
+ ) ;
246
+ }
247
+ }
174
248
}
0 commit comments