diff --git a/tools/workspace-plugin/src/executors/build/executor.ts b/tools/workspace-plugin/src/executors/build/executor.ts index fddb58be2f1467..6f7d1bace5ee78 100644 --- a/tools/workspace-plugin/src/executors/build/executor.ts +++ b/tools/workspace-plugin/src/executors/build/executor.ts @@ -4,6 +4,7 @@ import { compileSwc } from './lib/swc'; import { compileWithGriffelStylesAOT, compileWithReactCompiler, hasStylesFilesToProcess } from './lib/babel'; import { assetGlobsToFiles, copyAssets } from './lib/assets'; import { cleanOutput } from './lib/clean'; +import { postprocessCjsExtension, copyCjsTypes } from './lib/cjs-extension'; import { NormalizedOptions, normalizeOptions, processAsyncQueue, runInParallel, runSerially } from './lib/shared'; import { measureEnd, measureStart } from '../../utils'; @@ -33,6 +34,8 @@ const runExecutor: PromiseExecutor = async (schema, context }, ), () => copyAssets(assetFiles), + () => postprocessCjsExtension(options), + () => copyCjsTypes(options), ); measureEnd('BuildExecutor'); diff --git a/tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts new file mode 100644 index 00000000000000..b6c683829e5569 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts @@ -0,0 +1,113 @@ +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { logger } from '@nx/devkit'; + +import { type NormalizedOptions } from './shared'; +import { postprocessCjsExtension, copyCjsTypes } from './cjs-extension'; + +async function exists(path: string) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * The `cjs-extension` postprocessors only act on `"type": "module"` packages. For every other + * package they are a no-op, which keeps them safe to wire into the build executor before any + * package opts into ESM-first packaging. + */ +describe('cjs-extension', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), 'cjs-extension-')); + jest.spyOn(logger, 'log').mockImplementation(() => { + return; + }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + function createOptions(): NormalizedOptions { + return { + absoluteProjectRoot: projectRoot, + moduleOutput: [ + { module: 'es6', outputPath: 'lib' }, + { module: 'commonjs', outputPath: 'lib-commonjs' }, + ], + } as unknown as NormalizedOptions; + } + + describe('postprocessCjsExtension', () => { + it('is a no-op when the package is not "type": "module"', async () => { + await writeFile(join(projectRoot, 'package.json'), JSON.stringify({ name: 'proj' })); + await mkdir(join(projectRoot, 'lib-commonjs')); + await writeFile(join(projectRoot, 'lib-commonjs/index.js'), `require("./other.js");`); + + const result = await postprocessCjsExtension(createOptions()); + + expect(result).toBe(true); + expect(await exists(join(projectRoot, 'lib-commonjs/index.js'))).toBe(true); + expect(await exists(join(projectRoot, 'lib-commonjs/index.cjs'))).toBe(false); + }); + + it('renames *.js -> *.cjs, rewrites relative requires and renames source maps', async () => { + await writeFile(join(projectRoot, 'package.json'), JSON.stringify({ name: 'proj', type: 'module' })); + await mkdir(join(projectRoot, 'lib-commonjs')); + await writeFile( + join(projectRoot, 'lib-commonjs/index.js'), + [`var other = require("./other.js");`, `//# sourceMappingURL=index.js.map`].join('\n'), + ); + await writeFile(join(projectRoot, 'lib-commonjs/index.js.map'), JSON.stringify({ file: 'index.js' })); + await writeFile(join(projectRoot, 'lib-commonjs/other.js'), `module.exports = {};`); + + const result = await postprocessCjsExtension(createOptions()); + + expect(result).toBe(true); + + const files = (await readdir(join(projectRoot, 'lib-commonjs'))).sort(); + expect(files).toEqual(['index.cjs', 'index.cjs.map', 'other.cjs']); + + const index = await readFile(join(projectRoot, 'lib-commonjs/index.cjs'), 'utf-8'); + expect(index).toContain(`require("./other.cjs")`); + expect(index).toContain(`//# sourceMappingURL=index.cjs.map`); + + const map = JSON.parse(await readFile(join(projectRoot, 'lib-commonjs/index.cjs.map'), 'utf-8')); + expect(map.file).toBe('index.cjs'); + }); + }); + + describe('copyCjsTypes', () => { + it('is a no-op when the package is not "type": "module"', async () => { + await writeFile(join(projectRoot, 'package.json'), JSON.stringify({ name: 'proj' })); + await mkdir(join(projectRoot, 'dist')); + await writeFile(join(projectRoot, 'dist/index.d.ts'), `export declare const a: string;`); + + const result = await copyCjsTypes(createOptions()); + + expect(result).toBe(true); + expect(await exists(join(projectRoot, 'dist/index.d.cts'))).toBe(false); + }); + + it('copies rolled *.d.ts -> *.d.cts for "type": "module" packages', async () => { + await writeFile(join(projectRoot, 'package.json'), JSON.stringify({ name: 'proj', type: 'module' })); + await mkdir(join(projectRoot, 'dist')); + const dts = `export declare const a: string;`; + await writeFile(join(projectRoot, 'dist/index.d.ts'), dts); + + const result = await copyCjsTypes(createOptions()); + + expect(result).toBe(true); + expect(await exists(join(projectRoot, 'dist/index.d.cts'))).toBe(true); + expect(await readFile(join(projectRoot, 'dist/index.d.cts'), 'utf-8')).toBe(dts); + }); + }); +}); diff --git a/tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts new file mode 100644 index 00000000000000..b211c5b1b8394f --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts @@ -0,0 +1,125 @@ +import { readFile, writeFile, rm, readdir, access, copyFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { readJsonFile, logger } from '@nx/devkit'; + +import { type NormalizedOptions } from './shared'; + +// rewrite only RELATIVE specifiers (./ or ../) ending in .js -> .cjs +const RELATIVE_REQUIRE = /(require\(\s*["'])(\.[^"']+?)\.js(["']\s*\))/g; +const RELATIVE_SOURCEMAP = /(\/\/#\s*sourceMappingURL=)(\.?[^\s]+?)\.js\.map/g; + +async function exists(path: string) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function* walk(dir: string): AsyncGenerator { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(p); + } else { + yield p; + } + } +} + +/** + * When a package ships as `"type": "module"`, the CommonJS output (`lib-commonjs`) must use the + * `.cjs` extension - otherwise Node would parse those `.js` files as ESM and fail. + * + * This renames every `lib-commonjs/**\/*.js` -> `*.cjs` (incl. `*.styles.raw.js`), rewrites relative + * `require("./x.js")` -> `require("./x.cjs")`, and renames the adjacent `*.js.map` -> `*.cjs.map`. + * + * No-op for packages that are not `"type": "module"`. + */ +export async function postprocessCjsExtension(options: NormalizedOptions): Promise { + const pkgJson = readJsonFile<{ type?: string }>(join(options.absoluteProjectRoot, 'package.json')); + if (pkgJson.type !== 'module') { + return true; + } + + const commonjsOutput = options.moduleOutput.find(output => output.module === 'commonjs'); + if (!commonjsOutput) { + return true; + } + + const cjsDir = join(options.absoluteProjectRoot, commonjsOutput.outputPath); + if (!(await exists(cjsDir))) { + return true; + } + + let renamedFiles = 0; + let renamedMaps = 0; + + for await (const file of walk(cjsDir)) { + if (file.endsWith('.js')) { + const code = (await readFile(file, 'utf-8')) + .replace(RELATIVE_REQUIRE, '$1$2.cjs$3') + .replace(RELATIVE_SOURCEMAP, '$1$2.cjs.map'); + await writeFile(file.replace(/\.js$/, '.cjs'), code); + await rm(file); + renamedFiles++; + } + } + + for await (const file of walk(cjsDir)) { + if (file.endsWith('.js.map')) { + const map = JSON.parse(await readFile(file, 'utf-8')); + if (typeof map.file === 'string') { + map.file = map.file.replace(/\.js$/, '.cjs'); + } + await writeFile(file.replace(/\.js\.map$/, '.cjs.map'), JSON.stringify(map)); + await rm(file); + renamedMaps++; + } + } + + logger.log( + `📦 CJS extension: ${renamedFiles} *.js → *.cjs (${renamedMaps} source maps) in ${commonjsOutput.outputPath}`, + ); + + return true; +} + +/** + * `type: module` packages expose CommonJS types under a `.d.cts` so that `require`-path consumers + * (TypeScript `node16`/`nodenext`) get a CommonJS-flavoured declaration matching the `.cjs` runtime + * file, instead of the ESM-flavoured `.d.ts` (which `@arethetypeswrong/cli` flags as "masquerading"). + * + * Our `dist/*.d.ts` are rolled single-file declarations (api-extractor) using only `export`/`export + * declare` syntax, so a verbatim copy to `.d.cts` is valid in a CommonJS declaration context. + * + * No-op for packages that are not `"type": "module"`. + */ +export async function copyCjsTypes(options: NormalizedOptions): Promise { + const pkgJson = readJsonFile<{ type?: string }>(join(options.absoluteProjectRoot, 'package.json')); + if (pkgJson.type !== 'module') { + return true; + } + + const distDir = join(options.absoluteProjectRoot, 'dist'); + if (!(await exists(distDir))) { + return true; + } + + let copied = 0; + for await (const file of walk(distDir)) { + // copy rolled declarations only (e.g. `index.d.ts`, `unstable.d.ts`), skip `.d.cts`/maps + if (file.endsWith('.d.ts')) { + await copyFile(file, file.replace(/\.d\.ts$/, '.d.cts')); + copied++; + } + } + + if (copied > 0) { + logger.log(`📦 CJS types: ${copied} *.d.ts → *.d.cts in dist`); + } + + return true; +} diff --git a/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts index bc169d7fabb7b5..ae526525a0645c 100644 --- a/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts +++ b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts @@ -8,17 +8,38 @@ import type { PackageJson } from '../../../types'; import type { NormalizedOptions } from '../executor'; import { verboseLog } from './shared'; -function isTypedEntry(exportValue: unknown): exportValue is { types: string } & Record { - return typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; +/** + * Resolves the declaration (`.d.ts`) types path from an export entry, supporting both the legacy flat + * shape (`{ types }`) and the ESM-first nested shape (`{ import: { types } }`). The `import` condition + * always points at the ESM `.d.ts` rollup, which is what api-extractor consumes. + */ +function getEntryTypes(exportValue: unknown): string | undefined { + if (typeof exportValue !== 'object' || exportValue === null) { + return undefined; + } + const value = exportValue as Record; + if (typeof value.types === 'string') { + return value.types; + } + const importCondition = value.import; + if ( + typeof importCondition === 'object' && + importCondition !== null && + typeof (importCondition as Record).types === 'string' + ) { + return (importCondition as Record).types as string; + } + return undefined; +} + +function isTypedEntry(exportValue: unknown): exportValue is Record { + return getEntryTypes(exportValue) !== undefined; } /** * Checks whether a single export map entry is a wildcard entry with a `types` field. */ -function isWildcardTypedEntry( - exportKey: string, - exportValue: unknown, -): exportValue is { types: string } & Record { +function isWildcardTypedEntry(exportKey: string, exportValue: unknown): exportValue is Record { return exportKey.includes('*') && isTypedEntry(exportValue); } @@ -26,10 +47,7 @@ function isWildcardTypedEntry( * Checks whether a single export map entry is a named (non-wildcard, non-root) entry with a `types` field. * Skips `"."` and `"./package.json"`. */ -function isNamedTypedEntry( - exportKey: string, - exportValue: unknown, -): exportValue is { types: string } & Record { +function isNamedTypedEntry(exportKey: string, exportValue: unknown): exportValue is Record { if (exportKey === '.' || exportKey === './package.json' || exportKey.includes('*')) { return false; } @@ -61,7 +79,7 @@ export function getExportSubpathConfigs(options: NormalizedOptions): IConfigFile for (const [exportKey, exportValue] of Object.entries(exports)) { // Wildcard entries: expand into sub-directories if (isWildcardTypedEntry(exportKey, exportValue)) { - const pathPrefixes = parseWildcardTypesPattern(exportValue.types); + const pathPrefixes = parseWildcardTypesPattern(getEntryTypes(exportValue)!); if (!pathPrefixes) { continue; } @@ -88,7 +106,7 @@ export function getExportSubpathConfigs(options: NormalizedOptions): IConfigFile // Named entries: create config directly from types field if (isNamedTypedEntry(exportKey, exportValue)) { - const parsed = parseNamedTypesPattern(exportValue.types); + const parsed = parseNamedTypesPattern(getEntryTypes(exportValue)!); if (!parsed) { continue; } diff --git a/tools/workspace-plugin/src/types.ts b/tools/workspace-plugin/src/types.ts index 1b0e524daf29fb..3fb7f43d0099c9 100644 --- a/tools/workspace-plugin/src/types.ts +++ b/tools/workspace-plugin/src/types.ts @@ -21,6 +21,7 @@ export interface TsConfig { export interface PackageJson { bin?: string | Record; + type?: 'module' | 'commonjs'; types?: string; typings?: string; private?: boolean; @@ -47,7 +48,17 @@ export interface PackageJson { dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; - exports?: Record>; + exports?: Record< + string, + | string + | Partial<{ + types: string; + style: string; + node: string | { module: string; default: string }; + import: string | { types: string; default: string }; + require: string | { types: string; default: string }; + }> + >; } export interface PackageJsonWithBeachball extends PackageJson {