From e1d50e2ce5f210862ad4e0240fe5b19316925e98 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 24 Dec 2024 12:35:52 +0100 Subject: [PATCH] cli: add module support for rollup build Signed-off-by: Patrik Oldsberg --- packages/cli-node/report.api.md | 2 + .../cli-node/src/monorepo/PackageGraph.ts | 2 + packages/cli/src/lib/builder/config.ts | 99 +++++++++++++++++-- packages/cli/src/lib/builder/packager.ts | 2 +- .../tests/transforms/__fixtures__/.gitignore | 1 + .../__fixtures__/pkg-default/package.json | 5 +- .../__fixtures__/pkg-module/main.ts | 4 +- .../src/tests/transforms/transforms.test.ts | 78 ++++++++++++++- 8 files changed, 180 insertions(+), 13 deletions(-) diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 7c1a3bc744..bec25ea251 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -72,6 +72,8 @@ export interface BackstagePackageJson { [key: string]: string; }; // (undocumented) + type?: 'module' | 'commonjs'; + // (undocumented) types?: string; // (undocumented) typesVersions?: Record>; diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 721aa79e50..1da896a057 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -43,6 +43,8 @@ export interface BackstagePackageJson { // that the package bundles all of its dependencies in its build output. bundled?: boolean; + type?: 'module' | 'commonjs'; + backstage?: { role?: PackageRole; moved?: string; diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index e419503649..c52efd6c2d 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -16,7 +16,11 @@ import chalk from 'chalk'; import fs from 'fs-extra'; -import { relative as relativePath, resolve as resolvePath } from 'path'; +import { + extname, + relative as relativePath, + resolve as resolvePath, +} from 'path'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import postcss from 'rollup-plugin-postcss'; @@ -29,6 +33,7 @@ import { RollupOptions, OutputOptions, WarningHandlerWithDefault, + OutputPlugin, } from 'rollup'; import { forwardFileImports } from './plugins'; @@ -40,6 +45,11 @@ import { readEntryPoints } from '../entryPoints'; const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx']; +const MODULE_EXTS = ['.mjs', '.mts']; +const COMMONJS_EXTS = ['.cjs', '.cts']; +const MOD_EXT = '.mjs'; +const CJS_EXT = '.cjs'; + function isFileImport(source: string) { if (source.startsWith('.')) { return true; @@ -68,6 +78,39 @@ function buildInternalImportPattern(options: BuildOptions) { return new RegExp(`^(?:${names.join('|')})(?:$|/)`); } +// This Rollup output plugin enables support for mixed CommonJS and ESM output. +// It does it be filtering out the unwanted output files that don't match the +// input file format, allowing the rollup configuration to have overlapping +// output configurations for different formats. +function multiOutputFormat(): OutputPlugin { + return { + name: 'backstage-multi-output-format', + generateBundle(opts, bundle) { + const filter: (name: string) => boolean = + opts.format === 'cjs' + ? s => s.endsWith(MOD_EXT) + : s => !s.endsWith(MOD_EXT); + + // Delete any files that don't match the current output format + for (const name in bundle) { + if (filter(name)) { + delete bundle[name]; + delete bundle[`${name}.map`]; + } + } + }, + renderDynamicImport(opts) { + if (opts.format === 'cjs') { + return { + left: 'import(', + right: ')', + }; + } + return undefined; + }, + }; +} + export async function makeRollupConfigs( options: BuildOptions, ): Promise { @@ -120,18 +163,46 @@ export async function makeRollupConfigs( const rewriteNodeModules = (name: string) => name.replaceAll('node_modules', 'node_modules_dist'); + // For CommonJS we build both CommonJS and ESM output. Each of these outputs + // can output both .cjs and .mjs files. The files from each of these outputs + // will overlap, but we trim away files where the format doesn't match the + // file extensions. That way we are left with a combination of .cjs and .mjs + // files where the module format in the file matches the file extension. if (options.outputs.has(Output.cjs)) { - output.push({ + const defaultExt = targetPkg.type === 'module' ? MOD_EXT : CJS_EXT; + const outputOpts: OutputOptions = { dir: distDir, - entryFileNames: chunkInfo => - `${rewriteNodeModules(chunkInfo.name)}.cjs.js`, - chunkFileNames: `cjs/[name]-[hash].cjs.js`, - format: 'commonjs', - interop: 'compat', + entryFileNames(chunkInfo) { + const cleanName = rewriteNodeModules(chunkInfo.name); + + const inputId = chunkInfo.facadeModuleId; + if (!inputId) { + return cleanName + defaultExt; + } + + const inputExt = extname(inputId); + if (MODULE_EXTS.includes(inputExt)) { + return cleanName + MOD_EXT; + } + if (COMMONJS_EXTS.includes(inputExt)) { + return cleanName + CJS_EXT; + } + return cleanName + defaultExt; + }, sourcemap: true, preserveModules: true, preserveModulesRoot: `${targetDir}/src`, exports: 'named', + plugins: [multiOutputFormat()], + }; + + output.push({ + ...outputOpts, + format: 'cjs', + }); + output.push({ + ...outputOpts, + format: 'module', }); } if (options.outputs.has(Output.esm)) { @@ -160,7 +231,19 @@ export async function makeRollupConfigs( // All module imports are always marked as external external, plugins: [ - resolve({ mainFields }), + resolve({ + mainFields, + extensions: [ + '.ts', + '.js', + '.tsx', + '.jsx', + '.mts', + '.cts', + '.mjs', + '.cjs', + ], + }), commonjs({ include: /node_modules/, exclude: [/\/[^/]+\.(?:stories|test)\.[^/]+$/], diff --git a/packages/cli/src/lib/builder/packager.ts b/packages/cli/src/lib/builder/packager.ts index be9bc9a6ea..b709cb0408 100644 --- a/packages/cli/src/lib/builder/packager.ts +++ b/packages/cli/src/lib/builder/packager.ts @@ -107,7 +107,7 @@ export const buildPackage = async (options: BuildOptions) => { const rollupConfigs = await makeRollupConfigs(options); - await fs.remove(paths.resolveTarget('dist')); + await fs.remove(resolvePath(options.targetDir ?? paths.targetDir, 'dist')); const buildTasks = rollupConfigs.map(rollupBuild); diff --git a/packages/cli/src/tests/transforms/__fixtures__/.gitignore b/packages/cli/src/tests/transforms/__fixtures__/.gitignore index cf4bab9ddd..dd13a98e05 100644 --- a/packages/cli/src/tests/transforms/__fixtures__/.gitignore +++ b/packages/cli/src/tests/transforms/__fixtures__/.gitignore @@ -1 +1,2 @@ !node_modules +dist diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/package.json b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/package.json index 9881bbf3a8..1bfa22188c 100644 --- a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/package.json +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/package.json @@ -1,3 +1,6 @@ { - "name": "pkg-default" + "name": "pkg-default", + "exports": { + ".": "./main.ts" + } } diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main.ts index 7b40a259f6..4c6c8f0079 100644 --- a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main.ts +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main.ts @@ -21,10 +21,10 @@ import * as depModule from 'dep-module'; import * as depDefault from 'dep-default'; import { value as namedA } from './a-named'; import { value as namedB } from './b-named'; -import cNamed from './c-named'; +import * as cNamed from './c-named'; import defaultA from './a-default'; import defaultB from './b-default'; -import cDefault from './c-default'; +import * as cDefault from './c-default'; const { default: defaultC } = cDefault; const { value: namedC } = cNamed; diff --git a/packages/cli/src/tests/transforms/transforms.test.ts b/packages/cli/src/tests/transforms/transforms.test.ts index ac67069b77..6f9c291bc7 100644 --- a/packages/cli/src/tests/transforms/transforms.test.ts +++ b/packages/cli/src/tests/transforms/transforms.test.ts @@ -16,6 +16,7 @@ import { execFileSync } from 'child_process'; import { resolve as resolvePath } from 'path'; +import { Output, buildPackage } from '../../lib/builder'; const exportValues = { all: { @@ -95,7 +96,12 @@ describe('node runtime module transforms', () => { dynCommonJs: expectedExports.commonJs, dynDefault: expectedExports.commonJs, dynModule: expectedExports.module, - dep: exportValues.all, + // TODO(Rugvip): Fix CommonJS import compat from modules + dep: { + ...exportValues.all, + defaultC: { default: 'c' }, + namedC: undefined, + }, dyn: exportValues.all, }); }); @@ -156,3 +162,73 @@ describe('Jest runtime module transforms', () => { }); }); }); + +describe('package build transforms', () => { + it('should build and load from commonjs format', async () => { + const pkgPath = resolvePath(__dirname, '__fixtures__/pkg-commonjs'); + + await buildPackage({ + targetDir: pkgPath, + outputs: new Set([Output.cjs]), + workspacePackages: [], + }); + const values = await import(resolvePath(pkgPath, 'dist/index.cjs')).then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should build and load from default format', async () => { + const pkgPath = resolvePath(__dirname, '__fixtures__/pkg-default'); + + await buildPackage({ + targetDir: pkgPath, + outputs: new Set([Output.cjs]), + workspacePackages: [], + }); + const values = await import(resolvePath(pkgPath, 'dist/index.cjs')).then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should build and load from module format', async () => { + const pkgPath = resolvePath(__dirname, '__fixtures__/pkg-module'); + + await buildPackage({ + targetDir: pkgPath, + outputs: new Set([Output.cjs]), + workspacePackages: [], + }); + const values = await import(resolvePath(pkgPath, 'dist/index.mjs')).then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + depModule: expectedExports.module, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + // TODO(Rugvip): Fix CommonJS import compat from modules + dep: { ...exportValues.all, defaultC: { default: 'c' } }, + dyn: exportValues.all, + }); + }); +});