bob-the-bundler
Version:
Bob The Bundler!
404 lines (403 loc) • 18.1 kB
JavaScript
import assert from 'assert';
import { dirname, join, resolve } from 'path';
import { execa } from 'execa';
import fse from 'fs-extra';
import { getTsconfig, parseTsconfig } from 'get-tsconfig';
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 { presetFieldsDual, presetFieldsOnlyESM } 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',
];
function compilerOptionsToArgs(options) {
return Object.entries(options)
.filter(([, value]) => !!value)
.flatMap(([key, value]) => [`--${key}`, `${value}`]);
}
function assertTypeScriptBuildResult(result, reporter) {
if (result.exitCode !== 0) {
reporter.error(result.stdout);
throw new Error('TypeScript compiler exited with non-zero exit code.');
}
}
async function buildTypeScript(buildPath, options, reporter) {
var _a, _b;
let project = options.tsconfig;
if (!project && (await fse.exists(join(options.cwd, DEFAULT_TS_BUILD_CONFIG)))) {
project = join(options.cwd, DEFAULT_TS_BUILD_CONFIG);
}
const tsconfig = project ? parseTsconfig(project) : (_a = getTsconfig(options.cwd)) === null || _a === void 0 ? void 0 : _a.config;
const moduleResolution = (((_b = tsconfig === null || tsconfig === void 0 ? void 0 : tsconfig.compilerOptions) === null || _b === void 0 ? void 0 : _b.moduleResolution) || '').toLowerCase();
const isModernNodeModuleResolution = ['node16', 'nodenext'].includes(moduleResolution);
const isOldNodeModuleResolution = ['classic', 'node', 'node10'].includes(moduleResolution);
if (moduleResolution && !isOldNodeModuleResolution && !isModernNodeModuleResolution) {
throw new Error(`'moduleResolution' option '${moduleResolution}' cannot be used to build CommonJS"`);
}
async function build(out) {
const revertPackageJsonsType = await setPackageJsonsType({ cwd: options.cwd, ignore: [...filesToExcludeFromDist, ...((tsconfig === null || tsconfig === void 0 ? void 0 : tsconfig.exclude) || [])] }, out);
try {
assertTypeScriptBuildResult(await execa('npx', [
'tsc',
...compilerOptionsToArgs({
project,
module: isModernNodeModuleResolution
? moduleResolution // match module with moduleResolution for modern node (nodenext and node16)
: out === 'module'
? 'es2022'
: isOldNodeModuleResolution
? 'commonjs' // old commonjs
: 'node16', // modern commonjs
sourceMap: false,
inlineSourceMap: false,
incremental: options.incremental,
outDir: out === 'module' ? join(buildPath, 'esm') : join(buildPath, 'cjs'),
}),
]), reporter);
}
finally {
await revertPackageJsonsType();
}
}
await build('module');
await build('commonjs');
}
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 }, reporter);
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 }, reporter);
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 dual = (_a = config === null || config === void 0 ? void 0 : config.commonjs) !== null && _a !== void 0 ? _a : true;
validatePackageJson(pkg, { dual });
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 (dual) {
// 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') {
reporter.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',
'typings',
'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.typings = newPkg.typings.replace(distDirStr, '');
if (!pkg.exports) {
newPkg.exports = presetFieldsDual.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.dual === true) {
expect('main', presetFieldsDual.main);
expect('typings', presetFieldsDual.typings);
}
else {
expect('main', presetFieldsOnlyESM.main);
expect('typings', presetFieldsOnlyESM.typings);
}
}
else if (pkg.main !== undefined || pkg.exports !== undefined || pkg.typings !== undefined) {
if (opts.dual === true) {
// if there is no bin property, we NEED to check the exports.
expect('main', presetFieldsDual.main);
expect('typings', presetFieldsDual.typings);
// For now we enforce a top level exports property
expect("exports['.'].require", presetFieldsDual.exports['.'].require);
expect("exports['.'].import", presetFieldsDual.exports['.'].import);
expect("exports['.'].default", presetFieldsDual.exports['.'].default);
}
else {
expect('main', presetFieldsOnlyESM.main);
expect('typings', presetFieldsOnlyESM.typings);
// For now, we enforce a top level exports property
expect("exports['.']", presetFieldsOnlyESM.exports['.']);
}
}
}
/**
* Sets the {@link cwd workspaces} package.json(s) `"type"` field to the defined {@link type}
* returning a "revert" function which puts the original `"type"` back.
*
* @returns A revert function that reverts the original value of the `"type"` field.
*/
async function setPackageJsonsType({ cwd, ignore }, type) {
const rootPkgJsonPath = join(cwd, 'package.json');
const rootContents = await fse.readFile(rootPkgJsonPath, 'utf8');
const rootPkg = JSON.parse(rootContents);
const workspaces = await getWorkspaces(rootPkg);
const isSinglePackage = workspaces === null;
const reverts = [];
for (const pkgJsonPath of [
// we also want to modify the root package.json TODO: do we in single package repos?
rootPkgJsonPath,
...(isSinglePackage
? []
: await globby(workspaces.map((w) => w + '/package.json'), { cwd, absolute: true, ignore })),
]) {
const contents = pkgJsonPath === rootPkgJsonPath
? // no need to re-read the root package.json
rootContents
: await fse.readFile(pkgJsonPath, 'utf8');
const endsWithNewline = contents.endsWith('\n');
const pkg = JSON.parse(contents);
if (pkg.type != null && pkg.type !== 'commonjs' && pkg.type !== 'module') {
throw new Error(`Invalid "type" property value "${pkg.type}" in ${pkgJsonPath}`);
}
const originalPkg = { ...pkg };
const differentType = (pkg.type ||
// default when the type is not defined
'commonjs') !== type;
// change only if the provided type is different
if (differentType) {
pkg.type = type;
await fse.writeFile(pkgJsonPath, JSON.stringify(pkg, null, ' ') + (endsWithNewline ? '\n' : ''));
// revert change, of course only if we changed something
reverts.push(async () => {
await fse.writeFile(pkgJsonPath, JSON.stringify(originalPkg, null, ' ') + (endsWithNewline ? '\n' : ''));
});
}
}
return async function revert() {
await Promise.all(reverts.map(r => r()));
};
}
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);
}
}
}));
}