nx/packages/rollup/src/plugins/package-json/update-package-json.ts
Jack Hsu 2d2c0b5acb
fix(bundling): explicitly set types for exports entries in package.json (#27152)
We currently rely on the TS behavior of matching `d.ts` files based on
the `.js` file names. e.g. `foo.js` matches `foo.d.ts`. However, it
isn't working for all tools so we should explicitly set it.

Most modern packages are still setting it even though it is not
technically needed. e.g.
[Nuxt](https://unpkg.com/browse/nuxt@3.12.4/package.json)

Note: If both ESM and CJS are present, then prefer `*.esm.d.ts` files
since the generated types are in ESM format.

## Current Behavior
`exports` entries are missing `types` field

## Expected Behavior
`exports` entries have `types` field set

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

Fixes #18835
2024-07-26 12:23:46 -04:00

153 lines
5.0 KiB
TypeScript

import { basename, join, parse } from 'path';
import { writeJsonFile } from 'nx/src/utils/fileutils';
import { writeFileSync } from 'fs';
import { PackageJson } from 'nx/src/utils/package-json';
import { stripIndents, workspaceRoot } from '@nx/devkit';
export function updatePackageJson(
options: {
outputPath: string;
main: string;
format: string[];
generateExportsField?: boolean;
skipTypeField?: boolean;
outputFileName?: string;
additionalEntryPoints?: string[];
},
packageJson: PackageJson
) {
const hasEsmFormat = options.format.includes('esm');
const hasCjsFormat = options.format.includes('cjs');
if (options.generateExportsField) {
packageJson.exports =
typeof packageJson.exports === 'string' ? {} : { ...packageJson.exports };
packageJson.exports['./package.json'] = './package.json';
}
if (hasEsmFormat) {
const esmExports = getExports({
...options,
fileExt: '.esm.js',
});
packageJson.module = esmExports['.'];
if (!hasCjsFormat) {
if (!options.skipTypeField) packageJson.type = 'module';
packageJson.main ??= esmExports['.'];
}
if (options.generateExportsField) {
for (const [exportEntry, filePath] of Object.entries(esmExports)) {
packageJson.exports[exportEntry] =
// If CJS format is used, make sure `import` (from Node) points to same instance of the package.
// Otherwise, packages that are required to be singletons (like React, RxJS, etc.) will break.
// Reserve `module` entry for bundlers to accommodate tree-shaking.
{
[hasCjsFormat ? 'module' : 'import']: filePath,
types: filePath.replace(/\.js$/, '.d.ts'),
};
}
}
}
if (hasCjsFormat) {
const cjsExports = getExports({
...options,
fileExt: '.cjs.js',
});
packageJson.main = cjsExports['.'];
if (!hasEsmFormat && !options.skipTypeField) {
packageJson.type = 'commonjs';
}
if (options.generateExportsField) {
for (const [exportEntry, filePath] of Object.entries(cjsExports)) {
if (hasEsmFormat) {
// If ESM format used, make sure `import` (from Node) points to a wrapped
// version of CJS file to ensure the package remains a singleton.
// TODO(jack): This can be made into a rollup plugin to re-use in Vite.
const relativeFile = parse(filePath).base;
const fauxEsmFilePath = filePath.replace(/\.cjs\.js$/, '.cjs.mjs');
packageJson.exports[exportEntry]['import'] ??= fauxEsmFilePath;
packageJson.exports[exportEntry]['default'] ??= filePath;
// Set `types` field only if it's not already set as the preferred ESM Format.
packageJson.exports[exportEntry]['types'] ??= filePath.replace(
/\.js$/,
'.d.ts'
);
// Re-export from relative CJS file, and Node will synthetically export it as ESM.
// Make sure both ESM and CJS point to same instance of the package because libs like React, RxJS, etc. requires it.
// Also need a special .cjs.default.js file that re-exports the `default` from CJS, or else
// default import in Node will not work.
writeFileSync(
join(
workspaceRoot,
options.outputPath,
filePath.replace(/\.cjs\.js$/, '.cjs.default.js')
),
`exports._default = require('./${parse(filePath).base}').default;`
);
writeFileSync(
join(workspaceRoot, options.outputPath, fauxEsmFilePath),
// Re-export from relative CJS file, and Node will synthetically export it as ESM.
stripIndents`
export * from './${relativeFile}';
export { _default as default } from './${relativeFile.replace(
/\.cjs\.js$/,
'.cjs.default.js'
)}';
`
);
} else {
packageJson.exports[exportEntry] = filePath;
}
}
}
}
if (packageJson.module) {
packageJson.types ??= packageJson.module.replace(/\.js$/, '.d.ts');
} else {
packageJson.types ??= packageJson.main.replace(/\.js$/, '.d.ts');
}
writeJsonFile(
join(workspaceRoot, options.outputPath, 'package.json'),
packageJson
);
}
interface Exports {
[name: string]: string;
}
function getExports(options: {
main?: string;
fileExt: string;
outputFileName?: string;
additionalEntryPoints?: string[];
}): Exports {
const exports: Exports = {};
// Users may provide custom input option and skip the main field.
if (options.main) {
const mainFile = options.outputFileName
? options.outputFileName.replace(/\.[tj]s$/, '')
: basename(options.main).replace(/\.[tj]s$/, '');
exports['.'] = './' + mainFile + options.fileExt;
}
if (options.additionalEntryPoints) {
for (const file of options.additionalEntryPoints) {
const { name: fileName } = parse(file);
exports['./' + fileName] = './' + fileName + options.fileExt;
}
}
return exports;
}