Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(@angular-devkit/build-angular): use worker pool for JavaScript transforms in esbuild builder #24266

Merged
merged 1 commit into from Nov 21, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -7,7 +7,6 @@
*/

import type { NgtscProgram } from '@angular/compiler-cli';
import { transformAsync } from '@babel/core';
import type {
OnStartResult,
OutputFile,
Expand All @@ -17,15 +16,14 @@ import type {
PluginBuild,
} from 'esbuild';
import * as assert from 'node:assert';
import * as fs from 'node:fs/promises';
import { platform } from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import ts from 'typescript';
import angularApplicationPreset from '../../babel/presets/application';
import { requiresLinking } from '../../babel/webpack-loader';
import { maxWorkers } from '../../utils/environment-options';
import { loadEsmModule } from '../../utils/load-esm';
import { createAngularCompilerHost, ensureSourceFileVersions } from './angular-host';
import { JavaScriptTransformer } from './javascript-transformer';
import {
logCumulativeDurations,
profileAsync,
Expand Down Expand Up @@ -175,6 +173,9 @@ export function createCompilerPlugin(
async setup(build: PluginBuild): Promise<void> {
let setupWarnings: PartialMessage[] | undefined;

// Initialize a worker pool for JavaScript transformations
const javascriptTransformer = new JavaScriptTransformer(pluginOptions, maxWorkers);

// This uses a wrapped dynamic import to load `@angular/compiler-cli` which is ESM.
// Once TypeScript provides support for retaining dynamic imports this workaround can be dropped.
const { GLOBAL_DEFS_FOR_TERSER_WITH_AOT, NgtscProgram, OptimizeFor, readConfiguration } =
Expand Down Expand Up @@ -252,7 +253,6 @@ export function createCompilerPlugin(

let previousBuilder: ts.EmitAndSemanticDiagnosticsBuilderProgram | undefined;
let previousAngularProgram: NgtscProgram | undefined;
const babelDataCache = new Map<string, Uint8Array>();
const diagnosticCache = new WeakMap<ts.SourceFile, ts.Diagnostic[]>();

build.onStart(async () => {
Expand Down Expand Up @@ -428,7 +428,7 @@ export function createCompilerPlugin(

if (contents === undefined) {
const typescriptResult = await fileEmitter(request);
if (!typescriptResult) {
if (!typescriptResult?.content) {
// No TS result indicates the file is not part of the TypeScript program.
// If allowJs is enabled and the file is JS then defer to the next load hook.
if (compilerOptions.allowJs && /\.[cm]?js$/.test(request)) {
Expand All @@ -447,17 +447,11 @@ export function createCompilerPlugin(
};
}

const data = typescriptResult.content ?? '';
// The pre-transformed data is used as a cache key. Since the cache is memory only,
// the options cannot change and do not need to be represented in the key. If the
// cache is later stored to disk, then the options that affect transform output
// would need to be added to the key as well.
contents = babelDataCache.get(data);
if (contents === undefined) {
const transformedData = await transformWithBabel(request, data, pluginOptions);
contents = Buffer.from(transformedData, 'utf-8');
babelDataCache.set(data, contents);
}
contents = await javascriptTransformer.transformData(
request,
typescriptResult.content,
true /* skipLinker */,
);

pluginOptions.sourceFileCache?.typeScriptFileCache.set(
pathToFileURL(request).href,
Expand All @@ -484,9 +478,7 @@ export function createCompilerPlugin(
// would need to be added to the key as well as a check for any change of content.
let contents = pluginOptions.sourceFileCache?.babelFileCache.get(args.path);
if (contents === undefined) {
const data = await fs.readFile(args.path, 'utf-8');
const transformedData = await transformWithBabel(args.path, data, pluginOptions);
contents = Buffer.from(transformedData, 'utf-8');
contents = await javascriptTransformer.transformFile(args.path);
pluginOptions.sourceFileCache?.babelFileCache.set(args.path, contents);
}

Expand Down Expand Up @@ -540,65 +532,6 @@ function createFileEmitter(
};
}

async function transformWithBabel(
filename: string,
data: string,
pluginOptions: CompilerPluginOptions,
): Promise<string> {
const forceAsyncTransformation =
!/[\\/][_f]?esm2015[\\/]/.test(filename) && /async\s+function\s*\*/.test(data);
const shouldLink = await requiresLinking(filename, data);
const useInputSourcemap =
pluginOptions.sourcemap &&
(!!pluginOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));

// If no additional transformations are needed, return the data directly
if (!forceAsyncTransformation && !pluginOptions.advancedOptimizations && !shouldLink) {
// Strip sourcemaps if they should not be used
return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
}

const angularPackage = /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);

const linkerPluginCreator = shouldLink
? (
await loadEsmModule<typeof import('@angular/compiler-cli/linker/babel')>(
'@angular/compiler-cli/linker/babel',
)
).createEs2015LinkerPlugin
: undefined;

const result = await transformAsync(data, {
filename,
inputSourceMap: (useInputSourcemap ? undefined : false) as undefined,
sourceMaps: pluginOptions.sourcemap ? 'inline' : false,
compact: false,
configFile: false,
babelrc: false,
browserslistConfigFile: false,
plugins: [],
presets: [
[
angularApplicationPreset,
{
angularLinker: {
shouldLink,
jitMode: false,
linkerPluginCreator,
},
forceAsyncTransformation,
optimize: pluginOptions.advancedOptimizations && {
looseEnums: angularPackage,
pureTopLevel: angularPackage,
},
},
],
],
});

return result?.code ?? data;
}

function findAffectedFiles(
builder: ts.EmitAndSemanticDiagnosticsBuilderProgram,
{ ignoreForDiagnostics, ignoreForEmit, incrementalCompilation }: NgtscProgram['compiler'],
Expand Down
@@ -0,0 +1,97 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { transformAsync } from '@babel/core';
import { readFile } from 'node:fs/promises';
import angularApplicationPreset from '../../babel/presets/application';
import { requiresLinking } from '../../babel/webpack-loader';
import { loadEsmModule } from '../../utils/load-esm';

interface JavaScriptTransformRequest {
filename: string;
data: string;
sourcemap: boolean;
thirdPartySourcemaps: boolean;
advancedOptimizations: boolean;
forceAsyncTransformation?: boolean;
skipLinker: boolean;
}

export default async function transformJavaScript(
request: JavaScriptTransformRequest,
): Promise<Uint8Array> {
request.data ??= await readFile(request.filename, 'utf-8');
const transformedData = await transformWithBabel(request);

return Buffer.from(transformedData, 'utf-8');
}

let linkerPluginCreator:
| typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin
| undefined;

async function transformWithBabel({
filename,
data,
...options
}: JavaScriptTransformRequest): Promise<string> {
const forceAsyncTransformation =
options.forceAsyncTransformation ??
(!/[\\/][_f]?esm2015[\\/]/.test(filename) && /async\s+function\s*\*/.test(data));
const shouldLink = !options.skipLinker && (await requiresLinking(filename, data));
const useInputSourcemap =
options.sourcemap &&
(!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));

// If no additional transformations are needed, return the data directly
if (!forceAsyncTransformation && !options.advancedOptimizations && !shouldLink) {
// Strip sourcemaps if they should not be used
return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
}

const angularPackage = /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);

// Lazy load the linker plugin only when linking is required
if (shouldLink) {
linkerPluginCreator ??= (
await loadEsmModule<typeof import('@angular/compiler-cli/linker/babel')>(
'@angular/compiler-cli/linker/babel',
)
).createEs2015LinkerPlugin;
}

const result = await transformAsync(data, {
filename,
inputSourceMap: (useInputSourcemap ? undefined : false) as undefined,
sourceMaps: options.sourcemap ? 'inline' : false,
compact: false,
configFile: false,
babelrc: false,
browserslistConfigFile: false,
plugins: [],
presets: [
[
angularApplicationPreset,
{
angularLinker: linkerPluginCreator && {
shouldLink,
jitMode: false,
linkerPluginCreator,
},
forceAsyncTransformation,
optimize: options.advancedOptimizations && {
looseEnums: angularPackage,
pureTopLevel: angularPackage,
},
},
],
],
});

return result?.code ?? data;
}
@@ -0,0 +1,91 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import Piscina from 'piscina';

/**
* Transformation options that should apply to all transformed files and data.
*/
export interface JavaScriptTransformerOptions {
sourcemap: boolean;
thirdPartySourcemaps?: boolean;
advancedOptimizations?: boolean;
}

/**
* A class that performs transformation of JavaScript files and raw data.
* A worker pool is used to distribute the transformation actions and allow
* parallel processing. Transformation behavior is based on the filename and
* data. Transformations may include: async downleveling, Angular linking,
* and advanced optimizations.
*/
export class JavaScriptTransformer {
#workerPool: Piscina;

constructor(private options: JavaScriptTransformerOptions, maxThreads?: number) {
this.#workerPool = new Piscina({
filename: require.resolve('./javascript-transformer-worker'),
maxThreads,
});
}

/**
* Performs JavaScript transformations on a file from the filesystem.
* If no transformations are required, the data for the original file will be returned.
* @param filename The full path to the file.
* @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result.
*/
transformFile(filename: string): Promise<Uint8Array> {
// Always send the request to a worker. Files are almost always from node modules which measn
// they may need linking. The data is also not yet available to perform most transformation checks.
return this.#workerPool.run({
filename,
...this.options,
});
}

/**
* Performs JavaScript transformations on the provided data of a file. The file does not need
* to exist on the filesystem.
* @param filename The full path of the file represented by the data.
* @param data The data of the file that should be transformed.
* @param skipLinker If true, bypass all Angular linker processing; if false, attempt linking.
* @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result.
*/
async transformData(filename: string, data: string, skipLinker: boolean): Promise<Uint8Array> {
// Perform a quick test to determine if the data needs any transformations.
// This allows directly returning the data without the worker communication overhead.
let forceAsyncTransformation;
if (skipLinker && !this.options.advancedOptimizations) {
// If the linker is being skipped and no optimizations are needed, only async transformation is left.
// This checks for async generator functions. All other async transformation is handled by esbuild.
forceAsyncTransformation = data.includes('async') && /async\s+function\s*\*/.test(data);
clydin marked this conversation as resolved.
Show resolved Hide resolved

if (!forceAsyncTransformation) {
return Buffer.from(data, 'utf-8');
}
}

return this.#workerPool.run({
filename,
data,
// Send the async check result if present to avoid rechecking in the worker
forceAsyncTransformation,
skipLinker,
...this.options,
});
}

/**
* Stops all active transformation tasks and shuts down all workers.
* @returns A void promise that resolves when closing is complete.
*/
close(): Promise<void> {
return this.#workerPool.destroy();
}
}