bob-the-bundler
Version:
Bob The Bundler!
362 lines (361 loc) • 14.7 kB
JavaScript
import assert from 'assert';
import { dirname, join, resolve } from 'path';
import { execa } from 'execa';
import fse from 'fs-extra';
import { globby } from 'globby';
import get from 'lodash.get';
import mkdirp from 'mkdirp';
import pLimit from 'p-limit';
import { createCommand } from '../command.js';
import { getBobConfig } from '../config.js';
import { getRootPackageJSON } from '../utils/get-root-package-json.js';
import { getWorkspacePackagePaths } from '../utils/get-workspace-package-paths.js';
import { getWorkspaces } from '../utils/get-workspaces.js';
import { rewriteExports } from '../utils/rewrite-exports.js';
import { presetFields, presetFieldsESM } from './bootstrap.js';
export const DIST_DIR = 'dist';
/**
* A list of files that we don't need within the published package.
* Also known as test files :)
* This list is derived from scouting various of our repositories.
*/
const filesToExcludeFromDist = [
'**/test/**',
'**/tests/**',
'**/__tests__/**',
'**/__testUtils__/**',
'**/*.spec.*',
'**/*.test.*',
'**/dist',
'**/temp',
];
const moduleMappings = {
esm: 'es2022',
cjs: 'commonjs',
};
function typeScriptCompilerOptions(target) {
return {
module: moduleMappings[target],
sourceMap: false,
inlineSourceMap: false,
};
}
function compilerOptionsToArgs(options) {
return Object.entries(options).flatMap(([key, value]) => [`--${key}`, `${value}`]);
}
function assertTypeScriptBuildResult(result) {
if (result.exitCode !== 0) {
console.log('TypeScript compiler exited with non-zero exit code.');
console.log(result.stdout);
throw new Error('TypeScript compiler exited with non-zero exit code.');
}
}
async function buildTypeScript(buildPath, options = {}) {
assertTypeScriptBuildResult(await execa('npx', [
'tsc',
...compilerOptionsToArgs(typeScriptCompilerOptions('esm')),
...(options.incremental ? ['--incremental'] : []),
'--outDir',
join(buildPath, 'esm'),
]));
assertTypeScriptBuildResult(await execa('npx', [
'tsc',
...compilerOptionsToArgs(typeScriptCompilerOptions('cjs')),
...(options.incremental ? ['--incremental'] : []),
'--outDir',
join(buildPath, 'cjs'),
]));
}
export const buildCommand = createCommand(api => {
const { reporter } = api;
return {
command: 'build',
describe: 'Build',
builder(yargs) {
return yargs.options({
incremental: {
describe: 'Better performance by building only packages that had changes.',
type: 'boolean',
},
});
},
async handler({ incremental }) {
const cwd = process.cwd();
const rootPackageJSON = await getRootPackageJSON();
const workspaces = await getWorkspaces(rootPackageJSON);
const isSinglePackage = workspaces === null;
if (isSinglePackage) {
const buildPath = join(cwd, '.bob');
if (!incremental) {
await fse.remove(buildPath);
}
await buildTypeScript(buildPath, { incremental });
const pkg = await fse.readJSON(resolve(cwd, 'package.json'));
const fullName = pkg.name;
const distPath = join(cwd, 'dist');
const getBuildPath = (target) => join(buildPath, target);
await build({
cwd,
pkg,
fullName,
reporter,
getBuildPath,
distPath,
});
return;
}
const limit = pLimit(4);
const workspacePackagePaths = await getWorkspacePackagePaths(workspaces);
const packageInfoList = await Promise.all(workspacePackagePaths.map(packagePath => limit(async () => {
const cwd = packagePath;
const pkg = await fse.readJSON(resolve(cwd, 'package.json'));
const fullName = pkg.name;
return { packagePath, cwd, pkg, fullName };
})));
const bobBuildPath = join(cwd, '.bob');
if (!incremental) {
await fse.remove(bobBuildPath);
}
await buildTypeScript(bobBuildPath, { incremental });
await Promise.all(packageInfoList.map(({ cwd, pkg, fullName }) => limit(async () => {
const getBuildPath = (target) => join(cwd.replace('packages', join('.bob', target)), 'src');
const distPath = join(cwd, 'dist');
await build({
cwd,
pkg,
fullName,
reporter,
getBuildPath,
distPath,
});
})));
},
};
});
const limit = pLimit(20);
async function build({ cwd, pkg, fullName, reporter, getBuildPath, distPath, }) {
var _a, _b, _c;
const config = getBobConfig(pkg);
if (config === false || (config === null || config === void 0 ? void 0 : config.build) === false) {
reporter.warn(`Skip build for '${fullName}'`);
return;
}
const declarations = await globby('**/*.d.ts', {
cwd: getBuildPath('esm'),
absolute: false,
ignore: filesToExcludeFromDist,
});
const esmFiles = await globby('**/*.js', {
cwd: getBuildPath('esm'),
absolute: false,
ignore: filesToExcludeFromDist,
});
// Check whether al esm files are empty, if not - probably a types only build
let emptyEsmFiles = true;
for (const file of esmFiles) {
const src = await fse.readFile(join(getBuildPath('esm'), file));
if (src.toString().trim() !== 'export {};') {
emptyEsmFiles = false;
break;
}
}
// Empty ESM files with existing declarations is a types-only package
const typesOnly = emptyEsmFiles && declarations.length > 0;
validatePackageJson(pkg, {
typesOnly,
includesCommonJS: (_a = config === null || config === void 0 ? void 0 : config.commonjs) !== null && _a !== void 0 ? _a : true,
});
// remove <project>/dist
await fse.remove(distPath);
// Copy type definitions
await fse.ensureDir(join(distPath, 'typings'));
await Promise.all(declarations.map(filePath => limit(() => fse.copy(join(getBuildPath('esm'), filePath), join(distPath, 'typings', filePath)))));
// If ESM files are not empty, copy them to dist/esm
if (!emptyEsmFiles) {
await fse.ensureDir(join(distPath, 'esm'));
await Promise.all(esmFiles.map(filePath => limit(() => fse.copy(join(getBuildPath('esm'), filePath), join(distPath, 'esm', filePath)))));
}
if (!emptyEsmFiles && (config === null || config === void 0 ? void 0 : config.commonjs) === undefined) {
// Transpile ESM to CJS and move CJS to dist/cjs only if there's something to transpile
await fse.ensureDir(join(distPath, 'cjs'));
const cjsFiles = await globby('**/*.js', {
cwd: getBuildPath('cjs'),
absolute: false,
ignore: filesToExcludeFromDist,
});
await Promise.all(cjsFiles.map(filePath => limit(() => fse.copy(join(getBuildPath('cjs'), filePath), join(distPath, 'cjs', filePath)))));
// Add package.json to dist/cjs to ensure files are interpreted as commonjs
await fse.writeFile(join(distPath, 'cjs', 'package.json'), JSON.stringify({ type: 'commonjs' }));
// We need to provide .cjs extension type definitions as well :)
// https://github.com/ardatan/graphql-tools/discussions/4581#discussioncomment-3329673
const declarations = await globby('**/*.d.ts', {
cwd: getBuildPath('cjs'),
absolute: false,
ignore: filesToExcludeFromDist,
});
await Promise.all(declarations.map(filePath => limit(async () => {
const contents = await fse.readFile(join(getBuildPath('cjs'), filePath), 'utf-8');
await fse.writeFile(join(distPath, 'typings', filePath.replace(/\.d\.ts/, '.d.cts')), contents.replace(/\.js";\n/g, `.cjs";\n`).replace(/\.js';\n/g, `.cjs';\n`));
})));
}
// move the package.json to dist
await fse.writeFile(join(distPath, 'package.json'), JSON.stringify(rewritePackageJson(pkg, typesOnly), null, 2));
// move README.md and LICENSE and other specified files
await copyToDist(cwd, ['README.md', 'LICENSE', ...((_c = (_b = config === null || config === void 0 ? void 0 : config.build) === null || _b === void 0 ? void 0 : _b.copy) !== null && _c !== void 0 ? _c : [])], distPath);
if (pkg.bin) {
if (globalThis.process.platform === 'win32') {
console.warn('Package includes bin files, but cannot set the executable bit on Windows.\n' +
'Please manually set the executable bit on the bin files before publishing.');
}
else {
await Promise.all(Object.values(pkg.bin).map(filePath => execa('chmod', ['+x', join(cwd, filePath)])));
}
}
reporter.success(`Built ${pkg.name}`);
}
function rewritePackageJson(pkg, typesOnly) {
const newPkg = {};
const fields = [
'name',
'version',
'description',
'sideEffects',
'peerDependenciesMeta',
'peerDependencies',
'dependencies',
'optionalDependencies',
'repository',
'homepage',
'keywords',
'author',
'license',
'engines',
'name',
'main',
'module',
'typings',
'typescript',
'type',
];
fields.forEach(field => {
if (pkg[field] !== undefined) {
newPkg[field] = pkg[field];
}
});
const distDirStr = `${DIST_DIR}/`;
if (typesOnly) {
newPkg.main = '';
delete newPkg.module;
delete newPkg.type;
}
else {
newPkg.main = newPkg.main.replace(distDirStr, '');
newPkg.module = newPkg.module.replace(distDirStr, '');
}
newPkg.typings = newPkg.typings.replace(distDirStr, '');
newPkg.typescript = {
definition: newPkg.typescript.definition.replace(distDirStr, ''),
};
if (!typesOnly) {
if (!pkg.exports) {
newPkg.exports = presetFields.exports;
}
newPkg.exports = rewriteExports(pkg.exports, DIST_DIR);
}
if (pkg.bin) {
newPkg.bin = {};
for (const alias in pkg.bin) {
newPkg.bin[alias] = pkg.bin[alias].replace(distDirStr, '');
}
}
return newPkg;
}
export function validatePackageJson(pkg, opts) {
var _a;
function expect(key, expected) {
const received = get(pkg, key);
assert.deepEqual(received, expected, `${pkg.name}: "${key}" equals "${JSON.stringify(received)}"` +
`, should be "${JSON.stringify(expected)}".`);
}
// Type only packages have simpler rules (following the style of https://github.com/DefinitelyTyped/DefinitelyTyped packages)
if (opts.typesOnly) {
expect('main', '');
expect('module', undefined);
expect('typings', presetFields.typings);
expect('typescript.definition', presetFields.typescript.definition);
expect('exports', undefined);
return;
}
// If the package has NO binary we need to check the exports map.
// a package should either
// 1. have a bin property
// 2. have an exports property
// 3. have an exports and bin property
if (Object.keys((_a = pkg.bin) !== null && _a !== void 0 ? _a : {}).length > 0) {
if (opts.includesCommonJS === true) {
expect('main', presetFields.main);
expect('module', presetFields.module);
expect('typings', presetFields.typings);
expect('typescript.definition', presetFields.typescript.definition);
}
else {
expect('main', presetFieldsESM.main);
expect('module', presetFieldsESM.module);
expect('typings', presetFieldsESM.typings);
expect('typescript.definition', presetFieldsESM.typescript.definition);
}
}
else if (pkg.main !== undefined ||
pkg.module !== undefined ||
pkg.exports !== undefined ||
pkg.typings !== undefined ||
pkg.typescript !== undefined) {
if (opts.includesCommonJS === true) {
// if there is no bin property, we NEED to check the exports.
expect('main', presetFields.main);
expect('module', presetFields.module);
expect('typings', presetFields.typings);
expect('typescript.definition', presetFields.typescript.definition);
// For now we enforce a top level exports property
expect("exports['.'].require", presetFields.exports['.'].require);
expect("exports['.'].import", presetFields.exports['.'].import);
expect("exports['.'].default", presetFields.exports['.'].default);
}
else {
expect('main', presetFieldsESM.main);
expect('module', presetFieldsESM.module);
expect('typings', presetFieldsESM.typings);
expect('typescript.definition', presetFieldsESM.typescript.definition);
// For now, we enforce a top level exports property
expect("exports['.']", presetFieldsESM.exports['.']);
}
}
}
async function executeCopy(sourcePath, destPath) {
await mkdirp(dirname(destPath));
await fse.copyFile(sourcePath, destPath);
}
async function copyToDist(cwd, files, distDir) {
const allFiles = await globby(files, { cwd });
return Promise.all(allFiles.map(async (file) => {
if (await fse.pathExists(join(cwd, file))) {
const sourcePath = join(cwd, file);
if (file.includes('src/')) {
// Figure relevant module types
const allTypes = [];
if (await fse.pathExists(join(distDir, 'esm'))) {
allTypes.push('esm');
}
if (await fse.pathExists(join(distDir, 'cjs'))) {
allTypes.push('cjs');
}
// FOr each type, copy files to the relevant directory
await Promise.all(allTypes.map(type => executeCopy(sourcePath, join(distDir, file.replace('src/', `${type}/`)))));
}
else {
const destPath = join(distDir, file);
executeCopy(sourcePath, destPath);
}
}
}));
}