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,18 +123,66 @@ 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+ let fileCacheKeyBase : Uint8Array | undefined ;
132+
133+ for ( const [ filename , file ] of this . #localizeFiles) {
119134 if ( filename . endsWith ( '.map' ) ) {
120135 continue ;
121136 }
122137
123- const fileRequest = this . #workerPool. run ( {
124- filename,
125- locale,
126- translation,
127- } ) ;
138+ let cacheKey : string | undefined ;
139+ if ( this . #cache) {
140+ fileCacheKeyBase ??= Buffer . from (
141+ JSON . stringify ( {
142+ locale,
143+ translation,
144+ missingTranslation,
145+ shouldOptimize,
146+ } ) ,
147+ 'utf-8' ,
148+ ) ;
149+
150+ // NOTE: If additional options are added, this may need to be updated.
151+ // TODO: Consider xxhash or similar instead of SHA256
152+ const cacheKey = createHash ( 'sha256' )
153+ . update ( file . hash )
154+ . update ( fileCacheKeyBase )
155+ . digest ( 'hex' ) ;
156+
157+ try {
158+ const result = await this . #cache. get ( cacheKey ) ;
159+ if ( result ) {
160+ requests . push ( Promise . resolve ( result ) ) ;
161+ continue ;
162+ }
163+ } catch {
164+ // Failure to get the value should not fail the transform
165+ }
166+ }
167+
168+ const fileRequest = this . #workerPool
169+ . run ( {
170+ filename,
171+ locale,
172+ translation,
173+ } )
174+ . then ( async ( result ) => {
175+ if ( this . #cache && cacheKey ) {
176+ try {
177+ await this . #cache. set ( cacheKey , result ) ;
178+ } catch {
179+ // Failure to store the value in the cache should not fail the transform
180+ }
181+ }
182+
183+ return result ;
184+ } ) ;
185+
128186 requests . push ( fileRequest ) ;
129187 }
130188
@@ -136,7 +194,7 @@ export class I18nInliner {
136194 const warnings : string [ ] = [ ] ;
137195 const outputFiles = [
138196 ...rawResults . flatMap ( ( { file, code, map, messages } ) => {
139- const type = this . #fileToType . get ( file ) ;
197+ const type = this . #localizeFiles . get ( file ) ?. type ;
140198 assert ( type !== undefined , 'localized file should always have a type' + file ) ;
141199
142200 const resultFiles = [ createOutputFile ( file , code , type ) ] ;
@@ -171,4 +229,37 @@ export class I18nInliner {
171229 close ( ) : Promise < void > {
172230 return this . #workerPool. destroy ( ) ;
173231 }
232+
233+ /**
234+ * Initializes the cache for storing translated bundles.
235+ * If the cache is already initialized, it does nothing.
236+ *
237+ * @returns A promise that resolves once the cache initialization process is complete.
238+ */
239+ private async initCache ( ) : Promise < void > {
240+ if ( this . #cache || this . #cacheInitFailed) {
241+ return ;
242+ }
243+
244+ const { persistentCachePath } = this . options ;
245+ // Webcontainers currently do not support this persistent cache store.
246+ if ( ! persistentCachePath || process . versions . webcontainer ) {
247+ return ;
248+ }
249+
250+ // Initialize a worker pool for i18n transformations.
251+ try {
252+ const { LmbdCacheStore } = await import ( './lmdb-cache-store' ) ;
253+
254+ this . #cache = new LmbdCacheStore ( join ( persistentCachePath , 'angular-i18n.db' ) ) ;
255+ } catch {
256+ this . #cacheInitFailed = true ;
257+
258+ // eslint-disable-next-line no-console
259+ console . warn (
260+ 'Unable to initialize JavaScript cache storage.\n' +
261+ 'This will not affect the build output content but may result in slower builds.' ,
262+ ) ;
263+ }
264+ }
174265}
0 commit comments