feat(angular): support providing esbuild plugins to @nx/angular:browser-esbuild (#20504)

This commit is contained in:
Leosvel Pérez Espinosa 2023-12-01 11:11:11 +01:00 committed by GitHub
parent 7cb8aead12
commit a831e262dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 356 additions and 94 deletions

View File

@ -513,6 +513,35 @@
"type": "boolean", "type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.", "description": "Read buildable libraries from source instead of building them separately.",
"default": true "default": true
},
"plugins": {
"description": "A list of ESBuild plugins.",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
},
"options": {
"type": "object",
"description": "The options to provide to the plugin.",
"properties": {},
"additionalProperties": true
}
},
"additionalProperties": false,
"required": ["path"]
},
{
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
}
]
}
} }
}, },
"additionalProperties": false, "additionalProperties": false,

View File

@ -21,15 +21,22 @@ import { join, normalize } from 'path';
describe('Angular Projects', () => { describe('Angular Projects', () => {
let proj: string; let proj: string;
const app1 = uniq('app1'); const app1 = uniq('app1');
const esbuildApp = uniq('esbuild-app');
const lib1 = uniq('lib1'); const lib1 = uniq('lib1');
let app1DefaultModule: string; let app1DefaultModule: string;
let app1DefaultComponentTemplate: string; let app1DefaultComponentTemplate: string;
let esbuildAppDefaultModule: string;
let esbuildAppDefaultComponentTemplate: string;
let esbuildAppDefaultProjectConfig: string;
beforeAll(() => { beforeAll(() => {
proj = newProject(); proj = newProject();
runCLI( runCLI(
`generate @nx/angular:app ${app1} --no-standalone --bundler=webpack --project-name-and-root-format=as-provided --no-interactive` `generate @nx/angular:app ${app1} --no-standalone --bundler=webpack --project-name-and-root-format=as-provided --no-interactive`
); );
runCLI(
`generate @nx/angular:app ${esbuildApp} --bundler=esbuild --no-standalone --project-name-and-root-format=as-provided --no-interactive`
);
runCLI( runCLI(
`generate @nx/angular:lib ${lib1} --no-standalone --add-module-spec --project-name-and-root-format=as-provided --no-interactive` `generate @nx/angular:lib ${lib1} --no-standalone --add-module-spec --project-name-and-root-format=as-provided --no-interactive`
); );
@ -37,6 +44,11 @@ describe('Angular Projects', () => {
app1DefaultComponentTemplate = readFile( app1DefaultComponentTemplate = readFile(
`${app1}/src/app/app.component.html` `${app1}/src/app/app.component.html`
); );
esbuildAppDefaultModule = readFile(`${app1}/src/app/app.module.ts`);
esbuildAppDefaultComponentTemplate = readFile(
`${esbuildApp}/src/app/app.component.html`
);
esbuildAppDefaultProjectConfig = readFile(`${esbuildApp}/project.json`);
}); });
afterEach(() => { afterEach(() => {
@ -45,6 +57,12 @@ describe('Angular Projects', () => {
`${app1}/src/app/app.component.html`, `${app1}/src/app/app.component.html`,
app1DefaultComponentTemplate app1DefaultComponentTemplate
); );
updateFile(`${esbuildApp}/src/app/app.module.ts`, esbuildAppDefaultModule);
updateFile(
`${esbuildAppDefaultComponentTemplate}/src/app/app.component.html`,
esbuildAppDefaultComponentTemplate
);
updateFile(`${esbuildApp}/project.json`, esbuildAppDefaultProjectConfig);
}); });
afterAll(() => cleanupProject()); afterAll(() => cleanupProject());
@ -55,9 +73,9 @@ describe('Angular Projects', () => {
`generate @nx/angular:app ${standaloneApp} --directory=my-dir/${standaloneApp} --bundler=webpack --project-name-and-root-format=as-provided --no-interactive` `generate @nx/angular:app ${standaloneApp} --directory=my-dir/${standaloneApp} --bundler=webpack --project-name-and-root-format=as-provided --no-interactive`
); );
const esbuildApp = uniq('esbuild-app'); const esbuildStandaloneApp = uniq('esbuild-app');
runCLI( runCLI(
`generate @nx/angular:app ${esbuildApp} --bundler=esbuild --directory=my-dir/${esbuildApp} --project-name-and-root-format=as-provided --no-interactive` `generate @nx/angular:app ${esbuildStandaloneApp} --bundler=esbuild --directory=my-dir/${esbuildStandaloneApp} --project-name-and-root-format=as-provided --no-interactive`
); );
updateFile( updateFile(
@ -86,11 +104,12 @@ describe('Angular Projects', () => {
// check build // check build
runCLI( runCLI(
`run-many --target build --projects=${app1},${standaloneApp},${esbuildApp} --parallel --prod --output-hashing none` `run-many --target build --projects=${app1},${esbuildApp},${standaloneApp},${esbuildStandaloneApp} --parallel --prod --output-hashing none`
); );
checkFilesExist(`dist/${app1}/main.js`); checkFilesExist(`dist/${app1}/main.js`);
checkFilesExist(`dist/${esbuildApp}/browser/main.js`);
checkFilesExist(`dist/my-dir/${standaloneApp}/main.js`); checkFilesExist(`dist/my-dir/${standaloneApp}/main.js`);
checkFilesExist(`dist/my-dir/${esbuildApp}/browser/main.js`); checkFilesExist(`dist/my-dir/${esbuildStandaloneApp}/browser/main.js`);
// This is a loose requirement because there are a lot of // This is a loose requirement because there are a lot of
// influences external from this project that affect this. // influences external from this project that affect this.
const es2015BundleSize = getSize(tmpProjPath(`dist/${app1}/main.js`)); const es2015BundleSize = getSize(tmpProjPath(`dist/${app1}/main.js`));
@ -101,7 +120,7 @@ describe('Angular Projects', () => {
// check unit tests // check unit tests
runCLI( runCLI(
`run-many --target test --projects=${app1},${standaloneApp},${esbuildApp},${lib1} --parallel` `run-many --target test --projects=${app1},${standaloneApp},${esbuildStandaloneApp},${lib1} --parallel`
); );
// check e2e tests // check e2e tests
@ -121,7 +140,7 @@ describe('Angular Projects', () => {
await killProcessAndPorts(process.pid, appPort); await killProcessAndPorts(process.pid, appPort);
const esbProcess = await runCommandUntil( const esbProcess = await runCommandUntil(
`serve ${esbuildApp} -- --port=${appPort}`, `serve ${esbuildStandaloneApp} -- --port=${appPort}`,
(output) => (output) =>
output.includes(`Application bundle generation complete`) && output.includes(`Application bundle generation complete`) &&
output.includes(`localhost:${appPort}`) output.includes(`localhost:${appPort}`)
@ -199,11 +218,6 @@ describe('Angular Projects', () => {
it('should build the dependent buildable lib and its child lib, as well as the app', async () => { it('should build the dependent buildable lib and its child lib, as well as the app', async () => {
// ARRANGE // ARRANGE
const esbuildApp = uniq('esbuild-app');
runCLI(
`generate @nx/angular:app ${esbuildApp} --bundler=esbuild --no-standalone --project-name-and-root-format=as-provided --no-interactive`
);
const buildableLib = uniq('buildlib1'); const buildableLib = uniq('buildlib1');
const buildableChildLib = uniq('buildlib2'); const buildableChildLib = uniq('buildlib2');
@ -328,6 +342,56 @@ describe('Angular Projects', () => {
expect(mainEsBuildBundle).toContain(`dist/${buildableLib}`); expect(mainEsBuildBundle).toContain(`dist/${buildableLib}`);
}); });
it('should support esbuild plugins', async () => {
updateFile(
`${esbuildApp}/replace-text.plugin.mjs`,
`const replaceTextPlugin = {
name: 'replace-text',
setup(build) {
const options = build.initialOptions;
options.define.BUILD_DEFINED = '"Value was provided at build time"';
},
};
export default replaceTextPlugin;`
);
updateJson(join(esbuildApp, 'project.json'), (config) => {
config.targets.build.executor = '@nx/angular:browser-esbuild';
config.targets.build.options = {
...config.targets.build.options,
outputPath: `dist/${esbuildApp}`,
main: config.targets.build.options.browser,
browser: undefined,
plugins: [`${esbuildApp}/replace-text.plugin.mjs`],
};
return config;
});
updateFile(
`${esbuildApp}/src/app/app.component.ts`,
`import { Component } from '@angular/core';
declare const BUILD_DEFINED: string;
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent {
title = 'esbuild-app';
buildDefined = BUILD_DEFINED;
}`
);
// ACT
runCLI(`build ${esbuildApp} --configuration=development`);
// ASSERT
const mainBundle = readFile(`dist/${esbuildApp}/main.js`);
expect(mainBundle).toContain(
'this.buildDefined = "Value was provided at build time";'
);
});
it('should build publishable libs successfully', () => { it('should build publishable libs successfully', () => {
// ARRANGE // ARRANGE
const lib = uniq('lib'); const lib = uniq('lib');

View File

@ -1,10 +1,16 @@
import type { BuilderContext } from '@angular-devkit/architect'; import type { BuilderContext } from '@angular-devkit/architect';
import type { DevServerBuilderOptions } from '@angular-devkit/build-angular'; import type {
ApplicationBuilderOptions,
BrowserBuilderOptions,
DevServerBuilderOptions,
} from '@angular-devkit/build-angular';
import type { Schema as BrowserEsbuildBuilderOptions } from '@angular-devkit/build-angular/src/builders/browser-esbuild/schema';
import { import {
joinPathFragments, joinPathFragments,
normalizePath, normalizePath,
parseTargetString, parseTargetString,
readCachedProjectGraph, readCachedProjectGraph,
type Target,
} from '@nx/devkit'; } from '@nx/devkit';
import { getRootTsConfigPath } from '@nx/js'; import { getRootTsConfigPath } from '@nx/js';
import type { DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils'; import type { DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils';
@ -13,9 +19,13 @@ import { existsSync } from 'fs';
import { isNpmProject } from 'nx/src/project-graph/operators'; import { isNpmProject } from 'nx/src/project-graph/operators';
import { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph'; import { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph';
import { relative } from 'path'; import { relative } from 'path';
import { from } from 'rxjs'; import { combineLatest, from } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { getInstalledAngularVersionInfo } from '../../executors/utilities/angular-version-utils'; import { getInstalledAngularVersionInfo } from '../../executors/utilities/angular-version-utils';
import {
loadPlugins,
type PluginSpec,
} from '../../executors/utilities/esbuild-extensions';
import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs'; import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs';
import { import {
mergeCustomWebpackConfig, mergeCustomWebpackConfig,
@ -33,6 +43,7 @@ type BuildTargetOptions = {
buildLibsFromSource?: boolean; buildLibsFromSource?: boolean;
customWebpackConfig?: { path?: string }; customWebpackConfig?: { path?: string };
indexFileTransformer?: string; indexFileTransformer?: string;
plugins?: string[] | PluginSpec[];
}; };
export function executeDevServerBuilder( export function executeDevServerBuilder(
@ -143,56 +154,66 @@ export function executeDevServerBuilder(
* builders. Since we are using a custom builder, we patch the context to * builders. Since we are using a custom builder, we patch the context to
* handle `@nx/angular:*` executors. * handle `@nx/angular:*` executors.
*/ */
patchBuilderContext(context); patchBuilderContext(context, !isUsingWebpackBuilder, parsedBuildTarget);
return from(import('@angular-devkit/build-angular')).pipe( return combineLatest([
switchMap(({ executeDevServerBuilder }) => from(import('@angular-devkit/build-angular')),
executeDevServerBuilder(delegateBuilderOptions, context, { from(loadPlugins(buildTargetOptions.plugins, buildTargetOptions.tsConfig)),
webpackConfiguration: isUsingWebpackBuilder ]).pipe(
? async (baseWebpackConfig) => { switchMap(([{ executeDevServerBuilder }, plugins]) =>
if (!buildLibsFromSource) { executeDevServerBuilder(
const workspaceDependencies = dependencies delegateBuilderOptions,
.filter((dep) => !isNpmProject(dep.node)) context,
.map((dep) => dep.node.name); {
// default for `nx run-many` is --all projects webpackConfiguration: isUsingWebpackBuilder
// by passing an empty string for --projects, run-many will default to ? async (baseWebpackConfig) => {
// run the target for all projects. if (!buildLibsFromSource) {
// This will occur when workspaceDependencies = [] const workspaceDependencies = dependencies
if (workspaceDependencies.length > 0) { .filter((dep) => !isNpmProject(dep.node))
baseWebpackConfig.plugins.push( .map((dep) => dep.node.name);
// @ts-expect-error - difference between angular and webpack plugin definitions bc of webpack versions // default for `nx run-many` is --all projects
new WebpackNxBuildCoordinationPlugin( // by passing an empty string for --projects, run-many will default to
`nx run-many --target=${ // run the target for all projects.
parsedBuildTarget.target // This will occur when workspaceDependencies = []
} --projects=${workspaceDependencies.join(',')}` if (workspaceDependencies.length > 0) {
) baseWebpackConfig.plugins.push(
); // @ts-expect-error - difference between angular and webpack plugin definitions bc of webpack versions
new WebpackNxBuildCoordinationPlugin(
`nx run-many --target=${
parsedBuildTarget.target
} --projects=${workspaceDependencies.join(',')}`
)
);
}
} }
if (!pathToWebpackConfig) {
return baseWebpackConfig;
}
return mergeCustomWebpackConfig(
baseWebpackConfig,
pathToWebpackConfig,
buildTargetOptions,
context.target
);
} }
: undefined,
if (!pathToWebpackConfig) { ...(pathToIndexFileTransformer
return baseWebpackConfig; ? {
indexHtml: resolveIndexHtmlTransformer(
pathToIndexFileTransformer,
buildTargetOptions.tsConfig,
context.target
),
} }
: {}),
return mergeCustomWebpackConfig( },
baseWebpackConfig, {
pathToWebpackConfig, buildPlugins: plugins,
buildTargetOptions, }
context.target )
);
}
: undefined,
...(pathToIndexFileTransformer
? {
indexHtml: resolveIndexHtmlTransformer(
pathToIndexFileTransformer,
buildTargetOptions.tsConfig,
context.target
),
}
: {}),
})
) )
); );
} }
@ -228,7 +249,11 @@ const executorToBuilderMap = new Map<string, string>([
'@angular-devkit/build-angular:browser-esbuild', '@angular-devkit/build-angular:browser-esbuild',
], ],
]); ]);
function patchBuilderContext(context: BuilderContext): void { function patchBuilderContext(
context: BuilderContext,
isUsingEsbuildBuilder: boolean,
buildTarget: Target
): void {
const originalGetBuilderNameForTarget = context.getBuilderNameForTarget; const originalGetBuilderNameForTarget = context.getBuilderNameForTarget;
context.getBuilderNameForTarget = async (target) => { context.getBuilderNameForTarget = async (target) => {
const builderName = await originalGetBuilderNameForTarget(target); const builderName = await originalGetBuilderNameForTarget(target);
@ -239,4 +264,35 @@ function patchBuilderContext(context: BuilderContext): void {
return builderName; return builderName;
}; };
if (isUsingEsbuildBuilder) {
const originalGetTargetOptions = context.getTargetOptions;
context.getTargetOptions = async (target) => {
const options = await originalGetTargetOptions(target);
if (
target.project === buildTarget.project &&
target.target === buildTarget.target &&
target.configuration === buildTarget.configuration
) {
cleanBuildTargetOptions(options);
}
return options;
};
}
}
function cleanBuildTargetOptions(
options: any
):
| ApplicationBuilderOptions
| BrowserBuilderOptions
| BrowserEsbuildBuilderOptions {
delete options.buildLibsFromSource;
delete options.customWebpackConfig;
delete options.indexFileTransformer;
delete options.plugins;
return options;
} }

View File

@ -1,10 +1,11 @@
import { type EsBuildSchema } from './schema'; import type { BuilderOutput } from '@angular-devkit/architect';
import { type DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils'; import { readCachedProjectGraph, type ExecutorContext } from '@nx/devkit';
import { type ExecutorContext, readCachedProjectGraph } from '@nx/devkit'; import type { DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils';
import { createTmpTsConfigForBuildableLibs } from './lib/buildable-libs'; import type { OutputFile } from 'esbuild';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter'; import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { type BuilderOutput } from '@angular-devkit/architect'; import { loadPlugins } from '../utilities/esbuild-extensions';
import { type OutputFile } from 'esbuild'; import { createTmpTsConfigForBuildableLibs } from './lib/buildable-libs';
import type { EsBuildSchema } from './schema';
export default async function* esbuildExecutor( export default async function* esbuildExecutor(
options: EsBuildSchema, options: EsBuildSchema,
@ -12,7 +13,11 @@ export default async function* esbuildExecutor(
) { ) {
options.buildLibsFromSource ??= true; options.buildLibsFromSource ??= true;
const { buildLibsFromSource, ...delegateExecutorOptions } = options; const {
buildLibsFromSource,
plugins: pluginPaths,
...delegateExecutorOptions
} = options;
let dependencies: DependentBuildableProjectNode[]; let dependencies: DependentBuildableProjectNode[];
let projectGraph = context.projectGraph; let projectGraph = context.projectGraph;
@ -28,6 +33,9 @@ export default async function* esbuildExecutor(
dependencies = foundDependencies; dependencies = foundDependencies;
delegateExecutorOptions.tsConfig = tsConfigPath; delegateExecutorOptions.tsConfig = tsConfigPath;
} }
const plugins = await loadPlugins(pluginPaths, options.tsConfig);
const { buildEsbuildBrowser } = await import( const { buildEsbuildBrowser } = await import(
'@angular-devkit/build-angular/src/builders/browser-esbuild/index' '@angular-devkit/build-angular/src/builders/browser-esbuild/index'
); );
@ -45,7 +53,9 @@ export default async function* esbuildExecutor(
return yield* buildEsbuildBrowser( return yield* buildEsbuildBrowser(
delegateExecutorOptions, delegateExecutorOptions,
builderContext builderContext,
/* infrastructureSettings */ undefined,
plugins
) as AsyncIterable< ) as AsyncIterable<
BuilderOutput & { BuilderOutput & {
outputFiles?: OutputFile[]; outputFiles?: OutputFile[];

View File

@ -1,5 +1,7 @@
import { Schema } from '@angular-devkit/build-angular/src/builders/browser-esbuild/schema'; import type { Schema } from '@angular-devkit/build-angular/src/builders/browser-esbuild/schema';
import type { PluginSpec } from '../utilities/esbuild-extensions';
export interface EsBuildSchema extends Schema { export interface EsBuildSchema extends Schema {
buildLibsFromSource?: boolean; buildLibsFromSource?: boolean;
plugins?: string[] | PluginSpec[];
} }

View File

@ -441,6 +441,35 @@
"type": "boolean", "type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.", "description": "Read buildable libraries from source instead of building them separately.",
"default": true "default": true
},
"plugins": {
"description": "A list of ESBuild plugins.",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
},
"options": {
"type": "object",
"description": "The options to provide to the plugin.",
"properties": {},
"additionalProperties": true
}
},
"additionalProperties": false,
"required": ["path"]
},
{
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
}
]
}
} }
}, },
"additionalProperties": false, "additionalProperties": false,

View File

@ -0,0 +1,41 @@
import { registerTsProject } from '@nx/js/src/internal';
import type { Plugin } from 'esbuild';
import { loadModule } from './module-loader';
export type PluginSpec = {
path: string;
options: any;
};
export async function loadPlugins(
plugins: string[] | PluginSpec[] | undefined,
tsConfig: string
): Promise<Plugin[]> {
if (!plugins?.length) {
return [];
}
const cleanupTranspiler = registerTsProject(tsConfig);
try {
return await Promise.all(
plugins.map((plugin: string | PluginSpec) => loadPlugin(plugin))
);
} finally {
cleanupTranspiler();
}
}
async function loadPlugin(pluginSpec: string | PluginSpec): Promise<Plugin> {
const pluginPath =
typeof pluginSpec === 'string' ? pluginSpec : pluginSpec.path;
let plugin = await loadModule(pluginPath);
if (typeof plugin === 'function') {
plugin =
typeof pluginSpec === 'object' ? plugin(pluginSpec.options) : plugin();
}
return plugin;
}

View File

@ -0,0 +1,55 @@
import { extname } from 'path';
import { pathToFileURL } from 'node:url';
export async function loadModule<T = any>(path: string): Promise<T> {
switch (extname(path)) {
case '.mjs': {
const result = await loadEsmModule(pathToFileURL(path));
return (result as { default: T }).default ?? (result as T);
}
case '.cjs': {
const result = require(path);
return result.default ?? result;
}
default:
// it can be CommonJS or ESM, try both
try {
const result = require(path);
return result.default ?? result;
} catch (e: any) {
if (e.code === 'ERR_REQUIRE_ESM') {
const result = await loadEsmModule(pathToFileURL(path));
return (result as { default: T }).default ?? (result as T);
}
throw e;
}
}
}
/**
* Lazily compiled dynamic import loader function.
*/
let load: (<T>(modulePath: string | URL) => Promise<T>) | undefined;
/**
* This uses a dynamic import to load a module which may be ESM.
* CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript
* will currently, unconditionally downlevel dynamic import into a require call.
* require calls cannot load ESM code and will result in a runtime error. To workaround
* this, a Function constructor is used to prevent TypeScript from changing the dynamic import.
* Once TypeScript provides support for keeping the dynamic import this workaround can
* be dropped.
*
* @param modulePath The path of the module to load.
* @returns A Promise that resolves to the dynamically imported module.
*/
export function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
load ??= new Function('modulePath', `return import(modulePath);`) as Exclude<
typeof load,
undefined
>;
return load(modulePath);
}

View File

@ -1,31 +1,7 @@
import { loadEsmModule } from './module-loader';
export function ngCompilerCli(): Promise< export function ngCompilerCli(): Promise<
typeof import('@angular/compiler-cli') typeof import('@angular/compiler-cli')
> { > {
return loadEsmModule('@angular/compiler-cli'); return loadEsmModule('@angular/compiler-cli');
} }
/**
* Lazily compiled dynamic import loader function.
*/
let load: (<T>(modulePath: string | URL) => Promise<T>) | undefined;
/**
* This uses a dynamic import to load a module which may be ESM.
* CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript
* will currently, unconditionally downlevel dynamic import into a require call.
* require calls cannot load ESM code and will result in a runtime error. To workaround
* this, a Function constructor is used to prevent TypeScript from changing the dynamic import.
* Once TypeScript provides support for keeping the dynamic import this workaround can
* be dropped.
*
* @param modulePath The path of the module to load.
* @returns A Promise that resolves to the dynamically imported module.
*/
export function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
load ??= new Function('modulePath', `return import(modulePath);`) as Exclude<
typeof load,
undefined
>;
return load(modulePath);
}