@anatine/esbuildnx
Version:
Esbuild plugin for Nx
440 lines (406 loc) • 12.9 kB
text/typescript
import { BuildExecutorSchema } from './schema';
import { ExecutorContext } from '@nrwl/devkit';
import { normalizeBuildOptions } from '../../utils/normalize-options';
import { pathExistsSync } from 'fs-extra';
import { readJsonFile } from '@nrwl/workspace';
import { build, BuildFailure, BuildOptions, BuildResult } from 'esbuild';
import { spawn } from 'child_process';
import { esbuildDecorators } from '@anatine/esbuild-decorators';
import { gray, green, red, yellow } from 'chalk';
import watch from 'node-watch';
import { Observable, OperatorFunction, Subject, zip } from 'rxjs';
import { buffer, delay, filter, map, share } from 'rxjs/operators';
import { eachValueFrom } from 'rxjs-for-await';
import { format } from 'date-fns';
// import { exportDiagnostics } from '../../utils/print-diagnostics';
import { inspect } from 'util';
import { copyPackages, getPackagesToCopy } from '../../utils/walk-packages';
import { copyAssets } from '../../utils/assets';
import { OUTFILE_NAME } from '../../utils/constants';
import { NodeBuildEvent } from '@nrwl/node/src/executors/build/build.impl';
export function buildExecutor(
rawOptions: BuildExecutorSchema,
context: ExecutorContext
): AsyncIterableIterator<NodeBuildEvent> {
const { sourceRoot, root } = context.workspace.projects[context.projectName];
if (!sourceRoot) {
throw new Error(`${context.projectName} does not have a sourceRoot.`);
}
if (!root) {
throw new Error(`${context.projectName} does not have a root.`);
}
// Eventually, it would be great to expose more esbuild settings on command line.
// For now, the app root directory can utilize an esbuild.json file for build API settings
// https://esbuild.github.io/api/#build-api
const esBuildExists = pathExistsSync(`${root}/esbuild.json`);
const packageExists = pathExistsSync(`${root}/package.json`);
const esbuildConfig: BuildOptions = esBuildExists
? readJsonFile<BuildOptions>(`${root}/esbuild.json`)
: { external: [] };
const projectPackage = packageExists
? readJsonFile(`${root}/package.json`)
: {};
const options = normalizeBuildOptions(
rawOptions,
esbuildConfig,
context.root,
sourceRoot,
root
);
const outdir = `${options.outputPath}`;
const outfile = `${outdir}/${OUTFILE_NAME}`;
const watchDir = `${options.root}/${options.sourceRoot}`;
const packages = packageExists
? Object.keys(projectPackage.dependencies)
: [];
esbuildConfig.external = [...packages, ...(esbuildConfig.external || [])];
const esbuildOptions: BuildOptions = {
logLevel: 'silent',
platform: 'node',
bundle: options.bundle || true,
sourcemap: 'external',
charset: 'utf8',
color: true,
conditions: options.watch ? ['development'] : ['production'],
watch: options.watch || false,
absWorkingDir: options.root,
plugins: [
esbuildDecorators({
cwd: options.root,
}),
],
// banner: {
// js: '// Compiled by esbuildnx ',
// },
tsconfig: options.tsConfig,
entryPoints: [options.main],
outdir,
// outfile,
...esbuildConfig,
incremental: options.watch || false,
};
let buildCounter = 1;
const buildSubscriber = runBuild(esbuildOptions, watchDir).pipe(
map(({ buildResult, buildFailure }) => {
let message = '';
const timeString = format(new Date(), 'h:mm:ss a');
const count = gray(`[${buildCounter}]`);
const prefix = `esbuild ${count} ${timeString}`;
// const warnings: string[] = [];
if (buildResult?.warnings.length > 0) {
let warningMessage = yellow(`${prefix} - Warnings:`);
buildResult?.warnings.forEach((warning) => {
warningMessage += `\n ${yellow(warning.location.file)}(${
warning.location.line
},${warning.location.column}):`;
warningMessage += ` ${warning.location.lineText.trim()}`;
warningMessage += gray(`\n ${warning.text}\n`);
});
// console.log(warningMessage);
message += warningMessage;
}
if (buildFailure) {
// console.log(red(`\nEsbuild Error ${count}`));
// console.error(stats.buildFailure);
message += red(`Esbuild Error ${count}`);
message += buildFailure;
} else if (buildResult?.warnings.length > 0) {
message += green(
`${prefix} - Build finished with ${yellow(
buildResult?.warnings.length
)} warnings. \n`
);
} else {
message += green(`${prefix} - Build finished \n`);
}
buildCounter++;
return {
success: !buildFailure,
message,
};
})
);
let typeCounter = 1;
const tscBufferTrigger = new Subject<boolean>();
const tscSubscriber = runTsc({
tsconfigPath: options.tsConfig,
watch: options.watch || !!esbuildOptions.watch,
root: options.root,
useGlobal: false,
}).pipe(
map(({ info, error, end }) => {
let message = '';
let hasErrors = Boolean(error);
const count = gray(`[${typeCounter}]`);
const prefix = `tsc ${count}`;
if (error) {
message += red(`${prefix} ${error.replace(/\n/g, '')} \n`);
} else if (info) {
if (info.match(/Found\s\d*\serror/)) {
if (info.includes('Found 0 errors')) {
message += green(`${prefix} ${info.replace(/\n/g, '')} \n`);
} else {
hasErrors = true;
message += yellow(`${prefix} ${info.replace(/\n/g, '')} \n`);
}
tscBufferTrigger.next(true);
} else {
message += green(`${prefix} ${info.replace(/\n/g, '')} \n`);
}
}
return { info, error, end, message, hasErrors };
}),
bufferUntil(({ info }) => !!info?.match(/Found\s\d*\serror/)),
// bufferUntil(({ info }) => true),
map((values) => {
typeCounter++;
let message = '';
values.forEach((value) => (message += value.message));
// console.log(message);
return {
success: !values.find((value) => value.hasErrors),
message,
};
})
);
const packageCopySubscriber = runCopyPackages(
process.cwd(),
options.outputPath,
esbuildOptions.external
).pipe(
map((result) => {
const message = result.error ?? result.copyResult;
return {
success: result.success,
message,
};
})
);
const assetCopySubscriber = runCopyAssets(
options.assets,
'',
options.outputPath
).pipe(map((result) => result));
// exportDiagnostics(
// `OUTPUT_LOG.ts`,
// `const output = ${inspect(
// {
// cwd: process.cwd(),
// options,
// rawOptions,
// context,
// projGraph,
// workspace,
// },
// false,
// 10
// )}`
// );
if (options.watch) {
return eachValueFrom(
zip(buildSubscriber, tscSubscriber).pipe(
map(([buildResults, tscResults]) => {
// console.log('\x1Bc');
console.log(tscResults.message);
console.log(buildResults.message);
return {
success: buildResults?.success && tscResults?.success,
outfile,
};
})
)
);
}
return eachValueFrom(
zip(
buildSubscriber,
tscSubscriber,
packageCopySubscriber,
assetCopySubscriber
).pipe(
map(
([buildResults, tscResults, packageCopyResults, assetCopyResults]) => {
// console.log('\x1Bc');
console.log(tscResults.message);
console.log(buildResults.message);
if (packageCopyResults.message.length !== 0) {
console.log(
`Copied node_modules: ${inspect(
packageCopyResults.message,
false,
10,
true
)}`
);
}
if (assetCopyResults.error) {
console.error(`Error copying assets: ${assetCopyResults.error}`);
}
return {
success:
buildResults?.success &&
tscResults?.success &&
packageCopyResults.success &&
assetCopyResults.success,
outfile,
};
}
)
)
);
}
interface RunBuildResponse {
buildResult: BuildResult | null;
buildFailure: BuildFailure | null;
}
function runBuild(
options: BuildOptions,
watchDir?: string
): Observable<RunBuildResponse> {
return new Observable<RunBuildResponse>((subscriber) => {
const cwd = watchDir || options.absWorkingDir || process.cwd();
// We will use the org watch settings with node-watch for better refresh performance
const { watch: buildWatch, ...opts } = options;
build(opts)
.then((buildResult) => {
subscriber.next({ buildResult, buildFailure: null });
// Helper to send back data for watch events & supporting existing esbuild settings
const watchNext = ({ buildFailure, buildResult }: RunBuildResponse) => {
subscriber.next({ buildFailure, buildResult });
if (typeof buildWatch === 'object' && buildWatch.onRebuild) {
buildWatch.onRebuild(buildFailure, buildResult);
}
};
// When in watch mode, it will continue to report back
if (buildWatch) {
watch(cwd, { recursive: true }, () => {
buildResult
.rebuild()
.then((watchResult) => {
watchNext({
buildFailure: null,
buildResult: watchResult,
});
})
.catch((watchFailure: BuildFailure) => {
watchNext({
buildFailure: watchFailure,
buildResult: null,
});
});
});
} else {
subscriber.complete();
}
})
.catch((buildFailure: BuildFailure) => {
subscriber.next({ buildResult: null, buildFailure });
subscriber.complete();
});
});
}
interface RunTscOptions {
tsconfigPath: string;
watch?: boolean;
root?: string;
useGlobal?: boolean;
}
function runTsc({ tsconfigPath, watch, root, useGlobal }: RunTscOptions) {
return new Observable<{
info?: string;
error?: string;
tscError?: Error;
end?: string;
}>((subscriber) => {
// Build command
const modeModulesPath = useGlobal
? ''
: (root ? root + '/' : './') + 'node_modules/typescript/bin/';
const command = `${modeModulesPath}tsc`;
// Build arguments
const args: string[] = ['--noEmit']; // --noEmit so as to not save out data
if (watch) {
args.push('-w');
}
args.push('-p');
args.push(tsconfigPath);
let errorCount = 0;
// Run command
const child = spawn(command, args, { shell: true });
child.stdout.on('data', (data) => {
const decoded = data.toString();
// eslint-disable-next-line no-control-regex
if (decoded.match(/\x1Bc/g)) return;
if (decoded.includes('): error T')) {
errorCount++;
subscriber.next({ error: decoded });
} else {
subscriber.next({ info: decoded });
}
});
child.stderr.on('error', (tscError) => {
subscriber.next({ tscError });
});
child.stdout.on('end', () => {
subscriber.next({
info: `Type check complete. Found ${errorCount} errors`,
});
});
});
}
interface RunCopyPackagesResponse {
copyResult: string[];
success: boolean;
error?: any;
}
function runCopyPackages(
root: string,
destination: string,
external: string[] = []
) {
return new Observable<RunCopyPackagesResponse>((subscriber) => {
getPackagesToCopy(root, external)
.then((modules) => copyPackages(root, destination, modules))
.then((directories) => {
subscriber.next({
copyResult: directories ?? [],
success: true,
});
})
.catch((error) => {
subscriber.next({
copyResult: [],
success: false,
error,
});
});
});
}
interface RunCopyAssetsResponse {
success: boolean;
error?: string;
}
function runCopyAssets(assets: string[], root: string, destination: string) {
return new Observable<RunCopyAssetsResponse>((subscriber) => {
copyAssets(assets, root, destination)
.then((response) => {
subscriber.next(response);
})
.catch((error) => {
subscriber.next({
success: false,
error,
});
});
});
}
function bufferUntil<T>(
predicate: (value: T) => boolean
): OperatorFunction<T, T[]> {
return function (source) {
const share$ = source.pipe(share());
const until$ = share$.pipe(filter(predicate), delay(0));
return share$.pipe(buffer(until$));
};
}
export default buildExecutor;