@knighted/duel
Version:
TypeScript dual packages.
173 lines (172 loc) • 7.57 kB
JavaScript
import { argv, platform } from 'node:process';
import { join, dirname, resolve, relative } from 'node:path';
import { spawn } from 'node:child_process';
import { writeFile, rm, rename, mkdir, cp } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { glob } from 'glob';
import { findUp, pathExists } from 'find-up';
import { specifier } from '@knighted/specifier';
import { transform } from '@knighted/module';
import { init } from './init.js';
import { getRealPathAsFileUrl, getCompileFiles, logError, log } from './util.js';
const handleErrorAndExit = message => {
const exitCode = Number(message);
logError('Compilation errors found.');
process.exit(exitCode);
};
const duel = async (args) => {
const ctx = await init(args);
if (ctx) {
const { projectDir, tsconfig, configPath, modules, dirs, pkg } = ctx;
const tsc = await findUp(async (dir) => {
const tscBin = join(dir, 'node_modules', '.bin', 'tsc');
if (await pathExists(tscBin)) {
return tscBin;
}
}, { cwd: projectDir });
const runBuild = (project, outDir) => {
return new Promise((resolve, reject) => {
const args = outDir ? ['-p', project, '--outDir', outDir] : ['-p', project];
const build = spawn(tsc, args, { stdio: 'inherit', shell: platform === 'win32' });
build.on('exit', code => {
if (code > 0) {
return reject(new Error(code));
}
resolve(code);
});
});
};
const pkgDir = dirname(pkg.path);
const outDir = tsconfig.compilerOptions?.outDir ?? 'dist';
const absoluteOutDir = resolve(projectDir, outDir);
const originalType = pkg.packageJson.type ?? 'commonjs';
const isCjsBuild = originalType !== 'commonjs';
const targetExt = isCjsBuild ? '.cjs' : '.mjs';
const hex = randomBytes(4).toString('hex');
const getOverrideTsConfig = () => {
return {
...tsconfig,
compilerOptions: {
...tsconfig.compilerOptions,
module: 'NodeNext',
moduleResolution: 'NodeNext',
},
};
};
const runPrimaryBuild = () => {
return runBuild(configPath, dirs
? isCjsBuild
? join(absoluteOutDir, 'esm')
: join(absoluteOutDir, 'cjs')
: absoluteOutDir);
};
const updateSpecifiersAndFileExtensions = async (filenames) => {
for (const filename of filenames) {
const dts = /(\.d\.ts)$/;
const outFilename = dts.test(filename)
? filename.replace(dts, isCjsBuild ? '.d.cts' : '.d.mts')
: filename.replace(/\.js$/, targetExt);
const update = await specifier.update(filename, ({ value }) => {
// Collapse any BinaryExpression or NewExpression to test for a relative specifier
const collapsed = value.replace(/['"`+)\s]|new String\(/g, '');
const relative = /^(?:\.|\.\.)\//;
if (relative.test(collapsed)) {
// $2 is for any closing quotation/parens around BE or NE
return value.replace(/(.+)\.js([)'"`]*)?$/, `$1${targetExt}$2`);
}
});
await writeFile(outFilename, update);
await rm(filename, { force: true });
}
};
const logSuccess = start => {
log(`Successfully created a dual ${isCjsBuild ? 'CJS' : 'ESM'} build in ${Math.round(performance.now() - start)}ms.`);
};
log('Starting primary build...');
let success = false;
const startTime = performance.now();
try {
await runPrimaryBuild();
success = true;
}
catch ({ message }) {
handleErrorAndExit(message);
}
if (success) {
const subDir = join(projectDir, `_${hex}_`);
const absoluteDualOutDir = join(projectDir, isCjsBuild ? join(outDir, 'cjs') : join(outDir, 'esm'));
const tsconfigDual = getOverrideTsConfig();
const pkgRename = 'package.json.bak';
let dualConfigPath = join(projectDir, `tsconfig.${hex}.json`);
let errorMsg = '';
if (modules) {
const compileFiles = getCompileFiles(tsc, projectDir);
dualConfigPath = join(subDir, `tsconfig.${hex}.json`);
await mkdir(subDir);
await Promise.all(compileFiles.map(file => cp(file, join(subDir, relative(projectDir, file).replace(/^(\.\.\/)*/, '')))));
/**
* Transform ambiguous modules for the target dual build.
* @see https://github.com/microsoft/TypeScript/issues/58658
*/
const toTransform = await glob(`${subDir}/**/*{.js,.jsx,.ts,.tsx}`, {
ignore: 'node_modules/**',
});
for (const file of toTransform) {
/**
* Maybe include the option to transform modules implicitly
* (modules: true) so that `exports` are correctly converted
* when targeting a CJS dual build. Depends on @knighted/module
* supporting he `modules` option.
*
* @see https://github.com/microsoft/TypeScript/issues/58658
*/
await transform(file, { out: file, type: isCjsBuild ? 'commonjs' : 'module' });
}
}
/**
* Create a new package.json with updated `type` field.
* Create a new tsconfig.json.
*/
await rename(pkg.path, join(pkgDir, pkgRename));
await writeFile(pkg.path, JSON.stringify({
type: isCjsBuild ? 'commonjs' : 'module',
}));
await writeFile(dualConfigPath, JSON.stringify(tsconfigDual));
// Build dual
log('Starting dual build...');
try {
await runBuild(dualConfigPath, absoluteDualOutDir);
}
catch ({ message }) {
success = false;
errorMsg = message;
}
finally {
// Cleanup and restore
await rm(dualConfigPath, { force: true });
await rm(pkg.path, { force: true });
await rm(subDir, { force: true, recursive: true });
await rename(join(pkgDir, pkgRename), pkg.path);
if (errorMsg) {
handleErrorAndExit(errorMsg);
}
}
if (success) {
const filenames = await glob(`${absoluteDualOutDir}/**/*{.js,.d.ts}`, {
ignore: 'node_modules/**',
});
await updateSpecifiersAndFileExtensions(filenames);
logSuccess(startTime);
}
}
}
};
(async () => {
const realFileUrlArgv1 = await getRealPathAsFileUrl(argv[1] ?? '');
if (import.meta.url === realFileUrlArgv1) {
await duel();
}
})();
export { duel };