Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 199 additions & 66 deletions packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts

Large diffs are not rendered by default.

157 changes: 94 additions & 63 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import assert from 'node:assert';
import { extname, join } from 'node:path';
import { serialize } from 'node:v8';
import { calculateHash, createContentHash, initializeHash } from '../../utils/hash';
import { WorkerPool } from '../../utils/worker-pool';
import { WorkerPool, getSharedBuildWorkerPool } from '../../utils/worker-pool';
import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
import { type Cache, type PersistentCacheStore, createPersistentCacheStore } from './cache';
import type { InlineCodeResult, InlineDiagnosticMessage } from './i18n-inliner-worker';

/**
* A keyword used to indicate if a JavaScript file may require inlining of translations.
Expand Down Expand Up @@ -116,6 +117,33 @@ interface CacheCheckItem {
cachedResult: Promise<TransformedFileResult | null>;
}

export interface InlineTemplateUpdateResult {
code: string;
errors: string[];
warnings: string[];
}

/**
* Partitions diagnostic messages into error and warning strings.
*/
function partitionDiagnostics(messages: readonly InlineDiagnosticMessage[]): {
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];

for (const message of messages) {
if (message.type === 'error') {
errors.push(message.message);
} else {
warnings.push(message.message);
}
}

return { errors, warnings };
}

/**
* A class that performs i18n translation inlining of JavaScript code.
* A worker pool is used to distribute the transformation actions and allow
Expand All @@ -128,27 +156,27 @@ export class I18nInliner {
#cacheStore: PersistentCacheStore | undefined;
#cache: Cache<TransformedFileResult> | undefined;
readonly #localizeFiles: ReadonlyMap<string, BuildOutputFile>;
readonly #filesBlobs: Map<string, Blob>;
readonly #unmodifiedFiles: Array<BuildOutputFile>;

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

const pendingMaps = [];
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.
this.#unmodifiedFiles.push(file);
continue;
}

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);
Expand Down Expand Up @@ -179,24 +207,11 @@ export class I18nInliner {
}

this.#localizeFiles = files;
this.#filesBlobs = new Map<string, Blob>(
Array.from(files, ([name, file]) => [name, new Blob([file.contents])]),
);

this.#workerPool = new WorkerPool({
filename: require.resolve('./i18n-inliner-worker'),
maxThreads,
// Extract options to ensure only the named options are serialized and sent to the worker
workerData: {
missingTranslation,
shouldOptimize,
translations,
// 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])]),
),
},
});
this.#workerPool = getSharedBuildWorkerPool();
}

/**
Expand Down Expand Up @@ -238,9 +253,15 @@ export class I18nInliner {
// Pre-calculate cache key bases and serialized Blobs for each locale in this window
const localeCacheBases = new Map<string, string>();
const localeBlobs = new Map<string, Blob | undefined>();
const localeKeys = new Map<string, string | undefined>();

for (const { locale, translation, translationIntegrity } of windowLocales) {
localeBlobs.set(locale, serializeTranslation(translation));
localeKeys.set(
locale,
translationIntegrity ??
(translation ? calculateHash(JSON.stringify(translation)) : undefined),
);

if (this.#cacheStore) {
localeCacheBases.set(
Expand Down Expand Up @@ -304,7 +325,7 @@ export class I18nInliner {
// Group uncached items by filename for this window
const uncachedByFile = new Map<
string,
Array<{ locale: string; cacheKey?: string; translation?: Blob }>
Array<{ locale: string; cacheKey?: string; translation?: Blob; translationKey?: string }>
>();

for (const item of resolvedChecks) {
Expand All @@ -322,6 +343,7 @@ export class I18nInliner {
locale: item.locale,
cacheKey: item.cacheKey,
translation: localeBlobs.get(item.locale),
translationKey: localeKeys.get(item.locale),
});
}
}
Expand Down Expand Up @@ -362,13 +384,11 @@ export class I18nInliner {
outputFiles.push(createOutputFile(filename + '.map', fileResult.map, type));
}

for (const message of fileResult.messages) {
if (message.type === 'error') {
errors.push(message.message);
} else {
warnings.push(message.message);
}
}
const { errors: newErrors, warnings: newWarnings } = partitionDiagnostics(
fileResult.messages,
);
errors.push(...newErrors);
warnings.push(...newWarnings);
}
}

Expand All @@ -386,7 +406,10 @@ export class I18nInliner {
}

async #processUncachedBatches(
uncachedByFile: Map<string, Array<{ locale: string; cacheKey?: string; translation?: Blob }>>,
uncachedByFile: Map<
string,
Array<{ locale: string; cacheKey?: string; translation?: Blob; translationKey?: string }>
>,
localeCount: number,
fileResultsByLocale: Map<string, Map<string, TransformedFileResult>>,
activeLocales?: string[],
Expand All @@ -402,22 +425,32 @@ export class I18nInliner {
const workerTasks: Promise<void>[] = [];

for (const [filename, entries] of uncachedByFile) {
const file = this.#localizeFiles.get(filename);
const fileBlob = this.#filesBlobs.get(filename);
const mapBlob = this.#filesBlobs.get(filename + '.map');
const fileKey = file ? `${filename}\0${file.hash}` : undefined;
const ephemeral = isLastWindow && entries.length <= localesPerBatch;

for (let i = 0; i < entries.length; i += localesPerBatch) {
const batchEntries = entries.slice(i, i + localesPerBatch);
const task = (async () => {
const batchResult = (await this.#workerPool.run(
{
filename,
locales: batchEntries.map((e) => ({
locale: e.locale,
translation: e.translation,
})),
ephemeral,
activeLocales,
},
{ name: 'inlineFileBatch' },
)) as {
const batchResult = (await this.#workerPool.run({
tag: 'inline-i18n',
action: 'inlineFileBatch',
filename,
fileBlob,
fileKey,
mapBlob,
missingTranslation: this.options.missingTranslation,
shouldOptimize: this.options.shouldOptimize,
ephemeral,
activeLocales,
locales: batchEntries.map((e) => ({
locale: e.locale,
translation: e.translation,
translationKey: e.translationKey,
})),
})) as {
file: string;
results: Array<TransformedFileResult & { locale: string }>;
};
Expand Down Expand Up @@ -476,7 +509,7 @@ export class I18nInliner {
translation: Record<string, unknown> | undefined,
templateCode: string,
templateId: string,
): Promise<{ code: string; errors: string[]; warnings: string[] }> {
): Promise<InlineTemplateUpdateResult> {
const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD);

if (!hasLocalize) {
Expand All @@ -487,25 +520,19 @@ export class I18nInliner {
};
}

const { output, messages } = await this.#workerPool.run(
{
code: templateCode,
filename: templateId,
locale,
translation: serializeTranslation(translation),
},
{ name: 'inlineCode' },
);
const { output, messages } = (await this.#workerPool.run({
tag: 'inline-i18n',
action: 'inlineCode',
code: templateCode,
filename: templateId,
locale,
translation: serializeTranslation(translation),
translationKey: translation ? calculateHash(JSON.stringify(translation)) : undefined,
missingTranslation: this.options.missingTranslation,
shouldOptimize: this.options.shouldOptimize,
})) as InlineCodeResult;

const errors: string[] = [];
const warnings: string[] = [];
for (const message of messages) {
if (message.type === 'error') {
errors.push(message.message);
} else {
warnings.push(message.message);
}
}
const { errors, warnings } = partitionDiagnostics(messages);

return {
code: output,
Expand All @@ -519,7 +546,11 @@ export class I18nInliner {
* @returns A void promise that resolves when closing is complete.
*/
async close(): Promise<void> {
await Promise.allSettled([this.#cacheStore?.close(), this.#workerPool.destroy()]);
if (this.#workerPool !== getSharedBuildWorkerPool()) {
await Promise.allSettled([this.#cacheStore?.close(), this.#workerPool.destroy()]);
} else {
await this.#cacheStore?.close();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,25 @@ import {
import { transform as transformWithOxc } from '../oxc/oxc-transform.js';
import type { JavaScriptTransformerOptions } from './javascript-transformer';

interface JavaScriptTransformRequest {
export interface JavaScriptTransformRequest {
filename: string;
data: string | Uint8Array;
skipLinker?: boolean;
sideEffects?: boolean;
instrumentForCoverage?: boolean;
sourcemap?: boolean;
thirdPartySourcemaps?: boolean;
advancedOptimizations?: boolean;
jit?: boolean;
}

interface TransformOptions extends Omit<JavaScriptTransformRequest, 'filename' | 'data'> {
inputSourceMap?: EncodedSourceMap;
isAlreadyStripped?: boolean;
sourcemap?: boolean;
thirdPartySourcemaps?: boolean;
advancedOptimizations?: boolean;
jit?: boolean;
}

const {
Expand Down Expand Up @@ -92,10 +100,18 @@ async function instrumentCoverage(
export default async function transformJavaScript(
request: JavaScriptTransformRequest,
): Promise<unknown> {
const { filename, data, ...options } = request;
const {
filename,
data,
sourcemap: reqSourcemap = sourcemap,
thirdPartySourcemaps: reqThirdPartySourcemaps = thirdPartySourcemaps,
advancedOptimizations: reqAdvancedOptimizations = advancedOptimizations,
jit: reqJit = jit,
...options
} = request;

const useInputSourcemap =
sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
reqSourcemap && (!!reqThirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));

let textData: string;
let inputSourceMap: EncodedSourceMap | undefined;
Expand Down Expand Up @@ -137,6 +153,10 @@ export default async function transformJavaScript(

const transformedData = await transformJavaScriptImpl(filename, textData, {
...options,
sourcemap: reqSourcemap,
thirdPartySourcemaps: reqThirdPartySourcemaps,
advancedOptimizations: reqAdvancedOptimizations,
jit: reqJit,
inputSourceMap,
isAlreadyStripped,
});
Expand All @@ -156,8 +176,13 @@ async function transformJavaScriptImpl(
options: TransformOptions,
): Promise<string> {
const shouldLink = !options.skipLinker;
const optSourcemap = options.sourcemap ?? sourcemap;
const optThirdPartySourcemaps = options.thirdPartySourcemaps ?? thirdPartySourcemaps;
const optAdvancedOptimizations = options.advancedOptimizations ?? advancedOptimizations;
const optJit = options.jit ?? jit;

const useInputSourcemap =
sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
optSourcemap && (!!optThirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));

let code = data;
const maps: (DecodedSourceMap | EncodedSourceMap)[] = [];
Expand Down Expand Up @@ -191,7 +216,7 @@ async function transformJavaScriptImpl(
relative: (_from: string, to: string) => to,
} as never,
logger: new ConsoleLogger(LogLevel.info),
linkerJitMode: jit,
linkerJitMode: optJit,
// This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
sourceMapping: false,
}) as PluginItem,
Expand All @@ -206,16 +231,16 @@ async function transformJavaScriptImpl(

// Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass
const oxcLink = shouldLink && !useBabelLinker;
if (oxcLink || advancedOptimizations) {
if (oxcLink || optAdvancedOptimizations) {
const sideEffectFree = options.sideEffects === false;
const safeAngularPackage =
sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
const topLevelSafeMode = !safeAngularPackage;

const result = transformWithOxc(filename, code, {
link: oxcLink,
jit,
advancedOptimizations,
jit: optJit,
advancedOptimizations: optAdvancedOptimizations,
sourcemap: useInputSourcemap,
sideEffects: options.sideEffects,
topLevelSafeMode,
Expand Down
Loading