Skip to content

Commit f3b3c94

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.
1 parent 833dc98 commit f3b3c94

File tree

2 files changed

+118
-26
lines changed

2 files changed

+118
-26
lines changed

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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 116 additions & 25 deletions
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,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

Comments
 (0)