fix(bundling): fix esbuild to work with ts project references (#30230)
<!-- Please make sure you have read the submission guidelines before posting an PR --> <!-- https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr --> <!-- Please make sure that your commit message follows our format --> <!-- Example: `fix(nx): must begin with lowercase` --> <!-- If this is a particularly complex change or feature addition, you can request a dedicated Nx release for this pull request branch. Mention someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they will confirm if the PR warrants its own release for testing purposes, and generate it for you if appropriate. --> ## Current Behavior <!-- This is the behavior we have today --> If we are using `esbuild` as our bundler and ts project references (`--workspaces`) local libraries are not building are not resolved in the build artifacts. ## Expected Behavior <!-- This is the behavior we should expect with the changes in this PR --> When using ts project references with esbuild all types libraries (buildable / non-buildable) should work out of the box. ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes #
This commit is contained in:
parent
1c323131f8
commit
7da48d6471
90
e2e/node/src/node-ts-solution-esbuild.test.ts
Normal file
90
e2e/node/src/node-ts-solution-esbuild.test.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { names } from '@nx/devkit';
|
||||
import {
|
||||
cleanupProject,
|
||||
getPackageManagerCommand,
|
||||
getSelectedPackageManager,
|
||||
newProject,
|
||||
readFile,
|
||||
runCLI,
|
||||
runCommand,
|
||||
uniq,
|
||||
updateFile,
|
||||
updateJson,
|
||||
} from '@nx/e2e/utils';
|
||||
|
||||
let originalEnvPort;
|
||||
|
||||
describe('Node Esbuild Applications', () => {
|
||||
beforeAll(() => {
|
||||
originalEnvPort = process.env.PORT;
|
||||
newProject({
|
||||
preset: 'ts',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.PORT = originalEnvPort;
|
||||
cleanupProject();
|
||||
});
|
||||
|
||||
it('it should generate an app that cosumes a non-buildable ts library', () => {
|
||||
const nodeapp = uniq('nodeapp');
|
||||
const lib = uniq('lib');
|
||||
const port = getRandomPort();
|
||||
process.env.PORT = `${port}`;
|
||||
|
||||
runCLI(
|
||||
`generate @nx/node:app apps/${nodeapp} --port=${port} --bundler=esbuild --framework=fastify --no-interactive`
|
||||
);
|
||||
|
||||
runCLI(
|
||||
`generate @nx/js:lib packages/${lib} --bundler=none --e2eTestRunner=none --unitTestRunner=none`
|
||||
);
|
||||
|
||||
updateFile(
|
||||
`apps/${nodeapp}/src/main.ts`,
|
||||
(content) => `import { ${names(lib).propertyName} } from '@proj/${lib}';
|
||||
|
||||
console.log(${names(lib).propertyName}());
|
||||
|
||||
${content}
|
||||
`
|
||||
);
|
||||
|
||||
// App is CJS by default so lets update the lib to follow the same pattern
|
||||
updateJson(`packages/${lib}/tsconfig.lib.json`, (json) => {
|
||||
json.compilerOptions.module = 'commonjs';
|
||||
json.compilerOptions.moduleResolution = 'node';
|
||||
return json;
|
||||
});
|
||||
|
||||
updateJson('tsconfig.base.json', (json) => {
|
||||
json.compilerOptions.moduleResolution = 'node';
|
||||
json.compilerOptions.module = 'esnext';
|
||||
return json;
|
||||
});
|
||||
|
||||
const pm = getSelectedPackageManager();
|
||||
if (pm === 'pnpm') {
|
||||
updateJson(`apps/${nodeapp}/package.json`, (json) => {
|
||||
json.dependencies ??= {};
|
||||
json.dependencies[`@proj/${lib}`] = 'workspace:*';
|
||||
return json;
|
||||
});
|
||||
|
||||
const pmc = getPackageManagerCommand({ packageManager: pm });
|
||||
runCommand(pmc.install);
|
||||
}
|
||||
|
||||
runCLI('sync');
|
||||
|
||||
// check build
|
||||
expect(runCLI(`build ${nodeapp}`)).toContain(
|
||||
`Successfully ran target build for project ${nodeapp}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function getRandomPort() {
|
||||
return Math.floor(1000 + Math.random() * 7000);
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
import * as path from 'path';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { existsSync, mkdirSync, writeFileSync, lstatSync } from 'fs';
|
||||
import {
|
||||
ExecutorContext,
|
||||
joinPathFragments,
|
||||
normalizePath,
|
||||
ProjectGraphProjectNode,
|
||||
readJsonFile,
|
||||
workspaceRoot,
|
||||
} from '@nx/devkit';
|
||||
|
||||
@ -74,7 +75,10 @@ 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 paths = getTsConfigCompilerPaths(context);
|
||||
const paths = options.isTsSolutionSetup
|
||||
? createPathsFromTsConfigReferences(context)
|
||||
: getTsConfigCompilerPaths(context);
|
||||
|
||||
const entryPointsFromProjects = getEntryPoints(
|
||||
context.projectName,
|
||||
context,
|
||||
@ -123,6 +127,132 @@ export function buildEsbuildOptions(
|
||||
return esbuildOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* When using TS project references we need to map the paths to the referenced projects.
|
||||
* This is necessary because esbuild does not support project references out of the box.
|
||||
* @param context ExecutorContext
|
||||
*/
|
||||
export function createPathsFromTsConfigReferences(
|
||||
context: ExecutorContext
|
||||
): Record<string, string[]> {
|
||||
const {
|
||||
findAllProjectNodeDependencies,
|
||||
} = require('nx/src/utils/project-graph-utils');
|
||||
const {
|
||||
isValidPackageJsonBuildConfig,
|
||||
} = require('@nx/js/src/plugins/typescript/util');
|
||||
const { readTsConfig } = require('@nx/js');
|
||||
const {
|
||||
findRuntimeTsConfigName,
|
||||
} = require('@nx/js/src/utils/typescript/ts-solution-setup');
|
||||
|
||||
const deps = findAllProjectNodeDependencies(
|
||||
context.projectName,
|
||||
context.projectGraph
|
||||
);
|
||||
const tsConfig = readJsonFile(
|
||||
joinPathFragments(context.root, 'tsconfig.json')
|
||||
);
|
||||
const referencesAsPaths = new Set(
|
||||
tsConfig.references.reduce((acc, ref) => {
|
||||
if (!ref.path) return acc;
|
||||
|
||||
const fullPath = joinPathFragments(workspaceRoot, ref.path);
|
||||
|
||||
try {
|
||||
if (lstatSync(fullPath).isDirectory()) {
|
||||
acc.push(fullPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors (e.g., path doesn't exist)
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [])
|
||||
);
|
||||
|
||||
// for each dep we check if it contains a build target
|
||||
// we only want to add the paths for projects that do not have a build target
|
||||
return deps.reduce((acc, dep) => {
|
||||
const projectNode = context.projectGraph.nodes[dep];
|
||||
const projectPath = joinPathFragments(workspaceRoot, projectNode.data.root);
|
||||
const resolvedTsConfigPath =
|
||||
findRuntimeTsConfigName(projectPath) ?? 'tsconfig.json';
|
||||
const projTsConfig = readTsConfig(resolvedTsConfigPath) as any;
|
||||
|
||||
const projectPkgJson = readJsonFile(
|
||||
joinPathFragments(projectPath, 'package.json')
|
||||
);
|
||||
|
||||
if (
|
||||
projTsConfig &&
|
||||
!isValidPackageJsonBuildConfig(
|
||||
projTsConfig,
|
||||
workspaceRoot,
|
||||
projectPath
|
||||
) &&
|
||||
projectPkgJson?.name
|
||||
) {
|
||||
const entryPoint = getProjectEntryPoint(projectPkgJson, projectPath);
|
||||
if (referencesAsPaths.has(projectPath)) {
|
||||
acc[projectPkgJson.name] = [path.relative(workspaceRoot, entryPoint)];
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Get the entry point for the project
|
||||
function getProjectEntryPoint(projectPkgJson: any, projectPath: string) {
|
||||
let entryPoint = null;
|
||||
if (typeof projectPkgJson.exports === 'string') {
|
||||
// If exports is a string, use it as the entry point
|
||||
entryPoint = path.relative(
|
||||
workspaceRoot,
|
||||
joinPathFragments(projectPath, projectPkgJson.exports)
|
||||
);
|
||||
} else if (
|
||||
typeof projectPkgJson.exports === 'object' &&
|
||||
projectPkgJson.exports['.']
|
||||
) {
|
||||
// If exports is an object and has a '.' key, process it
|
||||
const exportEntry = projectPkgJson.exports['.'];
|
||||
if (typeof exportEntry === 'object') {
|
||||
entryPoint =
|
||||
exportEntry.import ||
|
||||
exportEntry.require ||
|
||||
exportEntry.default ||
|
||||
null;
|
||||
} else if (typeof exportEntry === 'string') {
|
||||
entryPoint = exportEntry;
|
||||
}
|
||||
|
||||
if (entryPoint) {
|
||||
entryPoint = path.relative(
|
||||
workspaceRoot,
|
||||
joinPathFragments(projectPath, entryPoint)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no exports were found, fall back to main and module
|
||||
if (!entryPoint) {
|
||||
if (projectPkgJson.main) {
|
||||
entryPoint = path.relative(
|
||||
workspaceRoot,
|
||||
joinPathFragments(projectPath, projectPkgJson.main)
|
||||
);
|
||||
} else if (projectPkgJson.module) {
|
||||
entryPoint = path.relative(
|
||||
workspaceRoot,
|
||||
joinPathFragments(projectPath, projectPkgJson.module)
|
||||
);
|
||||
}
|
||||
}
|
||||
return entryPoint;
|
||||
}
|
||||
|
||||
export function getOutExtension(
|
||||
format: 'cjs' | 'esm',
|
||||
options: Pick<NormalizedEsBuildExecutorOptions, 'userDefinedBuildOptions'>
|
||||
|
||||
@ -35,7 +35,11 @@ import { hashArray, hashFile, hashObject } from 'nx/src/hasher/file-hasher';
|
||||
import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file';
|
||||
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
|
||||
import type { ParsedCommandLine, System } from 'typescript';
|
||||
import { addBuildAndWatchDepsTargets } from './util';
|
||||
import {
|
||||
addBuildAndWatchDepsTargets,
|
||||
isValidPackageJsonBuildConfig,
|
||||
ParsedTsconfigData,
|
||||
} from './util';
|
||||
|
||||
export interface TscPluginOptions {
|
||||
typecheck?:
|
||||
@ -72,12 +76,7 @@ interface NormalizedPluginOptions {
|
||||
}
|
||||
|
||||
type TscProjectResult = Pick<ProjectConfiguration, 'targets'>;
|
||||
type ParsedTsconfigData = Pick<
|
||||
ParsedCommandLine,
|
||||
'options' | 'projectReferences' | 'raw'
|
||||
> & {
|
||||
extendedConfigFile: { filePath: string; externalPackage?: string } | null;
|
||||
};
|
||||
|
||||
type TsconfigCacheData = {
|
||||
data: ParsedTsconfigData;
|
||||
hash: string;
|
||||
@ -756,103 +755,6 @@ function getOutputs(
|
||||
return Array.from(outputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the build configuration of a `package.json` file by ensuring that paths in the `exports`, `module`,
|
||||
* and `main` fields reference valid output paths within the `outDir` defined in the TypeScript configuration.
|
||||
* Priority is given to the `exports` field, specifically the `.` export if defined. If `exports` is not defined,
|
||||
* the function falls back to validating `main` and `module` fields. If `outFile` is specified, it validates that the file
|
||||
* is located within the output directory.
|
||||
* If no `package.json` file exists, it assumes the configuration is valid.
|
||||
*
|
||||
* @param tsConfig The TypeScript configuration object.
|
||||
* @param workspaceRoot The workspace root path.
|
||||
* @param projectRoot The project root path.
|
||||
* @returns `true` if the package has a valid build configuration; otherwise, `false`.
|
||||
*/
|
||||
function isValidPackageJsonBuildConfig(
|
||||
tsConfig: ParsedTsconfigData,
|
||||
workspaceRoot: string,
|
||||
projectRoot: string
|
||||
): boolean {
|
||||
const packageJsonPath = join(workspaceRoot, projectRoot, 'package.json');
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
// If the package.json file does not exist.
|
||||
// Assume it's valid because it would be using `project.json` instead.
|
||||
return true;
|
||||
}
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
|
||||
const outDir = tsConfig.options.outFile
|
||||
? dirname(tsConfig.options.outFile)
|
||||
: tsConfig.options.outDir;
|
||||
const resolvedOutDir = outDir
|
||||
? resolve(workspaceRoot, projectRoot, outDir)
|
||||
: undefined;
|
||||
|
||||
const isPathSourceFile = (path: string): boolean => {
|
||||
if (resolvedOutDir) {
|
||||
const pathToCheck = resolve(workspaceRoot, projectRoot, path);
|
||||
return !pathToCheck.startsWith(resolvedOutDir);
|
||||
}
|
||||
|
||||
const ext = extname(path);
|
||||
// Check that the file extension is a TS file extension. As the source files are in the same directory as the output files.
|
||||
return ['.ts', '.tsx', '.cts', '.mts'].includes(ext);
|
||||
};
|
||||
|
||||
// Checks if the value is a path within the `src` directory.
|
||||
const containsInvalidPath = (
|
||||
value: string | Record<string, string>
|
||||
): boolean => {
|
||||
if (typeof value === 'string') {
|
||||
return isPathSourceFile(value);
|
||||
} else if (typeof value === 'object') {
|
||||
return Object.entries(value).some(([currentKey, subValue]) => {
|
||||
// Skip types field
|
||||
if (currentKey === 'types') {
|
||||
return false;
|
||||
}
|
||||
if (typeof subValue === 'string') {
|
||||
return isPathSourceFile(subValue);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const exports = packageJson?.exports;
|
||||
|
||||
// Check the `.` export if `exports` is defined.
|
||||
if (exports) {
|
||||
if (typeof exports === 'string') {
|
||||
return !isPathSourceFile(exports);
|
||||
}
|
||||
if (typeof exports === 'object' && '.' in exports) {
|
||||
return !containsInvalidPath(exports['.']);
|
||||
}
|
||||
|
||||
// Check other exports if `.` is not defined or valid.
|
||||
for (const key in exports) {
|
||||
if (key !== '.' && containsInvalidPath(exports[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// If `exports` is not defined, fallback to `main` and `module` fields.
|
||||
const buildPaths = ['main', 'module'];
|
||||
for (const field of buildPaths) {
|
||||
if (packageJson[field] && isPathSourceFile(packageJson[field])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function pathToInputOrOutput(
|
||||
path: string,
|
||||
workspaceRoot: string,
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import { readJsonFile, type TargetConfiguration } from '@nx/devkit';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, extname, isAbsolute, relative, resolve } from 'node:path';
|
||||
import { type PackageManagerCommands } from 'nx/src/utils/package-manager';
|
||||
import { join } from 'path';
|
||||
import { type ParsedCommandLine } from 'typescript';
|
||||
|
||||
export type ParsedTsconfigData = Pick<
|
||||
ParsedCommandLine,
|
||||
'options' | 'projectReferences' | 'raw'
|
||||
> & {
|
||||
extendedConfigFile: { filePath: string; externalPackage?: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Allow uses that use incremental builds to run `nx watch-deps` to continuously build all dependencies.
|
||||
@ -39,3 +48,94 @@ export function addBuildAndWatchDepsTargets(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidPackageJsonBuildConfig(
|
||||
tsConfig: ParsedTsconfigData,
|
||||
workspaceRoot: string,
|
||||
projectRoot: string
|
||||
): boolean {
|
||||
const resolvedProjectPath = isAbsolute(projectRoot)
|
||||
? relative(workspaceRoot, projectRoot)
|
||||
: projectRoot;
|
||||
const packageJsonPath = join(
|
||||
workspaceRoot,
|
||||
resolvedProjectPath,
|
||||
'package.json'
|
||||
);
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
// If the package.json file does not exist.
|
||||
// Assume it's valid because it would be using `project.json` instead.
|
||||
return true;
|
||||
}
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
|
||||
const outDir = tsConfig.options.outFile
|
||||
? dirname(tsConfig.options.outFile)
|
||||
: tsConfig.options.outDir;
|
||||
const resolvedOutDir = outDir
|
||||
? resolve(workspaceRoot, resolvedProjectPath, outDir)
|
||||
: undefined;
|
||||
|
||||
const isPathSourceFile = (path: string): boolean => {
|
||||
if (resolvedOutDir) {
|
||||
const pathToCheck = resolve(workspaceRoot, resolvedProjectPath, path);
|
||||
return !pathToCheck.startsWith(resolvedOutDir);
|
||||
}
|
||||
|
||||
const ext = extname(path);
|
||||
// Check that the file extension is a TS file extension. As the source files are in the same directory as the output files.
|
||||
return ['.ts', '.tsx', '.cts', '.mts'].includes(ext);
|
||||
};
|
||||
|
||||
// Checks if the value is a path within the `src` directory.
|
||||
const containsInvalidPath = (
|
||||
value: string | Record<string, string>
|
||||
): boolean => {
|
||||
if (typeof value === 'string') {
|
||||
return isPathSourceFile(value);
|
||||
} else if (typeof value === 'object') {
|
||||
return Object.entries(value).some(([currentKey, subValue]) => {
|
||||
// Skip types field
|
||||
if (currentKey === 'types') {
|
||||
return false;
|
||||
}
|
||||
if (typeof subValue === 'string') {
|
||||
return isPathSourceFile(subValue);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const exports = packageJson?.exports;
|
||||
|
||||
// Check the `.` export if `exports` is defined.
|
||||
if (exports) {
|
||||
if (typeof exports === 'string') {
|
||||
return !isPathSourceFile(exports);
|
||||
}
|
||||
if (typeof exports === 'object' && '.' in exports) {
|
||||
return !containsInvalidPath(exports['.']);
|
||||
}
|
||||
|
||||
// Check other exports if `.` is not defined or valid.
|
||||
for (const key in exports) {
|
||||
if (key !== '.' && containsInvalidPath(exports[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// If `exports` is not defined, fallback to `main` and `module` fields.
|
||||
const buildPaths = ['main', 'module'];
|
||||
for (const field of buildPaths) {
|
||||
if (packageJson[field] && isPathSourceFile(packageJson[field])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -108,9 +108,10 @@ export function assertNotUsingTsSolutionSetup(
|
||||
}
|
||||
|
||||
export function findRuntimeTsConfigName(
|
||||
tree: Tree,
|
||||
projectRoot: string
|
||||
projectRoot: string,
|
||||
tree?: Tree
|
||||
): string | null {
|
||||
tree ??= new FsTree(workspaceRoot, false);
|
||||
if (tree.exists(joinPathFragments(projectRoot, 'tsconfig.app.json')))
|
||||
return 'tsconfig.app.json';
|
||||
if (tree.exists(joinPathFragments(projectRoot, 'tsconfig.lib.json')))
|
||||
|
||||
@ -235,7 +235,7 @@ export function createStorybookTsconfigFile(
|
||||
};
|
||||
|
||||
if (useTsSolution) {
|
||||
const runtimeConfig = findRuntimeTsConfigName(tree, projectRoot);
|
||||
const runtimeConfig = findRuntimeTsConfigName(projectRoot, tree);
|
||||
if (runtimeConfig) {
|
||||
storybookTsConfig.references ??= [];
|
||||
storybookTsConfig.references.push({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user