Skip to content

Commit d2e1c8e

Browse files
committed
perf(@angular/build): cache translated i18n bundles for faster builds
When disk caching is enabled, translated i18n bundles are stored on disk, improving performance and speeding up both incremental and non-incremental builds. (cherry picked from commit b50b6ee)
1 parent 36afdf5 commit d2e1c8e

File tree

2 files changed

+105
-26
lines changed

2 files changed

+105
-26
lines changed

packages/angular/build/src/builders/application/i18n.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,15 @@ export async function inlineI18n(
3939
warnings: string[];
4040
prerenderedRoutes: PrerenderedRoutesRecord;
4141
}> {
42-
const { i18nOptions, optimizationOptions, baseHref } = options;
42+
const { i18nOptions, optimizationOptions, baseHref, cacheOptions } = options;
4343

4444
// Create the multi-threaded inliner with common options and the files generated from the build.
4545
const inliner = new I18nInliner(
4646
{
4747
missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning',
4848
outputFiles: executionResult.outputFiles,
4949
shouldOptimize: optimizationOptions.scripts,
50+
persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined,
5051
},
5152
maxWorkers,
5253
);

packages/angular/build/src/tools/esbuild/i18n-inliner.ts

+103-25
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
*/
88

99
import assert from 'node:assert';
10+
import { createHash } from 'node:crypto';
11+
import { extname, join } from 'node:path';
1012
import { WorkerPool } from '../../utils/worker-pool';
1113
import { BuildOutputFile, BuildOutputFileType } from './bundler-context';
14+
import type { LmbdCacheStore } from './lmdb-cache-store';
1215
import { 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
*/
3539
export 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

Comments
 (0)