77 */
88
99import assert from 'node:assert' ;
10+ import { createHash } from 'node:crypto' ;
11+ import { extname , join } from 'node:path' ;
1012import { WorkerPool } from '../../utils/worker-pool' ;
1113import { BuildOutputFile , BuildOutputFileType } from './bundler-context' ;
14+ import type { LmbdCacheStore } from './lmdb-cache-store' ;
1215import { createOutputFile } from './utils' ;
1316
1417/**
@@ -24,6 +27,7 @@ export interface I18nInlinerOptions {
2427 missingTranslation : 'error' | 'warning' | 'ignore' ;
2528 outputFiles : BuildOutputFile [ ] ;
2629 shouldOptimize ?: boolean ;
30+ persistentCachePath ?: string ;
2731}
2832
2933/**
@@ -33,42 +37,42 @@ export interface I18nInlinerOptions {
3337 * localize function (`$localize`).
3438 */
3539export class I18nInliner {
40+ #cacheInitFailed = false ;
3641 #workerPool: WorkerPool ;
37- readonly #localizeFiles: ReadonlyMap < string , Blob > ;
42+ #cache: LmbdCacheStore | undefined ;
43+ readonly #localizeFiles: ReadonlyMap < string , BuildOutputFile > ;
3844 readonly #unmodifiedFiles: Array < BuildOutputFile > ;
39- readonly #fileToType = new Map < string , BuildOutputFileType > ( ) ;
4045
41- constructor ( options : I18nInlinerOptions , maxThreads ?: number ) {
46+ constructor (
47+ private readonly options : I18nInlinerOptions ,
48+ maxThreads ?: number ,
49+ ) {
4250 this . #unmodifiedFiles = [ ] ;
51+ const { outputFiles, shouldOptimize, missingTranslation } = options ;
52+ const files = new Map < string , BuildOutputFile > ( ) ;
4353
44- const files = new Map < string , Blob > ( ) ;
4554 const pendingMaps = [ ] ;
46- for ( const file of options . outputFiles ) {
55+ for ( const file of outputFiles ) {
4756 if ( file . type === BuildOutputFileType . Root || file . type === BuildOutputFileType . ServerRoot ) {
4857 // Skip also the server entry-point.
4958 // Skip stats and similar files.
5059 continue ;
5160 }
5261
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' ) {
5664 // Check if localizations are present
5765 const contentBuffer = Buffer . isBuffer ( file . contents )
5866 ? file . contents
5967 : Buffer . from ( file . contents . buffer , file . contents . byteOffset , file . contents . byteLength ) ;
6068 const hasLocalize = contentBuffer . includes ( LOCALIZE_KEYWORD ) ;
6169
6270 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 ) ;
6872
6973 continue ;
7074 }
71- } else if ( file . path . endsWith ( '.js. map') ) {
75+ } else if ( fileExtension === '. map') {
7276 // The related JS file may not have been checked yet. To ensure that map files are not
7377 // missed, store any pending map files and check them after all output files.
7478 pendingMaps . push ( file ) ;
@@ -81,7 +85,7 @@ export class I18nInliner {
8185 // Check if any pending map files should be processed by checking if the parent JS file is present
8286 for ( const file of pendingMaps ) {
8387 if ( files . has ( file . path . slice ( 0 , - 4 ) ) ) {
84- files . set ( file . path , new Blob ( [ file . contents ] ) ) ;
88+ files . set ( file . path , file ) ;
8589 } else {
8690 this . #unmodifiedFiles. push ( file ) ;
8791 }
@@ -94,9 +98,15 @@ export class I18nInliner {
9498 maxThreads,
9599 // Extract options to ensure only the named options are serialized and sent to the worker
96100 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+ ) ,
100110 } ,
101111 } ) ;
102112 }
@@ -113,19 +123,50 @@ export class I18nInliner {
113123 locale : string ,
114124 translation : Record < string , unknown > | undefined ,
115125 ) : Promise < { outputFiles : BuildOutputFile [ ] ; errors : string [ ] ; warnings : string [ ] } > {
126+ await this . initCache ( ) ;
127+
128+ const { shouldOptimize, missingTranslation } = this . options ;
116129 // Request inlining for each file that contains localize calls
117130 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) {
119136 if ( filename . endsWith ( '.map' ) ) {
120137 continue ;
121138 }
122139
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 ;
127167 } ) ;
128- requests . push ( fileRequest ) ;
168+
169+ requests . push ( fileResult ) ;
129170 }
130171
131172 // Wait for all file requests to complete
@@ -136,7 +177,7 @@ export class I18nInliner {
136177 const warnings : string [ ] = [ ] ;
137178 const outputFiles = [
138179 ...rawResults . flatMap ( ( { file, code, map, messages } ) => {
139- const type = this . #fileToType . get ( file ) ;
180+ const type = this . #localizeFiles . get ( file ) ?. type ;
140181 assert ( type !== undefined , 'localized file should always have a type' + file ) ;
141182
142183 const resultFiles = [ createOutputFile ( file , code , type ) ] ;
@@ -171,4 +212,37 @@ export class I18nInliner {
171212 close ( ) : Promise < void > {
172213 return this . #workerPool. destroy ( ) ;
173214 }
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+ }
174248}
0 commit comments