From 70f1a6b781b61efc885602ee7a638290d0c6c1b4 Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Tue, 17 May 2022 21:11:18 -0400 Subject: [PATCH] feat(web): add support for Node ESM when used to package SSR-ready library (#10349) --- docs/generated/packages/web.json | 2 +- e2e/react/src/react-package.test.ts | 36 ++-- e2e/web/src/web.test.ts | 2 +- e2e/workspace-integrations/src/cache.test.ts | 4 +- packages/nx/src/utils/package-json.ts | 8 + .../rollup/lib/update-package-json.spec.ts | 156 ++++++++++++++++++ .../rollup/lib/update-package-json.ts | 76 +++++++++ .../src/executors/rollup/rollup.impl.spec.ts | 14 +- .../web/src/executors/rollup/rollup.impl.ts | 66 +++----- packages/web/src/executors/rollup/schema.json | 2 +- ...lup-format-backwards-compatibility.spec.ts | 54 ++++++ .../rollup-format-backwards-compatibility.ts | 24 +++ 12 files changed, 366 insertions(+), 78 deletions(-) create mode 100644 packages/web/src/executors/rollup/lib/update-package-json.spec.ts create mode 100644 packages/web/src/executors/rollup/lib/update-package-json.ts create mode 100644 packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.spec.ts create mode 100644 packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.ts diff --git a/docs/generated/packages/web.json b/docs/generated/packages/web.json index 59f83920944eb..18f45df0ab075 100644 --- a/docs/generated/packages/web.json +++ b/docs/generated/packages/web.json @@ -659,7 +659,7 @@ "description": "Only build the specified comma-separated formats (`esm,umd,cjs`)", "alias": "f", "items": { "type": "string", "enum": ["esm", "umd", "cjs"] }, - "default": ["esm", "umd"] + "default": ["esm"] }, "external": { "type": "array", diff --git a/e2e/react/src/react-package.test.ts b/e2e/react/src/react-package.test.ts index 22058498b770e..00f8ea0102311 100644 --- a/e2e/react/src/react-package.test.ts +++ b/e2e/react/src/react-package.test.ts @@ -104,11 +104,9 @@ describe('Build React libraries and apps', () => { runCLI(`build ${childLib}`); runCLI(`build ${childLib2}`); - checkFilesExist(`dist/libs/${childLib}/index.esm.js`); - checkFilesExist(`dist/libs/${childLib}/index.umd.js`); + checkFilesExist(`dist/libs/${childLib}/index.js`); - checkFilesExist(`dist/libs/${childLib2}/index.esm.js`); - checkFilesExist(`dist/libs/${childLib2}/index.umd.js`); + checkFilesExist(`dist/libs/${childLib2}/index.js`); checkFilesExist(`dist/libs/${childLib}/assets/hello.txt`); checkFilesExist(`dist/libs/${childLib2}/README.md`); @@ -118,8 +116,7 @@ describe('Build React libraries and apps', () => { */ runCLI(`build ${parentLib}`); - checkFilesExist(`dist/libs/${parentLib}/index.esm.js`); - checkFilesExist(`dist/libs/${parentLib}/index.umd.js`); + checkFilesExist(`dist/libs/${parentLib}/index.js`); const jsonFile = readJson(`dist/libs/${parentLib}/package.json`); expect(jsonFile.peerDependencies).toEqual( @@ -136,9 +133,9 @@ describe('Build React libraries and apps', () => { runCLI(`build ${parentLib} --skip-nx-cache`); - checkFilesExist(`dist/libs/${parentLib}/index.esm.js`); - checkFilesExist(`dist/libs/${childLib}/index.esm.js`); - checkFilesExist(`dist/libs/${childLib2}/index.esm.js`); + checkFilesExist(`dist/libs/${parentLib}/index.js`); + checkFilesExist(`dist/libs/${childLib}/index.js`); + checkFilesExist(`dist/libs/${childLib2}/index.js`); }); it('should support --format option', () => { @@ -151,25 +148,20 @@ export async function h() { return 'c'; } ` ); - runCLI(`build ${childLib} --format cjs,esm,umd`); + runCLI(`build ${childLib} --format cjs,esm`); - checkFilesExist(`dist/libs/${childLib}/index.cjs.js`); - checkFilesExist(`dist/libs/${childLib}/index.esm.js`); - checkFilesExist(`dist/libs/${childLib}/index.umd.js`); + checkFilesExist(`dist/libs/${childLib}/index.cjs`); + checkFilesExist(`dist/libs/${childLib}/index.js`); const cjsPackageSize = getSize( - tmpProjPath(`dist/libs/${childLib}/index.cjs.js`) + tmpProjPath(`dist/libs/${childLib}/index.cjs`) ); const esmPackageSize = getSize( - tmpProjPath(`dist/libs/${childLib}/index.esm.js`) - ); - const umdPackageSize = getSize( - tmpProjPath(`dist/libs/${childLib}/index.umd.js`) + tmpProjPath(`dist/libs/${childLib}/index.js`) ); - // This is a loose requirement that ESM and CJS packages should be less than the UMD counterpart. - expect(esmPackageSize).toBeLessThanOrEqual(umdPackageSize); - expect(cjsPackageSize).toBeLessThanOrEqual(umdPackageSize); + // This is a loose requirement that ESM should be smaller than CJS output. + expect(esmPackageSize).toBeLessThanOrEqual(cjsPackageSize); }); it('should preserve the tsconfig target set by user', () => { @@ -214,7 +206,7 @@ export async function h() { return 'c'; } // What we're testing runCLI(`build ${myLib}`); // Assertion - const content = readFile(`dist/libs/${myLib}/index.esm.js`); + const content = readFile(`dist/libs/${myLib}/index.js`); /** * Then check if the result contains this "promise" polyfill? diff --git a/e2e/web/src/web.test.ts b/e2e/web/src/web.test.ts index e2107f368b0e0..cecb16bb69cfe 100644 --- a/e2e/web/src/web.test.ts +++ b/e2e/web/src/web.test.ts @@ -95,7 +95,7 @@ describe('Web Components Applications', () => { checkFilesExist(`dist/apps/_should_not_remove.txt`); // Asset that React runtime is imported - expect(readFile(`dist/libs/${libName}/index.esm.js`)).toMatch( + expect(readFile(`dist/libs/${libName}/index.js`)).toMatch( /react\/jsx-runtime/ ); diff --git a/e2e/workspace-integrations/src/cache.test.ts b/e2e/workspace-integrations/src/cache.test.ts index 3c359640ed686..6eb953ff26674 100644 --- a/e2e/workspace-integrations/src/cache.test.ts +++ b/e2e/workspace-integrations/src/cache.test.ts @@ -168,7 +168,7 @@ describe('cache', () => { }; config.targets.build = { executor: '@nrwl/workspace:run-commands', - outputs: [`dist/libs/${mylib1}/index.esm.js`], + outputs: [`dist/libs/${mylib1}/index.js`], options: { commands: [ { @@ -193,7 +193,7 @@ describe('cache', () => { expect(outputWithBuildTasksCached).toContain('cache'); expectCached(outputWithBuildTasksCached, [mylib1]); // Ensure that only the specific file in outputs was copied to cache - expect(listFiles(`dist/libs/${mylib1}`)).toEqual([`index.esm.js`]); + expect(listFiles(`dist/libs/${mylib1}`)).toEqual([`index.js`]); }, 120000); function expectCached( diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index d9381dfc79961..8085730a30756 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -25,6 +25,14 @@ export interface PackageJson { name: string; version: string; scripts?: Record; + type?: 'module' | 'commonjs'; + main?: string; + types?: string; + module?: string; + exports?: Record< + string, + { types?: string; require?: string; import?: string } + >; dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; diff --git a/packages/web/src/executors/rollup/lib/update-package-json.spec.ts b/packages/web/src/executors/rollup/lib/update-package-json.spec.ts new file mode 100644 index 0000000000000..b41fe3f08c91e --- /dev/null +++ b/packages/web/src/executors/rollup/lib/update-package-json.spec.ts @@ -0,0 +1,156 @@ +import { updatePackageJson } from './update-package-json'; +import * as utils from 'nx/src/utils/fileutils'; +import { PackageJson } from 'nx/src/utils/package-json'; + +jest.mock('nx/src/utils/fileutils', () => ({ + writeJsonFile: () => {}, +})); + +describe('updatePackageJson', () => { + const commonOptions = { + outputPath: 'dist/index.js', + tsConfig: './tsconfig.json', + project: './package.json', + entryFile: './index.js', + entryRoot: '.', + projectRoot: '.', + assets: [], + rollupConfig: [], + }; + + const sharedContext = { + isVerbose: false, + workspace: { version: 2, projects: {} }, + root: '', + cwd: '', + }; + + it('should support ESM', () => { + const spy = jest.spyOn(utils, 'writeJsonFile'); + + updatePackageJson( + { + ...commonOptions, + format: ['esm'], + }, + sharedContext, + { type: 'app', name: 'test', data: {} }, + [], + {} as unknown as PackageJson + ); + + expect(utils.writeJsonFile).toHaveBeenCalledWith(expect.anything(), { + exports: { + '.': { + types: './index.d.ts', + import: './index.js', + }, + }, + main: './index.js', + module: './index.js', + type: 'module', + types: './index.d.ts', + }); + + spy.mockRestore(); + }); + + it('should support CJS', () => { + const spy = jest.spyOn(utils, 'writeJsonFile'); + + updatePackageJson( + { + ...commonOptions, + format: ['cjs'], + }, + sharedContext, + { type: 'app', name: 'test', data: {} }, + [], + {} as unknown as PackageJson + ); + + expect(utils.writeJsonFile).toHaveBeenCalledWith(expect.anything(), { + exports: { + '.': { + types: './index.d.ts', + require: './index.cjs', + }, + }, + main: './index.cjs', + type: 'commonjs', + types: './index.d.ts', + }); + + spy.mockRestore(); + }); + + it('should support ESM + CJS', () => { + const spy = jest.spyOn(utils, 'writeJsonFile'); + + updatePackageJson( + { + ...commonOptions, + format: ['esm', 'cjs'], + }, + sharedContext, + { type: 'app', name: 'test', data: {} }, + [], + {} as unknown as PackageJson + ); + + expect(utils.writeJsonFile).toHaveBeenCalledWith(expect.anything(), { + exports: { + '.': { + types: './index.d.ts', + import: './index.js', + require: './index.cjs', + }, + }, + main: './index.cjs', + module: './index.js', + type: 'module', + types: './index.d.ts', + }); + + spy.mockRestore(); + }); + + it('should support custom exports field', () => { + const spy = jest.spyOn(utils, 'writeJsonFile'); + + updatePackageJson( + { + ...commonOptions, + format: ['esm'], + }, + sharedContext, + { type: 'app', name: 'test', data: {} }, + [], + { + exports: { + foo: { + import: './foo.js', + }, + }, + } as unknown as PackageJson + ); + + expect(utils.writeJsonFile).toHaveBeenCalledWith(expect.anything(), { + exports: { + '.': { + types: './index.d.ts', + import: './index.js', + }, + foo: { + import: './foo.js', + }, + }, + main: './index.js', + module: './index.js', + type: 'module', + types: './index.d.ts', + }); + + spy.mockRestore(); + }); +}); diff --git a/packages/web/src/executors/rollup/lib/update-package-json.ts b/packages/web/src/executors/rollup/lib/update-package-json.ts new file mode 100644 index 0000000000000..d0537ff012db9 --- /dev/null +++ b/packages/web/src/executors/rollup/lib/update-package-json.ts @@ -0,0 +1,76 @@ +import { relative } from 'path'; +import { ExecutorContext } from 'nx/src/config/misc-interfaces'; +import { ProjectGraphProjectNode } from 'nx/src/config/project-graph'; +import { + DependentBuildableProjectNode, + updateBuildableProjectPackageJsonDependencies, +} from '@nrwl/workspace/src/utilities/buildable-libs-utils'; +import { writeJsonFile } from 'nx/src/utils/fileutils'; +import { PackageJson } from 'nx/src/utils/package-json'; +import { NormalizedWebRollupOptions } from './normalize'; + +export function updatePackageJson( + options: NormalizedWebRollupOptions, + context: ExecutorContext, + target: ProjectGraphProjectNode, + dependencies: DependentBuildableProjectNode[], + packageJson: PackageJson +) { + const hasEsmFormat = options.format.includes('esm'); + const hasCjsFormat = + options.format.includes('umd') || options.format.includes('cjs'); + + const types = `./${relative(options.entryRoot, options.entryFile).replace( + /\.[jt]sx?$/, + '.d.ts' + )}`; + const exports = { + // TS 4.5+ + '.': { + types, + }, + }; + + if (hasEsmFormat) { + // `module` field is used by bundlers like rollup and webpack to detect ESM. + // May not be required in the future if type is already "module". + packageJson.module = './index.js'; + exports['.']['import'] = './index.js'; + + if (!hasCjsFormat) { + packageJson.main = './index.js'; + } + } + + if (hasCjsFormat) { + packageJson.main = './index.cjs'; + exports['.']['require'] = './index.cjs'; + } + + packageJson.type = options.format.includes('esm') ? 'module' : 'commonjs'; + + // Support for older TS versions < 4.5 + packageJson.types = types; + + packageJson.exports = { + ...packageJson.exports, + ...exports, + }; + + writeJsonFile(`${options.outputPath}/package.json`, packageJson); + + if ( + dependencies.length > 0 && + options.updateBuildableProjectDepsInPackageJson + ) { + updateBuildableProjectPackageJsonDependencies( + context.root, + context.projectName, + context.targetName, + context.configurationName, + target, + dependencies, + options.buildableProjectDepsInPackageJsonType + ); + } +} diff --git a/packages/web/src/executors/rollup/rollup.impl.spec.ts b/packages/web/src/executors/rollup/rollup.impl.spec.ts index 4b44d12c7a128..d8db74dc0bdde 100644 --- a/packages/web/src/executors/rollup/rollup.impl.spec.ts +++ b/packages/web/src/executors/rollup/rollup.impl.spec.ts @@ -30,7 +30,7 @@ describe('rollupExecutor', () => { project: 'libs/ui/package.json', tsConfig: 'libs/ui/tsconfig.json', watch: false, - format: ['esm', 'umd'], + format: ['esm', 'cjs'], }; }); @@ -52,17 +52,17 @@ describe('rollupExecutor', () => { globals: { 'react/jsx-runtime': 'jsxRuntime' }, name: 'Example', inlineDynamicImports: false, - chunkFileNames: '[name].esm.js', - entryFileNames: '[name].esm.js', + chunkFileNames: '[name].js', + entryFileNames: '[name].js', }, { dir: '/root/dist/ui', - format: 'umd', + format: 'cjs', globals: { 'react/jsx-runtime': 'jsxRuntime' }, name: 'Example', - inlineDynamicImports: true, - chunkFileNames: '[name].umd.js', - entryFileNames: '[name].umd.js', + inlineDynamicImports: false, + chunkFileNames: '[name].cjs', + entryFileNames: '[name].cjs', }, ]); }); diff --git a/packages/web/src/executors/rollup/rollup.impl.ts b/packages/web/src/executors/rollup/rollup.impl.ts index 4509492a20029..367b5882ce58e 100644 --- a/packages/web/src/executors/rollup/rollup.impl.ts +++ b/packages/web/src/executors/rollup/rollup.impl.ts @@ -1,19 +1,22 @@ import * as rollup from 'rollup'; import * as peerDepsExternal from 'rollup-plugin-peer-deps-external'; import { getBabelInputPlugin } from '@rollup/plugin-babel'; -import { join, relative } from 'path'; +import { join } from 'path'; import { from, Observable, of } from 'rxjs'; import { catchError, concatMap, last, scan, tap } from 'rxjs/operators'; import { eachValueFrom } from 'rxjs-for-await'; import * as autoprefixer from 'autoprefixer'; -import type { ExecutorContext, ProjectGraphProjectNode } from '@nrwl/devkit'; -import { logger, names, readJsonFile, writeJsonFile } from '@nrwl/devkit'; -import { readCachedProjectGraph } from '@nrwl/devkit'; +import type { ExecutorContext } from '@nrwl/devkit'; +import { + logger, + names, + readCachedProjectGraph, + readJsonFile, +} from '@nrwl/devkit'; import { calculateProjectDependencies, computeCompilerOptionsPaths, DependentBuildableProjectNode, - updateBuildableProjectPackageJsonDependencies, } from '@nrwl/workspace/src/utilities/buildable-libs-utils'; import resolve from '@rollup/plugin-node-resolve'; @@ -28,6 +31,7 @@ import { analyze } from './lib/analyze-plugin'; import { deleteOutputDir } from '../../utils/fs'; import { swc } from './lib/swc-plugin'; import { validateTypes } from './lib/validate-types'; +import { updatePackageJson } from './lib/update-package-json'; // These use require because the ES import isn't correct. const commonjs = require('@rollup/plugin-commonjs'); @@ -59,6 +63,17 @@ export default async function* rollupExecutor( context.root, sourceRoot ); + + // TODO(jack): Remove UMD in Nx 15 + if (options.format.includes('umd')) { + if (options.format.includes('cjs')) { + throw new Error( + 'Cannot use both UMD and CJS. We recommend you use ESM or CJS.' + ); + } else { + logger.warn('UMD format is deprecated and will be removed in Nx 15'); + } + } const packageJson = readJsonFile(options.project); const npmDeps = (projectGraph.dependencies[context.projectName] ?? []) @@ -261,8 +276,8 @@ export function createRollupOptions( format, dir: `${options.outputPath}`, name: options.umdName || names(context.projectName).className, - entryFileNames: `[name].${format}.js`, - chunkFileNames: `[name].${format}.js`, + entryFileNames: `[name].${format === 'esm' ? 'js' : 'cjs'}`, + chunkFileNames: `[name].${format === 'esm' ? 'js' : 'cjs'}`, // umd doesn't support code-split bundles inlineDynamicImports: format === 'umd', }, @@ -293,43 +308,6 @@ function createCompilerOptions(options, dependencies) { }; } -function updatePackageJson( - options: NormalizedWebRollupOptions, - context: ExecutorContext, - target: ProjectGraphProjectNode, - dependencies: DependentBuildableProjectNode[], - packageJson: any -) { - const entryFileTmpl = `./index.<%= extension %>.js`; - const typingsFile = relative(options.entryRoot, options.entryFile).replace( - /\.[jt]sx?$/, - '.d.ts' - ); - if (options.format.includes('umd')) { - packageJson.main = entryFileTmpl.replace('<%= extension %>', 'umd'); - } else if (options.format.includes('cjs')) { - packageJson.main = entryFileTmpl.replace('<%= extension %>', 'cjs'); - } - packageJson.module = entryFileTmpl.replace('<%= extension %>', 'esm'); - packageJson.typings = `./${typingsFile}`; - writeJsonFile(`${options.outputPath}/package.json`, packageJson); - - if ( - dependencies.length > 0 && - options.updateBuildableProjectDepsInPackageJson - ) { - updateBuildableProjectPackageJsonDependencies( - context.root, - context.projectName, - context.targetName, - context.configurationName, - target, - dependencies, - options.buildableProjectDepsInPackageJsonType - ); - } -} - interface RollupCopyAssetOption { src: string; dest: string; diff --git a/packages/web/src/executors/rollup/schema.json b/packages/web/src/executors/rollup/schema.json index d7014ccfc0192..34a74160ee194 100644 --- a/packages/web/src/executors/rollup/schema.json +++ b/packages/web/src/executors/rollup/schema.json @@ -33,7 +33,7 @@ "type": "string", "enum": ["esm", "umd", "cjs"] }, - "default": ["esm", "umd"] + "default": ["esm"] }, "external": { "type": "array", diff --git a/packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.spec.ts b/packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.spec.ts new file mode 100644 index 0000000000000..bab9348cfe3ab --- /dev/null +++ b/packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.spec.ts @@ -0,0 +1,54 @@ +import { addProjectConfiguration, readJson } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import update from './rollup-format-backwards-compatibility'; + +describe('rollup-format-backwards-compatibility', () => { + it('should add format options to match previous behavior if it does not exist', async () => { + const tree = createTreeWithEmptyWorkspace(2); + addProjectConfiguration(tree, 'proj1', { + root: 'proj1', + targets: { + build: { + executor: '@nrwl/web:rollup', + }, + }, + }); + + await update(tree); + + expect(readJson(tree, 'proj1/project.json').targets).toEqual({ + build: { + executor: '@nrwl/web:rollup', + options: { + format: ['esm', 'cjs'], + }, + }, + }); + }); + + it('should skip update if format exists', async () => { + const tree = createTreeWithEmptyWorkspace(2); + addProjectConfiguration(tree, 'proj1', { + root: 'proj1', + targets: { + build: { + executor: '@nrwl/web:rollup', + options: { + format: ['esm'], + }, + }, + }, + }); + + await update(tree); + + expect(readJson(tree, 'proj1/project.json').targets).toEqual({ + build: { + executor: '@nrwl/web:rollup', + options: { + format: ['esm'], + }, + }, + }); + }); +}); diff --git a/packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.ts b/packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.ts new file mode 100644 index 0000000000000..47b393e085c5b --- /dev/null +++ b/packages/web/src/migrations/update-14-1-7/rollup-format-backwards-compatibility.ts @@ -0,0 +1,24 @@ +import { + formatFiles, + getProjects, + Tree, + updateProjectConfiguration, +} from '@nrwl/devkit'; + +/* + * + */ +export default async function update(tree: Tree) { + for (const [name, config] of getProjects(tree)) { + if (config.targets?.build?.executor !== '@nrwl/web:rollup') continue; + if (Array.isArray(config.targets.build.options?.format)) continue; + + config.targets.build.options = { + ...config.targets.build.options, + format: ['esm', 'cjs'], + }; + + updateProjectConfiguration(tree, name, config); + } + await formatFiles(tree); +}