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

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

Merged
merged 1 commit into from
Feb 21, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions e2e/node/src/node-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,27 @@ describe('Node Applications + webpack', () => {

afterEach(() => cleanupProject());

function addLibImport(appName: string, libName: string) {
function addLibImport(appName: string, libName: string, importPath?: string) {
const content = readFile(`apps/${appName}/src/main.ts`);
updateFile(
`apps/${appName}/src/main.ts`,
if (importPath) {
updateFile(
`apps/${appName}/src/main.ts`,
`
import { ${libName} } from '${importPath}';
${content}
console.log(${libName}());
`
);
} else {
updateFile(
`apps/${appName}/src/main.ts`,
`
import { ${libName} } from '@${proj}/${libName}';
${content}
console.log(${libName}());
`
);
);
}
}

async function runE2eTests(appName: string) {
Expand All @@ -48,12 +59,14 @@ describe('Node Applications + webpack', () => {
}

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 fastifyApp = uniq('fastifyapp');
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(
`generate @nrwl/node:app ${expressApp} --framework=express --no-interactive`
);
Expand All @@ -79,9 +92,12 @@ describe('Node Applications + webpack', () => {
// Only Fastify generates with unit tests since it supports them without additional libraries.
expect(() => runCLI(`lint ${fastifyApp}`)).not.toThrow();

addLibImport(expressApp, utilLib);
addLibImport(fastifyApp, utilLib);
addLibImport(koaApp, utilLib);
addLibImport(expressApp, testLib1);
addLibImport(expressApp, testLib2, '@acme/test2');
addLibImport(fastifyApp, testLib1);
addLibImport(fastifyApp, testLib2, '@acme/test2');
addLibImport(koaApp, testLib1);
addLibImport(koaApp, testLib2, '@acme/test2');

await runE2eTests(expressApp);
await runE2eTests(fastifyApp);
Expand Down
3 changes: 2 additions & 1 deletion packages/esbuild/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"dotenv": "~10.0.0",
"fast-glob": "3.2.7",
"fs-extra": "^11.1.0",
"tslib": "^2.3.0"
"tslib": "^2.3.0",
"tsconfig-paths": "^4.1.2"
},
"peerDependencies": {
"esbuild": "~0.17.5"
Expand Down
133 changes: 90 additions & 43 deletions packages/esbuild/src/executors/esbuild/lib/build-esbuild-options.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as esbuild from 'esbuild';
import * as path from 'path';
import { parse } from 'path';
import { join, parse } from 'path';
import {
ExecutorContext,
getImportPath,
joinPathFragments,
ProjectGraphProjectNode,
} from '@nrwl/devkit';
import { mkdirSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, writeFileSync } from 'fs';

import { getClientEnvironment } from '../../../utils/environment-variables';
import {
Expand Down Expand Up @@ -63,33 +63,19 @@ export function buildEsbuildOptions(
} 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.
// 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(
context.projectName,
context,
{
initialEntryPoints: entryPoints,
recursive: true,
onProjectFilesMatched: (currProjectName) => {
manifest.push({
module: getImportPath(
context.nxJsonConfiguration.npmScope,
currProjectName
),
root: context.projectGraph.nodes[currProjectName].data.root,
});
},
}
);

esbuildOptions.entryPoints = [
// Write a main entry file that registers workspace libs and then calls the user-defined main.
writeTmpEntryWithRequireOverrides(
manifest,
outExtension,
options,
context
),
writeTmpEntryWithRequireOverrides(paths, outExtension, options, context),
...entryPointsFromProjects.map((f) => {
/**
* Maintain same directory structure as the workspace, so that other workspace libs may be used by the project.
Expand Down Expand Up @@ -156,18 +142,14 @@ export function getOutfile(
}

function writeTmpEntryWithRequireOverrides(
manifest: Array<{ module: string; root: string }>,
paths: Record<string, string[]>,
outExtension: '.cjs' | '.js' | '.mjs',
options: NormalizedEsBuildExecutorOptions,
context: ExecutorContext
): { in: string; out: string } {
const project = context.projectGraph?.nodes[context.projectName];
// Write a temp main entry source that registers workspace libs.
const tmpPath = path.join(
context.root,
'tmp',
context.projectGraph?.nodes[context.projectName].name
);
const tmpPath = path.join(context.root, 'tmp', project.name);
mkdirSync(tmpPath, { recursive: true });

const { name: mainFileName, dir: mainPathRelativeToDist } = path.parse(
Expand All @@ -180,7 +162,8 @@ function writeTmpEntryWithRequireOverrides(
writeFileSync(
mainWithRequireOverridesInPath,
getRegisterFileContent(
manifest,
project,
paths,
`./${path.join(
mainPathRelativeToDist,
`${mainFileName}${outExtension}`
Expand Down Expand Up @@ -208,11 +191,37 @@ function writeTmpEntryWithRequireOverrides(
};
}

function getRegisterFileContent(
manifest: Array<{ module: string; root: string }>,
export function getRegisterFileContent(
project: ProjectGraphProjectNode,
paths: Record<string, string[]>,
mainFile: string,
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 `
/**
* IMPORTANT: Do not modify this file.
Expand All @@ -227,26 +236,28 @@ const distPath = __dirname;
const manifest = ${JSON.stringify(manifest)};

Module._resolveFilename = function(request, parent) {
const entry = manifest.find(x => request === x.module || request.startsWith(x.module + '/'));
let found;
if (entry) {
if (request === entry.module) {
// Known entry paths for libraries. Add more if missing.
const candidates = [
path.join(distPath, entry.root, 'src/index' + '${outExtension}'),
path.join(distPath, entry.root, 'src/main' + '${outExtension}'),
path.join(distPath, entry.root, 'index' + '${outExtension}'),
path.join(distPath, entry.root, 'main' + '${outExtension}')
];
found = candidates.find(f => fs.statSync(f).isFile());
} else {
const candidate = path.join(distPath, entry.root, request.replace(entry.module, '') + '${outExtension}');
if (fs.statSync(candidate).isFile()) {
for (const entry of manifest) {
if (request === entry.module && entry.exactMatch) {
const entry = manifest.find((x) => request === x.module || request.startsWith(x.module + "/"));
const candidate = path.join(distPath, entry.exactMatch);
if (isFile(candidate)) {
found = candidate;
break;
}
} else {
const re = new RegExp(entry.module.replace(/\\*$/, "(?<rest>.*)"));
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;
}
}

}
}

if (found) {
const modifiedArguments = [found, ...[].slice.call(arguments, 1)];
return originalResolveFilename.apply(this, modifiedArguments);
Expand All @@ -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.
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.'
);
}