Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions tools/workspace-plugin/src/executors/build/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -33,6 +34,8 @@ const runExecutor: PromiseExecutor<BuildExecutorSchema> = async (schema, context
},
),
() => copyAssets(assetFiles),
() => postprocessCjsExtension(options),
() => copyCjsTypes(options),
);

measureEnd('BuildExecutor');
Expand Down
113 changes: 113 additions & 0 deletions tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
125 changes: 125 additions & 0 deletions tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<boolean> {
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<boolean> {
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;
}
42 changes: 30 additions & 12 deletions tools/workspace-plugin/src/executors/generate-api/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,46 @@ import type { PackageJson } from '../../../types';
import type { NormalizedOptions } from '../executor';
import { verboseLog } from './shared';

function isTypedEntry(exportValue: unknown): exportValue is { types: string } & Record<string, unknown> {
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<string, unknown>;
if (typeof value.types === 'string') {
return value.types;
}
const importCondition = value.import;
if (
typeof importCondition === 'object' &&
importCondition !== null &&
typeof (importCondition as Record<string, unknown>).types === 'string'
) {
return (importCondition as Record<string, unknown>).types as string;
}
return undefined;
}

function isTypedEntry(exportValue: unknown): exportValue is Record<string, unknown> {
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<string, unknown> {
function isWildcardTypedEntry(exportKey: string, exportValue: unknown): exportValue is Record<string, unknown> {
return exportKey.includes('*') && isTypedEntry(exportValue);
}

/**
* 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<string, unknown> {
function isNamedTypedEntry(exportKey: string, exportValue: unknown): exportValue is Record<string, unknown> {
if (exportKey === '.' || exportKey === './package.json' || exportKey.includes('*')) {
return false;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
13 changes: 12 additions & 1 deletion tools/workspace-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface TsConfig {

export interface PackageJson {
bin?: string | Record<string, string>;
type?: 'module' | 'commonjs';
types?: string;
typings?: string;
private?: boolean;
Expand All @@ -47,7 +48,17 @@ export interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
exports?: Record<string, string | Partial<{ types: string; node: string; import: string; require: string }>>;
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 {
Expand Down
Loading