bob-the-bundler
Version:
Bob The Bundler!
366 lines (365 loc) • 15.6 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 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';
export const DEFAULT_TS_BUILD_CONFIG = 'tsconfig.build.json';
/**
* 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) {
let tsconfig = options.tsconfig;
if (!tsconfig && (await fse.exists(join(options.cwd, DEFAULT_TS_BUILD_CONFIG)))) {
tsconfig = join(options.cwd, DEFAULT_TS_BUILD_CONFIG);
}
assertTypeScriptBuildResult(await execa('npx', [
'tsc',
...(tsconfig ? ['--project', tsconfig] : []),
...compilerOptionsToArgs(typeScriptCompilerOptions('esm')),
...(options.incremental ? ['--incremental'] : []),
'--outDir',
join(buildPath, 'esm'),
]));
assertTypeScriptBuildResult(await execa('npx', [
'tsc',
...(tsconfig ? ['--project', tsconfig] : []),
...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({
tsconfig: {
describe: `Which tsconfig file to use when building TypeScript. By default bob will use ${DEFAULT_TS_BUILD_CONFIG} if it exists, otherwise the TSC's default.`,
type: 'string',
},
incremental: {
describe: 'Better performance by building only packages that had changes.',
type: 'boolean',
},
});
},
async handler({ tsconfig, 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, { cwd, tsconfig, 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, { cwd, tsconfig, 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;
}
validatePackageJson(pkg, {
includesCommonJS: (_a = config === null || config === void 0 ? void 0 : config.commonjs) !== null && _a !== void 0 ? _a : true,
});
const declarations = await globby('**/*.d.ts', {
cwd: getBuildPath('esm'),
absolute: false,
ignore: filesToExcludeFromDist,
});
await fse.ensureDir(join(distPath, 'typings'));
await Promise.all(declarations.map(filePath => limit(() => fse.copy(join(getBuildPath('esm'), filePath), join(distPath, 'typings', filePath)))));
const esmFiles = await globby('**/*.js', {
cwd: getBuildPath('esm'),
absolute: false,
ignore: filesToExcludeFromDist,
});
// all files that export nothing, should be completely empty
// this way we wont have issues with linters: <link to issue>
// and we will also make all type-only packages happy
for (const file of esmFiles) {
const src = await fse.readFile(join(getBuildPath('esm'), file));
if (src.toString().trim() === 'export {};') {
await fse.writeFile(join(getBuildPath('esm'), file), '');
}
}
await fse.ensureDir(join(distPath, 'esm'));
await Promise.all(esmFiles.map(filePath => limit(() => fse.copy(join(getBuildPath('esm'), filePath), join(distPath, 'esm', filePath)))));
if ((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,
});
// all files that export nothing, should be completely empty
// this way we wont have issues with linters: <link to issue>
// and we will also make all type-only packages happy
for (const file of cjsFiles) {
const src = await fse.readFile(join(getBuildPath('cjs'), file));
if (
// TODO: will this always be the case with empty cjs files
src.toString().trim() ===
'"use strict";\nObject.defineProperty(exports, "__esModule", { value: true });') {
await fse.writeFile(join(getBuildPath('cjs'), file), '');
}
}
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), 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) {
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];
if (field === 'engines') {
// remove all package managers from engines field
const ignoredPackageManagers = ['npm', 'yarn', 'pnpm'];
for (const packageManager of ignoredPackageManagers) {
if (newPkg[field][packageManager]) {
delete newPkg[field][packageManager];
}
}
}
}
});
const distDirStr = `${DIST_DIR}/`;
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 (!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)}".`);
}
// 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 fse.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);
}
}
}));
}