diff --git a/e2e/node/src/node-ts-solution-esbuild.test.ts b/e2e/node/src/node-ts-solution-esbuild.test.ts new file mode 100644 index 0000000000..aa7de4b87e --- /dev/null +++ b/e2e/node/src/node-ts-solution-esbuild.test.ts @@ -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); +} diff --git a/packages/esbuild/src/executors/esbuild/lib/build-esbuild-options.ts b/packages/esbuild/src/executors/esbuild/lib/build-esbuild-options.ts index 32df88ab18..e9839c192f 100644 --- a/packages/esbuild/src/executors/esbuild/lib/build-esbuild-options.ts +++ b/packages/esbuild/src/executors/esbuild/lib/build-esbuild-options.ts @@ -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 { + 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 diff --git a/packages/js/src/plugins/typescript/plugin.ts b/packages/js/src/plugins/typescript/plugin.ts index 9d6e6dc72a..1aa26de95a 100644 --- a/packages/js/src/plugins/typescript/plugin.ts +++ b/packages/js/src/plugins/typescript/plugin.ts @@ -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; -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 - ): 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, diff --git a/packages/js/src/plugins/typescript/util.ts b/packages/js/src/plugins/typescript/util.ts index 6cd334dce8..b9fb1bdcd9 100644 --- a/packages/js/src/plugins/typescript/util.ts +++ b/packages/js/src/plugins/typescript/util.ts @@ -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 + ): 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; +} diff --git a/packages/js/src/utils/typescript/ts-solution-setup.ts b/packages/js/src/utils/typescript/ts-solution-setup.ts index 0aeeea281f..754d86b7e1 100644 --- a/packages/js/src/utils/typescript/ts-solution-setup.ts +++ b/packages/js/src/utils/typescript/ts-solution-setup.ts @@ -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'))) diff --git a/packages/storybook/src/generators/configuration/lib/util-functions.ts b/packages/storybook/src/generators/configuration/lib/util-functions.ts index ee8a71da31..b32ac740e2 100644 --- a/packages/storybook/src/generators/configuration/lib/util-functions.ts +++ b/packages/storybook/src/generators/configuration/lib/util-functions.ts @@ -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({