Skip to content
Merged
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
78 changes: 66 additions & 12 deletions packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/

import remapping, { type EncodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
import { MagicString } from 'magic-string';
import assert from 'node:assert';
import { deserialize } from 'node:v8';
import { workerData } from 'node:worker_threads';
import { Visitor, parseSync } from 'oxc-parser';

Expand All @@ -28,9 +29,11 @@ interface InlineFileRequest {
locale: string;

/**
* The translation messages for the locale that should be used during the inlining process of the file.
* The serialized translation messages for the locale that should be used during the inlining
* process of the file. A Blob is used so that the messages are shared with the Worker by
* reference instead of being copied into it for every request.
*/
translation?: Record<string, unknown>;
translation?: Blob;
}

/**
Expand All @@ -53,19 +56,55 @@ interface InlineCodeRequest {
locale: string;

/**
* The translation messages for the locale that should be used during the inlining process of the file.
* The serialized translation messages for the locale that should be used during the inlining
* process of the file. A Blob is used so that the messages are shared with the Worker by
* reference instead of being copied into it for every request.
*/
translation?: Record<string, unknown>;
translation?: Blob;
}

// Extract the application files and common options used for inline requests from the Worker context
// TODO: Evaluate overall performance difference of passing translations here as well
const { files, missingTranslation, shouldOptimize } = (workerData || {}) as {
files: ReadonlyMap<string, Blob>;
missingTranslation: 'error' | 'warning' | 'ignore';
shouldOptimize: boolean;
};

/**
* The translation messages deserialized for the locale most recently requested of this Worker.
* Locales are inlined one at a time, so retaining only the active locale is enough to avoid
* deserializing the messages once per file while holding at most one set of messages in memory.
*/
let activeTranslation: { locale: string; messages: Promise<Record<string, unknown>> } | undefined;

/**
* Deserializes the translation messages for an inline request, reusing the result for any
* subsequent request that targets the same locale.
* @param request An inline request containing the locale and its serialized messages.
* @returns The translation messages, or undefined if the locale has no translations.
*/
function loadTranslation(
request: InlineFileRequest | InlineCodeRequest,
): Promise<Record<string, unknown>> | undefined {
const { locale, translation } = request;
if (!translation) {
return undefined;
}

if (activeTranslation?.locale !== locale) {
activeTranslation = {
locale,
// Deserializing within the stored promise ensures that concurrent requests for a locale
// share the one deserialization instead of each performing their own.
messages: translation
.arrayBuffer()
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, unknown>),
};
}

return activeTranslation.messages;
}

/**
* Inlines the provided locale and translation into a JavaScript file that contains `$localize` usage.
* This function is the main entry for the Worker's action that is called by the worker pool.
Expand All @@ -80,7 +119,12 @@ export default async function inlineFile(request: InlineFileRequest) {

const code = await data.text();
const map = await files.get(request.filename + '.map')?.text();
const result = await transformWithOxc(code, map && (JSON.parse(map) as SourceMapInput), request);
const result = await transformWithOxc(
code,
map && (JSON.parse(map) as SourceMapInput),
request,
await loadTranslation(request),
);

return {
file: request.filename,
Expand All @@ -98,7 +142,12 @@ export default async function inlineFile(request: InlineFileRequest) {
* @returns An object containing the inlined code.
*/
export async function inlineCode(request: InlineCodeRequest) {
const result = await transformWithOxc(request.code, undefined, request);
const result = await transformWithOxc(
request.code,
undefined,
request,
await loadTranslation(request),
);

return {
output: result.code,
Expand Down Expand Up @@ -136,12 +185,14 @@ async function loadLocalizeTools(): Promise<LocalizeUtilityModule> {
* @param code A string containing the JavaScript code to transform.
* @param map A sourcemap object for the provided JavaScript code.
* @param options The inline request options to use.
* @param translation The translation messages to inline, or undefined for an untranslated locale.
* @returns An object containing the code, map, and diagnostics from the transformation.
*/
async function transformWithOxc(
code: string,
map: SourceMapInput | undefined,
options: InlineFileRequest,
translation: Record<string, unknown> | undefined,
) {
const { program } = parseSync(options.filename, code, {
sourceType: 'unambiguous',
Expand Down Expand Up @@ -169,10 +220,10 @@ async function transformWithOxc(

const [translatedParts, translatedSubstitutions] = translate(
diagnostics,
options.translation || {},
translation || {},
messageParts,
node.quasi.expressions.map((_, index) => index),
options.translation === undefined ? 'ignore' : missingTranslation,
translation === undefined ? 'ignore' : missingTranslation,
);

// Reconstruct the new template/string literal replacement
Expand Down Expand Up @@ -209,12 +260,15 @@ async function transformWithOxc(
const outputCode = magicString.toString();
let outputMap;
if (map && magicString.hasChanged()) {
const rawMap = magicString.generateMap({
// A decoded map is generated here rather than an encoded one because remapping decodes its
// inputs. Encoding the mappings only for remapping to immediately decode them again doubles
// the peak memory of the largest structure involved in inlining a file.
const rawMap = magicString.generateDecodedMap({
source: options.filename,
includeContent: true,
hires: 'boundary',
});
outputMap = remapping([rawMap as EncodedSourceMap, map], () => null);
outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null);
}

return {
Expand Down
42 changes: 36 additions & 6 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import assert from 'node:assert';
import { createHash } from 'node:crypto';
import { extname, join } from 'node:path';
import { serialize } from 'node:v8';
import { WorkerPool } from '../../utils/worker-pool';
import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
Expand All @@ -19,6 +20,20 @@ import { type PersistentCacheStore, createPersistentCacheStore } from './cache';
*/
const LOCALIZE_KEYWORD = '$localize';

/**
* Serializes the translation messages for a locale for transfer to an inliner Worker.
*
* A Blob is used because cloning one shares its data by reference, whereas sending the messages
* themselves copies them into a Worker for every request that carries them. A locale can contain
* tens of thousands of messages, which makes that copy the dominant cost of inlining a locale.
*
* @param translation The translation messages for a locale, if the locale has any.
* @returns A Blob containing the serialized messages, or undefined if the locale has none.
*/
function serializeTranslation(translation: Record<string, unknown> | undefined): Blob | undefined {
return translation && new Blob([serialize(translation)]);
}
Comment thread
clydin marked this conversation as resolved.

/**
* Inlining options that should apply to all transformed code.
*/
Expand Down Expand Up @@ -125,6 +140,10 @@ export class I18nInliner {
await this.initCache();

const { shouldOptimize, missingTranslation } = this.options;

// Serialized once here and then shared by the request for every file of this locale
const translationBlob = serializeTranslation(translation);
Comment thread
clydin marked this conversation as resolved.

// Request inlining for each file that contains localize calls
const requests = [];

Expand All @@ -138,10 +157,12 @@ export class I18nInliner {

let cacheResultPromise = Promise.resolve(null);
if (this.#cache) {
fileCacheKeyBase ??= Buffer.from(
JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }),
'utf-8',
);
// The options are digested here so that each file's key is derived from a fixed number
// of bytes. Hashing the options directly would re-hash the full set of messages, which
// can be several megabytes, once for every file.
fileCacheKeyBase ??= createHash('sha256')
.update(JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }))
.digest();
Comment thread
clydin marked this conversation as resolved.

// NOTE: If additional options are added, this may need to be updated.
// TODO: Consider xxhash or similar instead of SHA256
Expand All @@ -160,7 +181,11 @@ export class I18nInliner {
return cachedResult;
}

const result = await this.#workerPool.run({ filename, locale, translation });
const result = await this.#workerPool.run({
filename,
locale,
translation: translationBlob,
});
if (this.#cache && cacheKey) {
try {
// Failure to set the value should not fail the transform
Expand Down Expand Up @@ -227,7 +252,12 @@ export class I18nInliner {
}

const { output, messages } = await this.#workerPool.run(
{ code: templateCode, filename: templateId, locale, translation },
{
code: templateCode,
filename: templateId,
locale,
translation: serializeTranslation(translation),
},
{ name: 'inlineCode' },
);
Comment thread
clydin marked this conversation as resolved.

Expand Down
Loading