fix(js): generate js libs with exports in package.json and ensure esm output when using rollup bundler (#29565)

- Ensure libs are generated with `exports` in `package.json`
- Generate `types` instead of `typings` in package.json
- Update js lib with rollup to only output esm
- Update `tsconfig.spec.json` for js libraries with rollup to set
`module: esnext` and `moduleResolution: bundler` (they use `@swc/jest`)
- Fix `@nx/js/typescript` issue with absolute paths when normalizing
inputs/outputs
- Fix `@nx/js/typescript` issue identifying buildable libs
- Fix express app generator not installing `@types/express`

<!-- 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 -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #

---------

Co-authored-by: Jack Hsu <jack.hsu@gmail.com>
This commit is contained in:
Leosvel Pérez Espinosa 2025-01-10 14:29:09 +01:00 committed by GitHub
parent cbfc6fe97f
commit dd9b09f917
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 235 additions and 86 deletions

View File

@ -48,7 +48,7 @@ describe('EsBuild Plugin', () => {
private: true,
type: 'commonjs',
main: './index.cjs',
typings: './index.d.ts',
types: './index.d.ts',
dependencies: {},
});

View File

@ -567,7 +567,7 @@ describe('Linter', () => {
name: `@proj/${mylib}`,
private: true,
type: 'commonjs',
typings: './src/index.d.ts',
types: './src/index.d.ts',
version: '0.0.1',
});

View File

@ -116,9 +116,11 @@ packages:
expect(() => runCLI(`lint ${nodeapp}`)).not.toThrow();
expect(() => runCLI(`test ${nodeapp}`)).not.toThrow();
expect(() => runCLI(`build ${nodeapp}`)).not.toThrow();
expect(() => runCLI(`typecheck ${nodeapp}`)).not.toThrow();
expect(() => runCLI(`lint ${nodelib}`)).not.toThrow();
expect(() => runCLI(`test ${nodelib}`)).not.toThrow();
expect(() => runCLI(`build ${nodelib}`)).not.toThrow();
expect(() => runCLI(`typecheck ${nodelib}`)).not.toThrow();
const p = await runCommandUntil(
`serve ${nodeapp}`,

View File

@ -82,6 +82,7 @@ export async function applicationGeneratorInternal(tree: Tree, schema: Schema) {
const applicationTask = await nodeApplicationGenerator(tree, {
...options,
bundler: 'webpack',
framework: 'express',
skipFormat: true,
});
tasks.push(applicationTask);

View File

@ -62,7 +62,8 @@ export function createFiles(
? `${rootOffset}tsconfig.base.json`
: './tsconfig.json',
outDir: isTsSolutionSetup ? `./out-tsc/jest` : `${rootOffset}dist/out-tsc`,
module: !isTsSolutionSetup ? 'commonjs' : undefined,
module:
!isTsSolutionSetup || transformer === 'ts-jest' ? 'commonjs' : undefined,
});
if (options.setupFile === 'none') {

View File

@ -1653,6 +1653,14 @@ describe('lib', () => {
expect(readJson(tree, 'my-ts-lib/package.json')).toMatchInlineSnapshot(`
{
"dependencies": {},
"exports": {
".": {
"default": "./src/index.ts",
"import": "./src/index.ts",
"types": "./src/index.ts",
},
"./package.json": "./package.json",
},
"main": "./src/index.ts",
"name": "@proj/my-ts-lib",
"private": true,
@ -1663,6 +1671,10 @@ describe('lib', () => {
expect(readJson(tree, 'my-js-lib/package.json')).toMatchInlineSnapshot(`
{
"dependencies": {},
"exports": {
".": "./src/index.js",
"./package.json": "./package.json",
},
"main": "./src/index.js",
"name": "@proj/my-js-lib",
"private": true,
@ -1686,11 +1698,20 @@ describe('lib', () => {
"dependencies": {
"tslib": "^2.3.0",
},
"exports": {
".": {
"default": "./dist/index.js",
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
},
"./package.json": "./package.json",
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"name": "@proj/my-ts-lib",
"private": true,
"type": "module",
"typings": "./dist/index.d.ts",
"types": "./dist/index.d.ts",
"version": "0.0.1",
}
`);
@ -1710,11 +1731,20 @@ describe('lib', () => {
"dependencies": {
"@swc/helpers": "~0.5.11",
},
"exports": {
".": {
"default": "./dist/index.js",
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
},
"./package.json": "./package.json",
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"name": "@proj/my-ts-lib",
"private": true,
"type": "module",
"typings": "./dist/index.d.ts",
"types": "./dist/index.d.ts",
"version": "0.0.1",
}
`);

View File

@ -1,12 +1,12 @@
import {
addDependenciesToPackageJson,
installPackagesTask,
addProjectConfiguration,
ensurePackage,
formatFiles,
generateFiles,
GeneratorCallback,
getPackageManagerCommand,
installPackagesTask,
joinPathFragments,
names,
offsetFromRoot,
@ -35,6 +35,7 @@ import { type PackageJson } from 'nx/src/utils/package-json';
import { join } from 'path';
import type { CompilerOptions } from 'typescript';
import { normalizeLinterOption } from '../../utils/generator-prompts';
import { getUpdatedPackageJsonContent } from '../../utils/package-json/update-package-json';
import {
getProjectPackageManagerWorkspaceState,
getProjectPackageManagerWorkspaceStateWarningTask,
@ -43,6 +44,7 @@ import { addSwcConfig } from '../../utils/swc/add-swc-config';
import { getSwcDependencies } from '../../utils/swc/add-swc-dependencies';
import { getNeededCompilerOptionOverrides } from '../../utils/typescript/configuration';
import { tsConfigBaseOptions } from '../../utils/typescript/create-ts-config';
import { ensureTypescript } from '../../utils/typescript/ensure-typescript';
import { ensureProjectIsIncludedInPluginRegistrations } from '../../utils/typescript/plugin';
import {
addTsConfigPath,
@ -68,7 +70,6 @@ import type {
LibraryGeneratorSchema,
NormalizedLibraryGeneratorOptions,
} from './schema';
import { ensureTypescript } from '../../utils/typescript/ensure-typescript';
const defaultOutputDirectory = 'dist';
@ -118,7 +119,7 @@ export async function libraryGeneratorInternal(
await configurationGenerator(tree, {
project: options.name,
compiler: 'swc',
format: ['cjs', 'esm'],
format: options.isUsingTsSolutionConfig ? ['esm'] : ['cjs', 'esm'],
});
}
@ -206,6 +207,12 @@ export async function libraryGeneratorInternal(
// add project reference to the runtime tsconfig.lib.json file
json.references ??= [];
json.references.push({ path: './tsconfig.lib.json' });
if (options.isUsingTsSolutionConfig && options.bundler === 'rollup') {
json.compilerOptions.module = 'esnext';
json.compilerOptions.moduleResolution = 'bundler';
}
return json;
}
);
@ -503,8 +510,7 @@ function createFiles(tree: Tree, options: NormalizedLibraryGeneratorOptions) {
let fileNameImport = options.fileName;
if (
options.bundler === 'vite' ||
(options.isUsingTsSolutionConfig &&
['esbuild', 'swc', 'tsc'].includes(options.bundler))
(options.isUsingTsSolutionConfig && options.bundler !== 'none')
) {
const tsConfig = readTsConfigFromTree(
tree,
@ -606,7 +612,8 @@ function createFiles(tree: Tree, options: NormalizedLibraryGeneratorOptions) {
// https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files
json.files = ['dist', '!**/*.tsbuildinfo'];
}
return {
const updatedPackageJson = {
...json,
dependencies: {
...json.dependencies,
@ -614,9 +621,26 @@ function createFiles(tree: Tree, options: NormalizedLibraryGeneratorOptions) {
},
...determineEntryFields(options),
};
if (
options.isUsingTsSolutionConfig &&
!['none', 'rollup', 'vite'].includes(options.bundler)
) {
return getUpdatedPackageJsonContent(updatedPackageJson, {
main: join(options.projectRoot, 'src/index.ts'),
outputPath: joinPathFragments(options.projectRoot, 'dist'),
projectRoot: options.projectRoot,
rootDir: join(options.projectRoot, 'src'),
generateExportsField: true,
packageJsonPath,
format: ['esm'],
});
}
return updatedPackageJson;
});
} else {
const packageJson: PackageJson = {
let packageJson: PackageJson = {
name: options.importPath,
version: '0.0.1',
dependencies: determineDependencies(options),
@ -630,6 +654,22 @@ function createFiles(tree: Tree, options: NormalizedLibraryGeneratorOptions) {
// https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files
packageJson.files = ['dist', '!**/*.tsbuildinfo'];
}
if (
options.isUsingTsSolutionConfig &&
!['none', 'rollup', 'vite'].includes(options.bundler)
) {
packageJson = getUpdatedPackageJsonContent(packageJson, {
main: join(options.projectRoot, 'src/index.ts'),
outputPath: joinPathFragments(options.projectRoot, 'dist'),
projectRoot: options.projectRoot,
rootDir: join(options.projectRoot, 'src'),
generateExportsField: true,
packageJsonPath,
format: ['esm'],
});
}
writeJson<PackageJson>(tree, packageJsonPath, packageJson);
}
@ -1094,74 +1134,84 @@ function determineEntryFields(
): Record<string, EntryField> {
switch (options.bundler) {
case 'tsc':
return {
type: options.isUsingTsSolutionConfig ? 'module' : 'commonjs',
main: options.isUsingTsSolutionConfig
? './dist/index.js'
: './src/index.js',
typings: options.isUsingTsSolutionConfig
? './dist/index.d.ts'
: './src/index.d.ts',
};
case 'swc':
return {
type: options.isUsingTsSolutionConfig ? 'module' : 'commonjs',
main: options.isUsingTsSolutionConfig
? './dist/index.js'
: './src/index.js',
typings: options.isUsingTsSolutionConfig
? './dist/index.d.ts'
: './src/index.d.ts',
};
if (options.isUsingTsSolutionConfig) {
return {
type: 'module',
main: './dist/index.js',
types: './dist/index.d.ts',
};
} else {
return {
type: 'commonjs',
main: './src/index.js',
types: './src/index.d.ts',
};
}
case 'rollup':
return {
// Since we're publishing both formats, skip the type field.
// Bundlers or Node will determine the entry point to use.
main: options.isUsingTsSolutionConfig
? './dist/index.cjs'
: './index.cjs',
module: options.isUsingTsSolutionConfig
? './dist/index.js'
: './index.js',
};
if (options.isUsingTsSolutionConfig) {
// the rollup configuration generator already handles this
return {};
} else {
return {
// Since we're publishing both formats, skip the type field.
// Bundlers or Node will determine the entry point to use.
main: './index.cjs',
module: './index.js',
};
}
case 'vite':
return {
type: 'module',
main: options.isUsingTsSolutionConfig
? './dist/index.js'
: './index.js',
typings: options.isUsingTsSolutionConfig
? './dist/index.d.ts'
: './index.d.ts',
};
if (options.isUsingTsSolutionConfig) {
// the vite configuration generator already handle this
return {};
} else {
return {
type: 'module',
main: './index.js',
types: './index.d.ts',
};
}
case 'esbuild':
return {
type: options.isUsingTsSolutionConfig ? 'module' : 'commonjs',
main: options.isUsingTsSolutionConfig
? './dist/index.js'
: './index.cjs',
typings: options.isUsingTsSolutionConfig
? './dist/index.d.ts'
: './index.d.ts',
};
default: {
if (options.isUsingTsSolutionConfig) {
return {
type: 'module',
main: './dist/index.js',
types: './dist/index.d.ts',
};
} else {
return {
type: 'commonjs',
main: './index.cjs',
types: './index.d.ts',
};
}
case 'none': {
if (options.isUsingTsSolutionConfig) {
return {
main: options.js ? './src/index.js' : './src/index.ts',
types: options.js ? './src/index.js' : './src/index.ts',
exports: {
'.': options.js
? './src/index.js'
: {
types: './src/index.ts',
import: './src/index.ts',
default: './src/index.ts',
},
'./package.json': './package.json',
},
};
}
return {
// Safest option is to not set a type field.
// Allow the user to decide which module format their library is using
type: undefined,
// For non-buildable libraries, point to source so we can still use them in apps via bundlers like Vite.
main: options.isUsingTsSolutionConfig
? options.js
? './src/index.js'
: './src/index.ts'
: undefined,
types: options.isUsingTsSolutionConfig
? options.js
? './src/index.js'
: './src/index.ts'
: undefined,
};
}
default: {
return {};
}
}
}

View File

@ -144,7 +144,7 @@ export async function setupBuildGenerator(
tsConfig: tsConfigFile,
project: options.project,
compiler: 'tsc',
format: ['cjs', 'esm'],
format: isTsSolutionSetup ? ['esm'] : ['cjs', 'esm'],
addPlugin,
skipFormat: true,
skipValidation: true,

View File

@ -1919,7 +1919,18 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
'libs/my-lib/tsconfig.json': `{}`,
'libs/my-lib/tsconfig.lib.json': `{"compilerOptions": {"outDir": "dist"}}`,
'libs/my-lib/tsconfig.build.json': `{}`,
'libs/my-lib/package.json': `{"main": "dist/index.js"}`,
'libs/my-lib/package.json': JSON.stringify({
main: 'dist/index.js',
types: 'dist/index.d.ts',
exports: {
'.': {
types: './dist/index.d.ts',
import: './dist/index.js',
default: './dist/index.js',
},
'./package.json': './package.json',
},
}),
});
expect(
await invokeCreateNodesOnMatchingFiles(context, {

View File

@ -633,18 +633,17 @@ function getOutputs(
* @returns `true` if the package has a valid build configuration; otherwise, `false`.
*/
function isValidPackageJsonBuildConfig(
tsConfig,
tsConfig: ParsedCommandLine,
workspaceRoot: string,
projectRoot: string
): boolean {
if (!existsSync(joinPathFragments(projectRoot, 'package.json'))) {
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(
joinPathFragments(projectRoot, 'package.json')
);
const packageJson = readJsonFile(packageJsonPath);
const outDir = tsConfig.options.outFile
? dirname(tsConfig.options.outFile)
@ -691,10 +690,9 @@ function isValidPackageJsonBuildConfig(
if (exports) {
if (typeof exports === 'string') {
return !isPathSourceFile(exports);
} else if (typeof exports === 'object' && '.' in exports) {
if (containsInvalidPath(exports['.'])) {
return false;
}
}
if (typeof exports === 'object' && '.' in exports) {
return !containsInvalidPath(exports['.']);
}
// Check other exports if `.` is not defined or valid.
@ -723,9 +721,16 @@ function pathToInputOrOutput(
workspaceRoot: string,
projectRoot: string
): string {
const pathRelativeToProjectRoot = normalizePath(relative(projectRoot, path));
const fullProjectRoot = resolve(workspaceRoot, projectRoot);
const fullPath = resolve(workspaceRoot, path);
const pathRelativeToProjectRoot = normalizePath(
relative(fullProjectRoot, fullPath)
);
if (pathRelativeToProjectRoot.startsWith('..')) {
return joinPathFragments('{workspaceRoot}', relative(workspaceRoot, path));
return joinPathFragments(
'{workspaceRoot}',
relative(workspaceRoot, fullPath)
);
}
return joinPathFragments('{projectRoot}', pathRelativeToProjectRoot);

View File

@ -157,6 +157,7 @@ describe('getUpdatedPackageJsonContent', () => {
version: '0.0.1',
exports: {
'.': {
default: './src/index.js',
import: './src/index.js',
types: './src/index.d.ts',
},
@ -268,6 +269,7 @@ describe('getUpdatedPackageJsonContent', () => {
version: '0.0.1',
exports: {
'.': {
default: './src/index.js',
import: './src/index.js',
types: './src/index.d.ts',
},

View File

@ -326,6 +326,9 @@ export function getUpdatedPackageJsonContent(
: filePath;
} else if (typeof packageJson.exports[exportEntry] === 'object') {
packageJson.exports[exportEntry].import ??= filePath;
if (!hasCjsFormat) {
packageJson.exports[exportEntry].default ??= filePath;
}
}
}
}

View File

@ -8,9 +8,9 @@ import {
updateJson,
workspaceRoot,
} from '@nx/devkit';
import { basename, dirname, join } from 'node:path/posix';
import { FsTree } from 'nx/src/generators/tree';
import { isUsingPackageManagerWorkspaces } from '../package-manager-workspaces';
import { basename, dirname, join, relative } from 'node:path/posix';
export function isUsingTypeScriptPlugin(tree: Tree): boolean {
const nxJson = readNxJson(tree);

View File

@ -433,6 +433,8 @@ describe('lib', () => {
expect(readJson(tree, 'mylib/tsconfig.spec.json')).toMatchInlineSnapshot(`
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node10",
"outDir": "./out-tsc/jest",
"types": [
"jest",

View File

@ -556,6 +556,14 @@ describe('lib', () => {
expect(readJson(tree, 'mylib/package.json')).toMatchInlineSnapshot(`
{
"dependencies": {},
"exports": {
".": {
"default": "./src/index.ts",
"import": "./src/index.ts",
"types": "./src/index.ts",
},
"./package.json": "./package.json",
},
"main": "./src/index.ts",
"name": "@proj/mylib",
"nx": {
@ -612,6 +620,8 @@ describe('lib', () => {
expect(readJson(tree, 'mylib/tsconfig.spec.json')).toMatchInlineSnapshot(`
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node10",
"outDir": "./out-tsc/jest",
"types": [
"jest",
@ -648,7 +658,16 @@ describe('lib', () => {
"dependencies": {
"tslib": "^2.3.0",
},
"exports": {
".": {
"default": "./dist/index.js",
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
},
"./package.json": "./package.json",
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"name": "@proj/mylib",
"nx": {
"name": "mylib",
@ -671,8 +690,7 @@ describe('lib', () => {
},
},
"private": true,
"type": "module",
"typings": "./dist/index.d.ts",
"types": "./dist/index.d.ts",
"version": "0.0.1",
}
`);

View File

@ -7,6 +7,7 @@ import {
joinPathFragments,
names,
offsetFromRoot,
readJson,
readNxJson,
readProjectConfiguration,
runTasksInSerial,
@ -84,6 +85,8 @@ export async function libraryGeneratorInternal(tree: Tree, schema: Schema) {
})
);
updatePackageJson(tree, options);
tasks.push(
await initGenerator(tree, {
...options,
@ -234,3 +237,22 @@ function ensureDependencies(tree: Tree): GeneratorCallback {
{ '@types/node': typesNodeVersion }
);
}
function updatePackageJson(tree: Tree, options: NormalizedSchema) {
const packageJson = readJson(
tree,
joinPathFragments(options.projectRoot, 'package.json')
);
if (packageJson.type === 'module') {
// The @nx/js:lib generator can set the type to 'module' which would
// potentially break consumers of the library.
delete packageJson.type;
}
writeJson(
tree,
joinPathFragments(options.projectRoot, 'package.json'),
packageJson
);
}

View File

@ -1226,6 +1226,7 @@ module.exports = withNx(
{
"exports": {
".": {
"default": "./dist/index.esm.js",
"import": "./dist/index.esm.js",
"types": "./dist/index.esm.d.ts",
},

View File

@ -370,6 +370,7 @@ describe('@nx/vite:configuration', () => {
{
"exports": {
".": {
"default": "./dist/index.js",
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
},