fix(node): support custom import paths based on tsconfig when building node apps (#15154)

This commit is contained in:
Jack Hsu 2023-02-21 12:49:49 -05:00 committed by GitHub
parent 19bfd8ef6a
commit a45d52e9e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 117 additions and 53 deletions

View File

@ -20,8 +20,18 @@ describe('Node Applications + webpack', () => {
afterEach(() => cleanupProject()); afterEach(() => cleanupProject());
function addLibImport(appName: string, libName: string) { function addLibImport(appName: string, libName: string, importPath?: string) {
const content = readFile(`apps/${appName}/src/main.ts`); const content = readFile(`apps/${appName}/src/main.ts`);
if (importPath) {
updateFile(
`apps/${appName}/src/main.ts`,
`
import { ${libName} } from '${importPath}';
${content}
console.log(${libName}());
`
);
} else {
updateFile( updateFile(
`apps/${appName}/src/main.ts`, `apps/${appName}/src/main.ts`,
` `
@ -31,6 +41,7 @@ describe('Node Applications + webpack', () => {
` `
); );
} }
}
async function runE2eTests(appName: string) { async function runE2eTests(appName: string) {
process.env.PORT = '5000'; process.env.PORT = '5000';
@ -48,12 +59,14 @@ describe('Node Applications + webpack', () => {
} }
it('should generate an app using webpack', async () => { it('should generate an app using webpack', async () => {
const utilLib = uniq('util'); const testLib1 = uniq('test1');
const testLib2 = uniq('test2');
const expressApp = uniq('expressapp'); const expressApp = uniq('expressapp');
const fastifyApp = uniq('fastifyapp'); const fastifyApp = uniq('fastifyapp');
const koaApp = uniq('koaapp'); const koaApp = uniq('koaapp');
runCLI(`generate @nrwl/node:lib ${utilLib}`); runCLI(`generate @nrwl/node:lib ${testLib1}`);
runCLI(`generate @nrwl/node:lib ${testLib2} --importPath=@acme/test2`);
runCLI( runCLI(
`generate @nrwl/node:app ${expressApp} --framework=express --no-interactive` `generate @nrwl/node:app ${expressApp} --framework=express --no-interactive`
); );
@ -79,9 +92,12 @@ describe('Node Applications + webpack', () => {
// Only Fastify generates with unit tests since it supports them without additional libraries. // Only Fastify generates with unit tests since it supports them without additional libraries.
expect(() => runCLI(`lint ${fastifyApp}`)).not.toThrow(); expect(() => runCLI(`lint ${fastifyApp}`)).not.toThrow();
addLibImport(expressApp, utilLib); addLibImport(expressApp, testLib1);
addLibImport(fastifyApp, utilLib); addLibImport(expressApp, testLib2, '@acme/test2');
addLibImport(koaApp, utilLib); addLibImport(fastifyApp, testLib1);
addLibImport(fastifyApp, testLib2, '@acme/test2');
addLibImport(koaApp, testLib1);
addLibImport(koaApp, testLib2, '@acme/test2');
await runE2eTests(expressApp); await runE2eTests(expressApp);
await runE2eTests(fastifyApp); await runE2eTests(fastifyApp);

View File

@ -36,7 +36,8 @@
"dotenv": "~10.0.0", "dotenv": "~10.0.0",
"fast-glob": "3.2.7", "fast-glob": "3.2.7",
"fs-extra": "^11.1.0", "fs-extra": "^11.1.0",
"tslib": "^2.3.0" "tslib": "^2.3.0",
"tsconfig-paths": "^4.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"esbuild": "~0.17.5" "esbuild": "~0.17.5"

View File

@ -1,12 +1,12 @@
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
import * as path from 'path'; import * as path from 'path';
import { parse } from 'path'; import { join, parse } from 'path';
import { import {
ExecutorContext, ExecutorContext,
getImportPath,
joinPathFragments, joinPathFragments,
ProjectGraphProjectNode,
} from '@nrwl/devkit'; } from '@nrwl/devkit';
import { mkdirSync, writeFileSync } from 'fs'; import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { getClientEnvironment } from '../../../utils/environment-variables'; import { getClientEnvironment } from '../../../utils/environment-variables';
import { import {
@ -63,33 +63,19 @@ export function buildEsbuildOptions(
} else if (options.platform === 'node' && format === 'cjs') { } else if (options.platform === 'node' && format === 'cjs') {
// When target platform Node and target format is CJS, then also transpile workspace libs used by the app. // When target platform Node and target format is CJS, then also transpile workspace libs used by the app.
// Provide a `require` override in the main entry file so workspace libs can be loaded when running the app. // Provide a `require` override in the main entry file so workspace libs can be loaded when running the app.
const manifest: Array<{ module: string; root: string }> = []; // Manifest allows the built app to load compiled workspace libs. const paths = getTsConfigCompilerPaths(context);
const entryPointsFromProjects = getEntryPoints( const entryPointsFromProjects = getEntryPoints(
context.projectName, context.projectName,
context, context,
{ {
initialEntryPoints: entryPoints, initialEntryPoints: entryPoints,
recursive: true, recursive: true,
onProjectFilesMatched: (currProjectName) => {
manifest.push({
module: getImportPath(
context.nxJsonConfiguration.npmScope,
currProjectName
),
root: context.projectGraph.nodes[currProjectName].data.root,
});
},
} }
); );
esbuildOptions.entryPoints = [ esbuildOptions.entryPoints = [
// Write a main entry file that registers workspace libs and then calls the user-defined main. // Write a main entry file that registers workspace libs and then calls the user-defined main.
writeTmpEntryWithRequireOverrides( writeTmpEntryWithRequireOverrides(paths, outExtension, options, context),
manifest,
outExtension,
options,
context
),
...entryPointsFromProjects.map((f) => { ...entryPointsFromProjects.map((f) => {
/** /**
* Maintain same directory structure as the workspace, so that other workspace libs may be used by the project. * Maintain same directory structure as the workspace, so that other workspace libs may be used by the project.
@ -156,18 +142,14 @@ export function getOutfile(
} }
function writeTmpEntryWithRequireOverrides( function writeTmpEntryWithRequireOverrides(
manifest: Array<{ module: string; root: string }>, paths: Record<string, string[]>,
outExtension: '.cjs' | '.js' | '.mjs', outExtension: '.cjs' | '.js' | '.mjs',
options: NormalizedEsBuildExecutorOptions, options: NormalizedEsBuildExecutorOptions,
context: ExecutorContext context: ExecutorContext
): { in: string; out: string } { ): { in: string; out: string } {
const project = context.projectGraph?.nodes[context.projectName]; const project = context.projectGraph?.nodes[context.projectName];
// Write a temp main entry source that registers workspace libs. // Write a temp main entry source that registers workspace libs.
const tmpPath = path.join( const tmpPath = path.join(context.root, 'tmp', project.name);
context.root,
'tmp',
context.projectGraph?.nodes[context.projectName].name
);
mkdirSync(tmpPath, { recursive: true }); mkdirSync(tmpPath, { recursive: true });
const { name: mainFileName, dir: mainPathRelativeToDist } = path.parse( const { name: mainFileName, dir: mainPathRelativeToDist } = path.parse(
@ -180,7 +162,8 @@ function writeTmpEntryWithRequireOverrides(
writeFileSync( writeFileSync(
mainWithRequireOverridesInPath, mainWithRequireOverridesInPath,
getRegisterFileContent( getRegisterFileContent(
manifest, project,
paths,
`./${path.join( `./${path.join(
mainPathRelativeToDist, mainPathRelativeToDist,
`${mainFileName}${outExtension}` `${mainFileName}${outExtension}`
@ -208,11 +191,37 @@ function writeTmpEntryWithRequireOverrides(
}; };
} }
function getRegisterFileContent( export function getRegisterFileContent(
manifest: Array<{ module: string; root: string }>, project: ProjectGraphProjectNode,
paths: Record<string, string[]>,
mainFile: string, mainFile: string,
outExtension = '.js' outExtension = '.js'
) { ) {
// Sort by longest prefix so imports match the most specific path.
const sortedKeys = Object.keys(paths).sort(
(a: string, b: string) => getPrefixLength(b) - getPrefixLength(a)
);
const manifest: Array<{
module: string;
pattern: string;
exactMatch?: string;
}> = sortedKeys.reduce((acc, k) => {
let exactMatch: string;
// Nx generates a single path entry.
// If more sophisticated setup is needed, we can consider tsconfig-paths.
const pattern = paths[k][0];
if (/.[cm]?ts$/.test(pattern)) {
// Path specifies a single entry point e.g. "a/b/src/index.ts".
// This is the default setup.
const { dir, name } = path.parse(pattern);
exactMatch = path.join(dir, `${name}${outExtension}`);
}
acc.push({ module: k, exactMatch, pattern });
return acc;
}, []);
return ` return `
/** /**
* IMPORTANT: Do not modify this file. * IMPORTANT: Do not modify this file.
@ -227,26 +236,28 @@ const distPath = __dirname;
const manifest = ${JSON.stringify(manifest)}; const manifest = ${JSON.stringify(manifest)};
Module._resolveFilename = function(request, parent) { Module._resolveFilename = function(request, parent) {
const entry = manifest.find(x => request === x.module || request.startsWith(x.module + '/'));
let found; let found;
if (entry) { for (const entry of manifest) {
if (request === entry.module) { if (request === entry.module && entry.exactMatch) {
// Known entry paths for libraries. Add more if missing. const entry = manifest.find((x) => request === x.module || request.startsWith(x.module + "/"));
const candidates = [ const candidate = path.join(distPath, entry.exactMatch);
path.join(distPath, entry.root, 'src/index' + '${outExtension}'), if (isFile(candidate)) {
path.join(distPath, entry.root, 'src/main' + '${outExtension}'), found = candidate;
path.join(distPath, entry.root, 'index' + '${outExtension}'), break;
path.join(distPath, entry.root, 'main' + '${outExtension}') }
];
found = candidates.find(f => fs.statSync(f).isFile());
} else { } else {
const candidate = path.join(distPath, entry.root, request.replace(entry.module, '') + '${outExtension}'); const re = new RegExp(entry.module.replace(/\\*$/, "(?<rest>.*)"));
if (fs.statSync(candidate).isFile()) { const match = request.match(re);
if (match?.groups) {
const candidate = path.join(distPath, entry.pattern.replace("*", ""), match.groups.rest + ".js");
if (isFile(candidate)) {
found = candidate; found = candidate;
} }
} }
}
}
}
if (found) { if (found) {
const modifiedArguments = [found, ...[].slice.call(arguments, 1)]; const modifiedArguments = [found, ...[].slice.call(arguments, 1)];
return originalResolveFilename.apply(this, modifiedArguments); return originalResolveFilename.apply(this, modifiedArguments);
@ -255,7 +266,43 @@ Module._resolveFilename = function(request, parent) {
} }
}; };
function isFile(s) {
try {
return fs.statSync(s).isFile();
} catch (_e) {
return false;
}
}
// Call the user-defined main. // Call the user-defined main.
require('${mainFile}'); require('${mainFile}');
`; `;
} }
function getPrefixLength(pattern: string): number {
return pattern.substring(0, pattern.indexOf('*')).length;
}
function getTsConfigCompilerPaths(context: ExecutorContext): {
[key: string]: string[];
} {
const tsconfigPaths = require('tsconfig-paths');
const tsConfigResult = tsconfigPaths.loadConfig(getRootTsConfigPath(context));
if (tsConfigResult.resultType !== 'success') {
throw new Error('Cannot load tsconfig file');
}
return tsConfigResult.paths;
}
function getRootTsConfigPath(context: ExecutorContext): string | null {
for (const tsConfigName of ['tsconfig.base.json', 'tsconfig.json']) {
const tsConfigPath = join(context.root, tsConfigName);
if (existsSync(tsConfigPath)) {
return tsConfigPath;
}
}
throw new Error(
'Could not find a root tsconfig.json or tsconfig.base.json file.'
);
}