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",
"description": "Read buildable libraries from source instead of building them separately.",
"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,

View File

@ -21,15 +21,22 @@ import { join, normalize } from 'path';
describe('Angular Projects', () => {
let proj: string;
const app1 = uniq('app1');
const esbuildApp = uniq('esbuild-app');
const lib1 = uniq('lib1');
let app1DefaultModule: string;
let app1DefaultComponentTemplate: string;
let esbuildAppDefaultModule: string;
let esbuildAppDefaultComponentTemplate: string;
let esbuildAppDefaultProjectConfig: string;
beforeAll(() => {
proj = newProject();
runCLI(
`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(
`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(
`${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(() => {
@ -45,6 +57,12 @@ describe('Angular Projects', () => {
`${app1}/src/app/app.component.html`,
app1DefaultComponentTemplate
);
updateFile(`${esbuildApp}/src/app/app.module.ts`, esbuildAppDefaultModule);
updateFile(
`${esbuildAppDefaultComponentTemplate}/src/app/app.component.html`,
esbuildAppDefaultComponentTemplate
);
updateFile(`${esbuildApp}/project.json`, esbuildAppDefaultProjectConfig);
});
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`
);
const esbuildApp = uniq('esbuild-app');
const esbuildStandaloneApp = uniq('esbuild-app');
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(
@ -86,11 +104,12 @@ describe('Angular Projects', () => {
// check build
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/${esbuildApp}/browser/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
// influences external from this project that affect this.
const es2015BundleSize = getSize(tmpProjPath(`dist/${app1}/main.js`));
@ -101,7 +120,7 @@ describe('Angular Projects', () => {
// check unit tests
runCLI(
`run-many --target test --projects=${app1},${standaloneApp},${esbuildApp},${lib1} --parallel`
`run-many --target test --projects=${app1},${standaloneApp},${esbuildStandaloneApp},${lib1} --parallel`
);
// check e2e tests
@ -121,7 +140,7 @@ describe('Angular Projects', () => {
await killProcessAndPorts(process.pid, appPort);
const esbProcess = await runCommandUntil(
`serve ${esbuildApp} -- --port=${appPort}`,
`serve ${esbuildStandaloneApp} -- --port=${appPort}`,
(output) =>
output.includes(`Application bundle generation complete`) &&
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 () => {
// 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 buildableChildLib = uniq('buildlib2');
@ -328,6 +342,56 @@ describe('Angular Projects', () => {
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', () => {
// ARRANGE
const lib = uniq('lib');

View File

@ -1,10 +1,16 @@
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 {
joinPathFragments,
normalizePath,
parseTargetString,
readCachedProjectGraph,
type Target,
} from '@nx/devkit';
import { getRootTsConfigPath } from '@nx/js';
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 { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph';
import { relative } from 'path';
import { from } from 'rxjs';
import { combineLatest, from } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { getInstalledAngularVersionInfo } from '../../executors/utilities/angular-version-utils';
import {
loadPlugins,
type PluginSpec,
} from '../../executors/utilities/esbuild-extensions';
import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs';
import {
mergeCustomWebpackConfig,
@ -33,6 +43,7 @@ type BuildTargetOptions = {
buildLibsFromSource?: boolean;
customWebpackConfig?: { path?: string };
indexFileTransformer?: string;
plugins?: string[] | PluginSpec[];
};
export function executeDevServerBuilder(
@ -143,56 +154,66 @@ export function executeDevServerBuilder(
* builders. Since we are using a custom builder, we patch the context to
* handle `@nx/angular:*` executors.
*/
patchBuilderContext(context);
patchBuilderContext(context, !isUsingWebpackBuilder, parsedBuildTarget);
return from(import('@angular-devkit/build-angular')).pipe(
switchMap(({ executeDevServerBuilder }) =>
executeDevServerBuilder(delegateBuilderOptions, context, {
webpackConfiguration: isUsingWebpackBuilder
? async (baseWebpackConfig) => {
if (!buildLibsFromSource) {
const workspaceDependencies = dependencies
.filter((dep) => !isNpmProject(dep.node))
.map((dep) => dep.node.name);
// default for `nx run-many` is --all projects
// by passing an empty string for --projects, run-many will default to
// run the target for all projects.
// This will occur when workspaceDependencies = []
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(',')}`
)
);
return combineLatest([
from(import('@angular-devkit/build-angular')),
from(loadPlugins(buildTargetOptions.plugins, buildTargetOptions.tsConfig)),
]).pipe(
switchMap(([{ executeDevServerBuilder }, plugins]) =>
executeDevServerBuilder(
delegateBuilderOptions,
context,
{
webpackConfiguration: isUsingWebpackBuilder
? async (baseWebpackConfig) => {
if (!buildLibsFromSource) {
const workspaceDependencies = dependencies
.filter((dep) => !isNpmProject(dep.node))
.map((dep) => dep.node.name);
// default for `nx run-many` is --all projects
// by passing an empty string for --projects, run-many will default to
// run the target for all projects.
// This will occur when workspaceDependencies = []
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) {
return baseWebpackConfig;
...(pathToIndexFileTransformer
? {
indexHtml: resolveIndexHtmlTransformer(
pathToIndexFileTransformer,
buildTargetOptions.tsConfig,
context.target
),
}
return mergeCustomWebpackConfig(
baseWebpackConfig,
pathToWebpackConfig,
buildTargetOptions,
context.target
);
}
: undefined,
...(pathToIndexFileTransformer
? {
indexHtml: resolveIndexHtmlTransformer(
pathToIndexFileTransformer,
buildTargetOptions.tsConfig,
context.target
),
}
: {}),
})
: {}),
},
{
buildPlugins: plugins,
}
)
)
);
}
@ -228,7 +249,11 @@ const executorToBuilderMap = new Map<string, string>([
'@angular-devkit/build-angular:browser-esbuild',
],
]);
function patchBuilderContext(context: BuilderContext): void {
function patchBuilderContext(
context: BuilderContext,
isUsingEsbuildBuilder: boolean,
buildTarget: Target
): void {
const originalGetBuilderNameForTarget = context.getBuilderNameForTarget;
context.getBuilderNameForTarget = async (target) => {
const builderName = await originalGetBuilderNameForTarget(target);
@ -239,4 +264,35 @@ function patchBuilderContext(context: BuilderContext): void {
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 DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils';
import { type ExecutorContext, readCachedProjectGraph } from '@nx/devkit';
import { createTmpTsConfigForBuildableLibs } from './lib/buildable-libs';
import type { BuilderOutput } from '@angular-devkit/architect';
import { readCachedProjectGraph, type ExecutorContext } from '@nx/devkit';
import type { DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils';
import type { OutputFile } from 'esbuild';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { type BuilderOutput } from '@angular-devkit/architect';
import { type OutputFile } from 'esbuild';
import { loadPlugins } from '../utilities/esbuild-extensions';
import { createTmpTsConfigForBuildableLibs } from './lib/buildable-libs';
import type { EsBuildSchema } from './schema';
export default async function* esbuildExecutor(
options: EsBuildSchema,
@ -12,7 +13,11 @@ export default async function* esbuildExecutor(
) {
options.buildLibsFromSource ??= true;
const { buildLibsFromSource, ...delegateExecutorOptions } = options;
const {
buildLibsFromSource,
plugins: pluginPaths,
...delegateExecutorOptions
} = options;
let dependencies: DependentBuildableProjectNode[];
let projectGraph = context.projectGraph;
@ -28,6 +33,9 @@ export default async function* esbuildExecutor(
dependencies = foundDependencies;
delegateExecutorOptions.tsConfig = tsConfigPath;
}
const plugins = await loadPlugins(pluginPaths, options.tsConfig);
const { buildEsbuildBrowser } = await import(
'@angular-devkit/build-angular/src/builders/browser-esbuild/index'
);
@ -45,7 +53,9 @@ export default async function* esbuildExecutor(
return yield* buildEsbuildBrowser(
delegateExecutorOptions,
builderContext
builderContext,
/* infrastructureSettings */ undefined,
plugins
) as AsyncIterable<
BuilderOutput & {
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 {
buildLibsFromSource?: boolean;
plugins?: string[] | PluginSpec[];
}

View File

@ -441,6 +441,35 @@
"type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.",
"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,

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<
typeof import('@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);
}