Skip to content

Commit

Permalink
perf(@angular/build): cache translated i18n bundles for faster builds
Browse files Browse the repository at this point in the history
When disk caching is enabled, translated i18n bundles are stored on disk, improving performance and speeding up both incremental and non-incremental builds.
  • Loading branch information
alan-agius4 committed Feb 11, 2025
1 parent 833dc98 commit 7d46517
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 26 deletions.
3 changes: 2 additions & 1 deletion packages/angular/build/src/builders/application/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ export async function inlineI18n(
warnings: string[];
prerenderedRoutes: PrerenderedRoutesRecord;
}> {
const { i18nOptions, optimizationOptions, baseHref } = options;
const { i18nOptions, optimizationOptions, baseHref, cacheOptions } = options;

// Create the multi-threaded inliner with common options and the files generated from the build.
const inliner = new I18nInliner(
{
missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning',
outputFiles: executionResult.outputFiles,
shouldOptimize: optimizationOptions.scripts,
persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined,
},
maxWorkers,
);
Expand Down
128 changes: 103 additions & 25 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
*/

import assert from 'node:assert';
import { createHash } from 'node:crypto';
import { extname, join } from 'node:path';
import { WorkerPool } from '../../utils/worker-pool';
import { BuildOutputFile, BuildOutputFileType } from './bundler-context';
import type { LmbdCacheStore } from './lmdb-cache-store';
import { createOutputFile } from './utils';

/**
Expand All @@ -24,6 +27,7 @@ export interface I18nInlinerOptions {
missingTranslation: 'error' | 'warning' | 'ignore';
outputFiles: BuildOutputFile[];
shouldOptimize?: boolean;
persistentCachePath?: string;
}

/**
Expand All @@ -33,42 +37,42 @@ export interface I18nInlinerOptions {
* localize function (`$localize`).
*/
export class I18nInliner {
#cacheInitFailed = false;
#workerPool: WorkerPool;
readonly #localizeFiles: ReadonlyMap<string, Blob>;
#cache: LmbdCacheStore | undefined;
readonly #localizeFiles: ReadonlyMap<string, BuildOutputFile>;
readonly #unmodifiedFiles: Array<BuildOutputFile>;
readonly #fileToType = new Map<string, BuildOutputFileType>();

constructor(options: I18nInlinerOptions, maxThreads?: number) {
constructor(
private readonly options: I18nInlinerOptions,
maxThreads?: number,
) {
this.#unmodifiedFiles = [];
const { outputFiles, shouldOptimize, missingTranslation } = options;
const files = new Map<string, BuildOutputFile>();

const files = new Map<string, Blob>();
const pendingMaps = [];
for (const file of options.outputFiles) {
for (const file of outputFiles) {
if (file.type === BuildOutputFileType.Root || file.type === BuildOutputFileType.ServerRoot) {
// Skip also the server entry-point.
// Skip stats and similar files.
continue;
}

this.#fileToType.set(file.path, file.type);

if (file.path.endsWith('.js') || file.path.endsWith('.mjs')) {
const fileExtension = extname(file.path);
if (fileExtension === '.js' || fileExtension === '.mjs') {
// Check if localizations are present
const contentBuffer = Buffer.isBuffer(file.contents)
? file.contents
: Buffer.from(file.contents.buffer, file.contents.byteOffset, file.contents.byteLength);
const hasLocalize = contentBuffer.includes(LOCALIZE_KEYWORD);

if (hasLocalize) {
// A Blob is an immutable data structure that allows sharing the data between workers
// without copying until the data is actually used within a Worker. This is useful here
// since each file may not actually be processed in each Worker and the Blob avoids
// unneeded repeat copying of potentially large JavaScript files.
files.set(file.path, new Blob([file.contents]));
files.set(file.path, file);

continue;
}
} else if (file.path.endsWith('.js.map')) {
} else if (fileExtension === '.map') {
// The related JS file may not have been checked yet. To ensure that map files are not
// missed, store any pending map files and check them after all output files.
pendingMaps.push(file);
Expand All @@ -81,7 +85,7 @@ export class I18nInliner {
// Check if any pending map files should be processed by checking if the parent JS file is present
for (const file of pendingMaps) {
if (files.has(file.path.slice(0, -4))) {
files.set(file.path, new Blob([file.contents]));
files.set(file.path, file);
} else {
this.#unmodifiedFiles.push(file);
}
Expand All @@ -94,9 +98,15 @@ export class I18nInliner {
maxThreads,
// Extract options to ensure only the named options are serialized and sent to the worker
workerData: {
missingTranslation: options.missingTranslation,
shouldOptimize: options.shouldOptimize,
files,
missingTranslation,
shouldOptimize,
// A Blob is an immutable data structure that allows sharing the data between workers
// without copying until the data is actually used within a Worker. This is useful here
// since each file may not actually be processed in each Worker and the Blob avoids
// unneeded repeat copying of potentially large JavaScript files.
files: new Map<string, Blob>(
Array.from(files, ([name, file]) => [name, new Blob([file.contents])]),
),
},
});
}
Expand All @@ -113,19 +123,54 @@ export class I18nInliner {
locale: string,
translation: Record<string, unknown> | undefined,
): Promise<{ outputFiles: BuildOutputFile[]; errors: string[]; warnings: string[] }> {
await this.initCache();

const { shouldOptimize, missingTranslation } = this.options;
// Request inlining for each file that contains localize calls
const requests = [];
for (const filename of this.#localizeFiles.keys()) {

let fileCacheKeyBase: Uint8Array | undefined;

for (const [filename, file] of this.#localizeFiles) {
let cacheKey: string | undefined;
if (filename.endsWith('.map')) {
continue;
}

const fileRequest = this.#workerPool.run({
filename,
locale,
translation,
let cacheResultPromise = Promise.resolve(null);
if (this.#cache) {
fileCacheKeyBase ??= Buffer.from(
JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }),
'utf-8',
);

// NOTE: If additional options are added, this may need to be updated.
// TODO: Consider xxhash or similar instead of SHA256
cacheKey = createHash('sha256')
.update(file.hash)
.update(filename)
.update(fileCacheKeyBase)
.digest('hex');

// Failure to get the value should not fail the transform
cacheResultPromise = this.#cache.get(cacheKey).catch(() => null);
}

const fileResult = cacheResultPromise.then(async (cachedResult) => {
if (cachedResult) {
return cachedResult;
}

const result = await this.#workerPool.run({ filename, locale, translation });
if (this.#cache && cacheKey) {
// Failure to settung the value should not fail the transform
await this.#cache.set(cacheKey, result).catch(() => {});
}

return result;
});
requests.push(fileRequest);

requests.push(fileResult);
}

// Wait for all file requests to complete
Expand All @@ -136,7 +181,7 @@ export class I18nInliner {
const warnings: string[] = [];
const outputFiles = [
...rawResults.flatMap(({ file, code, map, messages }) => {
const type = this.#fileToType.get(file);
const type = this.#localizeFiles.get(file)?.type;
assert(type !== undefined, 'localized file should always have a type' + file);

const resultFiles = [createOutputFile(file, code, type)];
Expand Down Expand Up @@ -171,4 +216,37 @@ export class I18nInliner {
close(): Promise<void> {
return this.#workerPool.destroy();
}

/**
* Initializes the cache for storing translated bundles.
* If the cache is already initialized, it does nothing.
*
* @returns A promise that resolves once the cache initialization process is complete.
*/
private async initCache(): Promise<void> {
if (this.#cache || this.#cacheInitFailed) {
return;
}

const { persistentCachePath } = this.options;
// Webcontainers currently do not support this persistent cache store.
if (!persistentCachePath || process.versions.webcontainer) {
return;
}

// Initialize a worker pool for i18n transformations.
try {
const { LmbdCacheStore } = await import('./lmdb-cache-store');

this.#cache = new LmbdCacheStore(join(persistentCachePath, 'angular-i18n.db'));
} catch {
this.#cacheInitFailed = true;

// eslint-disable-next-line no-console
console.warn(
'Unable to initialize JavaScript cache storage.\n' +
'This will not affect the build output content but may result in slower builds.',
);
}
}
}

0 comments on commit 7d46517

Please sign in to comment.