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,54 @@ 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 fileCacheKeyBase : Uint8Array | undefined ;
133+
134+ for ( const [ filename , file ] of this . #localizeFiles) {
135+ let cacheKey : string | undefined ;
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' )
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 ;
127171 } ) ;
128- requests . push ( fileRequest ) ;
172+
173+ requests . push ( fileResult ) ;
129174 }
130175
131176 // Wait for all file requests to complete
@@ -136,7 +181,7 @@ export class I18nInliner {
136181 const warnings : string [ ] = [ ] ;
137182 const outputFiles = [
138183 ...rawResults . flatMap ( ( { file, code, map, messages } ) => {
139- const type = this . #fileToType . get ( file ) ;
184+ const type = this . #localizeFiles . get ( file ) ?. type ;
140185 assert ( type !== undefined , 'localized file should always have a type' + file ) ;
141186
142187 const resultFiles = [ createOutputFile ( file , code , type ) ] ;
@@ -171,4 +216,37 @@ export class I18nInliner {
171216 close ( ) : Promise < void > {
172217 return this . #workerPool. destroy ( ) ;
173218 }
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+ }
174252}
0 commit comments