diff --git a/.changeset/many-terms-type.md b/.changeset/many-terms-type.md new file mode 100644 index 0000000000..fd11d14106 --- /dev/null +++ b/.changeset/many-terms-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +The experimental types build enabled by `--experimental-type-build` now runs in a separate worker thread. diff --git a/.changeset/twenty-colts-applaud.md b/.changeset/twenty-colts-applaud.md new file mode 100644 index 0000000000..2390e21a6e --- /dev/null +++ b/.changeset/twenty-colts-applaud.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Introduced an experimental and hidden `repo` sub-command, that contains commands that operate on an entire monorepo rather than individual packages. diff --git a/packages/cli/src/commands/build/buildApp.ts b/packages/cli/src/commands/build/buildApp.ts index 3d86d796b7..62d51a0810 100644 --- a/packages/cli/src/commands/build/buildApp.ts +++ b/packages/cli/src/commands/build/buildApp.ts @@ -15,24 +15,27 @@ */ import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; import { buildBundle } from '../../lib/bundler'; import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; import { loadCliConfig } from '../../lib/config'; -import { paths } from '../../lib/paths'; interface BuildAppOptions { + targetDir: string; writeStats: boolean; configPaths: string[]; } export async function buildApp(options: BuildAppOptions) { - const { name } = await fs.readJson(paths.resolveTarget('package.json')); + const { targetDir, writeStats, configPaths } = options; + const { name } = await fs.readJson(resolvePath(targetDir, 'package.json')); await buildBundle({ + targetDir, entry: 'src/index', parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), - statsJsonEnabled: options.writeStats, + statsJsonEnabled: writeStats, ...(await loadCliConfig({ - args: options.configPaths, + args: configPaths, fromPackage: name, })), }); diff --git a/packages/cli/src/commands/build/buildBackend.ts b/packages/cli/src/commands/build/buildBackend.ts index a4d8858cf8..490cc413a4 100644 --- a/packages/cli/src/commands/build/buildBackend.ts +++ b/packages/cli/src/commands/build/buildBackend.ts @@ -19,7 +19,6 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; import tar, { CreateOptions } from 'tar'; import { createDistWorkspace } from '../../lib/packager'; -import { paths } from '../../lib/paths'; import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; import { buildPackage, Output } from '../../lib/builder'; @@ -27,12 +26,13 @@ const BUNDLE_FILE = 'bundle.tar.gz'; const SKELETON_FILE = 'skeleton.tar.gz'; interface BuildBackendOptions { + targetDir: string; skipBuildDependencies: boolean; } export async function buildBackend(options: BuildBackendOptions) { - const targetDir = paths.resolveTarget('dist'); - const pkg = await fs.readJson(paths.resolveTarget('package.json')); + const { targetDir, skipBuildDependencies } = options; + const pkg = await fs.readJson(resolvePath(targetDir, 'package.json')); // We build the target package without generating type declarations. await buildPackage({ outputs: new Set([Output.cjs]) }); @@ -41,7 +41,7 @@ export async function buildBackend(options: BuildBackendOptions) { try { await createDistWorkspace([pkg.name], { targetDir: tmpDir, - buildDependencies: !options.skipBuildDependencies, + buildDependencies: !skipBuildDependencies, buildExcludes: [pkg.name], parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), skeleton: SKELETON_FILE, diff --git a/packages/cli/src/commands/build/command.ts b/packages/cli/src/commands/build/command.ts index 8c13b515d7..1189883b12 100644 --- a/packages/cli/src/commands/build/command.ts +++ b/packages/cli/src/commands/build/command.ts @@ -17,6 +17,7 @@ import { Command } from 'commander'; import { buildPackage, Output } from '../../lib/builder'; import { findRoleFromCommand, getRoleInfo } from '../../lib/role'; +import { paths } from '../../lib/paths'; import { buildApp } from './buildApp'; import { buildBackend } from './buildBackend'; @@ -25,12 +26,14 @@ export async function command(cmd: Command): Promise { if (role === 'app') { return buildApp({ + targetDir: paths.resolveTarget('dist'), configPaths: cmd.config as string[], writeStats: Boolean(cmd.stats), }); } if (role === 'backend') { return buildBackend({ + targetDir: paths.resolveTarget('dist'), skipBuildDependencies: Boolean(cmd.skipBuildDependencies), }); } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index a33e7d4afe..2211af1dda 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -25,6 +25,25 @@ const configOption = [ Array(), ] as const; +export function registerRepoCommand(program: CommanderStatic) { + const command = program + .command('repo [command]', { hidden: true }) + .description( + 'Command that run across an entire Backstage project [EXPERIMENTAL]', + ); + + command + .command('build') + .description( + 'Build packages in the project, excluding bundled app and backend packages.', + ) + .option( + '--all', + 'Build all packages, including bundled app and backend packages.', + ) + .action(lazy(() => import('./repo/build').then(m => m.command))); +} + export function registerScriptCommand(program: CommanderStatic) { const command = program .command('script [command]', { hidden: true }) @@ -312,6 +331,7 @@ export function registerCommands(program: CommanderStatic) { .description('Print configuration schema') .action(lazy(() => import('./config/schema').then(m => m.default))); + registerRepoCommand(program); registerScriptCommand(program); registerMigrateCommand(program); diff --git a/packages/cli/src/commands/repo/build.ts b/packages/cli/src/commands/repo/build.ts new file mode 100644 index 0000000000..e8058887c5 --- /dev/null +++ b/packages/cli/src/commands/repo/build.ts @@ -0,0 +1,165 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; +import { Command } from 'commander'; +import { relative as relativePath } from 'path'; +import { buildPackages, getOutputsForRole } from '../../lib/builder'; +import { PackageGraph } from '../../lib/monorepo'; +import { ExtendedPackage } from '../../lib/monorepo/PackageGraph'; +import { paths } from '../../lib/paths'; +import { getRoleInfo } from '../../lib/role'; +import { buildApp } from '../build/buildApp'; +import { buildBackend } from '../build/buildBackend'; + +function createScriptOptionsParser(anyCmd: Command, commandPath: string[]) { + // Regardless of what command instance is passed in we want to find + // the root command and resolve the path from there + let rootCmd = anyCmd; + while (rootCmd.parent) { + rootCmd = rootCmd.parent; + } + + // Now find the command that was requested + let targetCmd = rootCmd as Command | undefined; + for (const name of commandPath) { + targetCmd = targetCmd?.commands.find(c => c.name() === name) as + | Command + | undefined; + } + + if (!targetCmd) { + throw new Error(`Could not find script command '${commandPath.join(' ')}'`); + } + const cmd = targetCmd; + + const expectedScript = `backstage-cli ${commandPath.join(' ')}`; + + return (scriptStr?: string) => { + if (!scriptStr || !scriptStr.startsWith(expectedScript)) { + return undefined; + } + + const argsStr = scriptStr.slice(expectedScript.length).trim(); + + // Can't clone or copy or even use commands as prototype, so we mutate + // the necessary members instead, and then reset them once we're done + const currentOpts = cmd._optionValues; + const currentStore = cmd._storeOptionsAsProperties; + + const result: Record = {}; + cmd._storeOptionsAsProperties = false; + cmd._optionValues = result; + + // Triggers the writing of options to the result object + cmd.parseOptions(argsStr.split(' ')); + + cmd._storeOptionsAsProperties = currentOpts; + cmd._optionValues = currentStore; + + return result; + }; +} + +export async function command(cmd: Command): Promise { + const packages = await PackageGraph.listTargetPackages(); + const bundledPackages = new Array(); + + const parseBuildScript = createScriptOptionsParser(cmd, ['script', 'build']); + + const options = packages.flatMap(pkg => { + const role = pkg.packageJson.backstage?.role; + if (!role) { + console.warn(`Ignored ${pkg.packageJson.name} because it has no role`); + return []; + } + + const outputs = getOutputsForRole(role); + if (outputs.size === 0) { + if (getRoleInfo(role).output.includes('bundle')) { + bundledPackages.push(pkg); + } else { + console.warn( + `Ignored ${pkg.packageJson.name} because it has no output`, + ); + } + return []; + } + + const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build); + if (!buildOptions) { + console.warn( + `Ignored ${pkg.packageJson.name} because it does not have a matching build script`, + ); + return []; + } + + return { + targetDir: pkg.dir, + outputs, + logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `, + minify: buildOptions.minify, + useApiExtractor: buildOptions.experimentalTypeBuild, + }; + }); + + console.log('Building packages'); + await buildPackages(options); + + if (cmd.all) { + const apps = bundledPackages.filter( + pkg => pkg.packageJson.backstage?.role === 'app', + ); + + console.log('Building apps'); + await Promise.all( + apps.map(async pkg => { + const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build); + if (!buildOptions) { + console.warn( + `Ignored ${pkg.packageJson.name} because it does not have a matching build script`, + ); + return; + } + await buildApp({ + targetDir: pkg.dir, + configPaths: (buildOptions.config as string[]) ?? [], + writeStats: Boolean(buildOptions.stats), + }); + }), + ); + + console.log('Building backends'); + const backends = bundledPackages.filter( + pkg => pkg.packageJson.backstage?.role === 'backend', + ); + await Promise.all( + backends.map(async pkg => { + const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build); + if (!buildOptions) { + console.warn( + `Ignored ${pkg.packageJson.name} because it does not have a matching build script`, + ); + return; + } + await buildBackend({ + targetDir: pkg.dir, + skipBuildDependencies: true, + }); + }), + ); + } +} diff --git a/packages/cli/src/lib/builder/buildTypeDefinitions.ts b/packages/cli/src/lib/builder/buildTypeDefinitions.ts index ba67532bdb..85e54850e4 100644 --- a/packages/cli/src/lib/builder/buildTypeDefinitions.ts +++ b/packages/cli/src/lib/builder/buildTypeDefinitions.ts @@ -16,146 +16,127 @@ import fs from 'fs-extra'; import chalk from 'chalk'; -import { - relative as relativePath, - resolve as resolvePath, - dirname, -} from 'path'; +import { Worker } from 'worker_threads'; +import { relative as relativePath, resolve as resolvePath } from 'path'; import { paths } from '../paths'; +import { buildTypeDefinitionsWorker } from './buildTypeDefinitionsWorker'; // These message types are ignored since we want to avoid duplicating the logic of // handling them correctly, and we already have the API Reports warning about them. const ignoredMessages = new Set(['tsdoc-undefined-tag', 'ae-forgotten-export']); -let apiExtractor: undefined | typeof import('@microsoft/api-extractor'); -function prepareApiExtractor() { - if (apiExtractor) { - return apiExtractor; - } - - try { - apiExtractor = require('@microsoft/api-extractor'); - } catch (error) { - throw new Error( - 'Failed to resolve @microsoft/api-extractor, it must best installed ' + - 'as a dependency of your project in order to use experimental type builds', - ); - } - - /** - * All of this monkey patching below is because MUI has these bare package.json file as a method - * for making TypeScript accept imports like `@material-ui/core/Button`, and improve tree-shaking - * by declaring them side effect free. - * - * The package.json lookup logic in api-extractor really doesn't like that though, as it enforces - * that the 'name' field exists in all package.json files that it discovers. This below is just - * making sure that we ignore those file package.json files instead of crashing. - */ - const { - PackageJsonLookup, - // eslint-disable-next-line import/no-extraneous-dependencies - } = require('@rushstack/node-core-library/lib/PackageJsonLookup'); - - const old = PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor; - PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor = - function tryGetPackageJsonFilePathForPatch(path: string) { - if ( - path.includes('@material-ui') && - !dirname(path).endsWith('@material-ui') - ) { - return undefined; - } - return old.call(this, path); - }; - - return apiExtractor!; -} - -export async function buildTypeDefinitions() { - const { Extractor, ExtractorConfig } = prepareApiExtractor(); - - const distTypesPackageDir = paths.resolveTargetRoot( - 'dist-types', - relativePath(paths.targetRoot, paths.targetDir), +export async function buildTypeDefinitions( + targetDirs: string[] = [paths.targetDir], +) { + const packageDirs = targetDirs.map(dir => + relativePath(paths.targetRoot, dir), ); - const entryPoint = resolvePath(distTypesPackageDir, 'src/index.d.ts'); + const entryPoints = await Promise.all( + packageDirs.map(async dir => { + const entryPoint = paths.resolveTargetRoot( + 'dist-types', + dir, + 'src/index.d.ts', + ); - const declarationsExist = await fs.pathExists(entryPoint); - if (!declarationsExist) { - const path = relativePath(paths.targetDir, entryPoint); - throw new Error( - `No declaration files found at ${path}, be sure to run ${chalk.bgRed.white( - 'yarn tsc', - )} to generate .d.ts files before packaging`, - ); - } + const declarationsExist = await fs.pathExists(entryPoint); + if (!declarationsExist) { + throw new Error( + `No declaration files found at ${entryPoint}, be sure to run ${chalk.bgRed.white( + 'yarn tsc', + )} to generate .d.ts files before packaging`, + ); + } + return entryPoint; + }), + ); - const extractorConfig = ExtractorConfig.prepare({ - configObject: { - mainEntryPointFilePath: entryPoint, - bundledPackages: [], + const workerConfigs = packageDirs.map(packageDir => { + const targetDir = paths.resolveTargetRoot(packageDir); + const targetTypesDir = paths.resolveTargetRoot('dist-types', packageDir); + const extractorOptions = { + configObject: { + mainEntryPointFilePath: resolvePath(targetTypesDir, 'src/index.d.ts'), + bundledPackages: [], - compiler: { - skipLibCheck: true, - tsconfigFilePath: paths.resolveTargetRoot('tsconfig.json'), + compiler: { + skipLibCheck: true, + tsconfigFilePath: paths.resolveTargetRoot('tsconfig.json'), + }, + + dtsRollup: { + enabled: true, + untrimmedFilePath: resolvePath(targetDir, 'dist/index.alpha.d.ts'), + betaTrimmedFilePath: resolvePath(targetDir, 'dist/index.beta.d.ts'), + publicTrimmedFilePath: resolvePath(targetDir, 'dist/index.d.ts'), + }, + + newlineKind: 'lf', + + projectFolder: targetDir, }, - - dtsRollup: { - enabled: true, - untrimmedFilePath: paths.resolveTarget('dist/index.alpha.d.ts'), - betaTrimmedFilePath: paths.resolveTarget('dist/index.beta.d.ts'), - publicTrimmedFilePath: paths.resolveTarget('dist/index.d.ts'), - }, - - newlineKind: 'lf', - - projectFolder: paths.targetDir, - }, - configObjectFullPath: paths.targetDir, - packageJsonFullPath: paths.resolveTarget('package.json'), + configObjectFullPath: targetDir, + packageJsonFullPath: resolvePath(targetDir, 'package.json'), + }; + return { extractorOptions, targetTypesDir }; }); const typescriptDir = paths.resolveTargetRoot('node_modules/typescript'); const hasTypescript = await fs.pathExists(typescriptDir); - const extractorResult = Extractor.invoke(extractorConfig, { - typescriptCompilerFolder: hasTypescript ? typescriptDir : undefined, - localBuild: false, - showVerboseMessages: false, - showDiagnostics: false, - messageCallback(message) { - message.handled = true; - if (ignoredMessages.has(message.messageId)) { - return; - } + const typescriptCompilerFolder = hasTypescript ? typescriptDir : undefined; - let text = `${message.text} (${message.messageId})`; - if (message.sourceFilePath) { - text += ' at '; - text += relativePath(distTypesPackageDir, message.sourceFilePath); - if (message.sourceFileLine) { - text += `:${message.sourceFileLine}`; - if (message.sourceFileColumn) { - text += `:${message.sourceFileColumn}`; - } - } - } - if (message.logLevel === 'error') { - console.error(chalk.red(`Error: ${text}`)); - } else if ( - message.logLevel === 'warning' || - message.category === 'Extractor' - ) { - console.warn(`Warning: ${text}`); - } else { - console.log(text); - } + const worker = new Worker(`(${buildTypeDefinitionsWorker})()`, { + eval: true, + workerData: { + entryPoints, + workerConfigs, + typescriptCompilerFolder, }, }); - if (!extractorResult.succeeded) { - throw new Error( - `Type definition build completed with ${extractorResult.errorCount} errors` + - ` and ${extractorResult.warningCount} warnings`, - ); - } + await new Promise((resolve, reject) => { + worker.once('error', reject); + worker.once('exit', code => { + if (code) { + reject(new Error(`Worker exited with code ${code}`)); + } + }); + worker.on('message', data => { + if (data.type === 'done') { + if (data.error) { + reject(data.error); + } else { + resolve(); + } + } else if (data.type === 'message') { + const { message, targetTypesDir } = data; + + if (ignoredMessages.has(message.messageId)) { + return; + } + + let text = `${message.text} (${message.messageId})`; + if (message.sourceFilePath) { + text += ' at '; + text += relativePath(targetTypesDir, message.sourceFilePath); + if (message.sourceFileLine) { + text += `:${message.sourceFileLine}`; + if (message.sourceFileColumn) { + text += `:${message.sourceFileColumn}`; + } + } + } + if (message.logLevel === 'error') { + console.error(chalk.red(`Error: ${text}`)); + } else if ( + message.logLevel === 'warning' || + message.category === 'Extractor' + ) { + console.warn(`Warning: ${text}`); + } else { + console.log(text); + } + } + }); + }); } diff --git a/packages/cli/src/lib/builder/buildTypeDefinitionsWorker.ts b/packages/cli/src/lib/builder/buildTypeDefinitionsWorker.ts new file mode 100644 index 0000000000..8cb843f58e --- /dev/null +++ b/packages/cli/src/lib/builder/buildTypeDefinitionsWorker.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * NOTE: This is a worker thread function that is stringified and executed + * withing a `worker_threads.Worker`. Everything in this function must + * be self-contained. + * Using TypeScript is fine as it is transpiled before being stringified. + */ +export function buildTypeDefinitionsWorker() { + try { + require('@microsoft/api-extractor'); + } catch (error) { + throw new Error( + 'Failed to resolve @microsoft/api-extractor, it must best installed ' + + 'as a dependency of your project in order to use experimental type builds', + ); + } + + const { dirname } = require('path'); + const { workerData, parentPort } = require('worker_threads'); + const { entryPoints, workerConfigs, typescriptCompilerFolder } = workerData; + + const apiExtractor = require('@microsoft/api-extractor'); + const { Extractor, ExtractorConfig, CompilerState } = apiExtractor; + + /** + * All of this monkey patching below is because MUI has these bare package.json file as a method + * for making TypeScript accept imports like `@material-ui/core/Button`, and improve tree-shaking + * by declaring them side effect free. + * + * The package.json lookup logic in api-extractor really doesn't like that though, as it enforces + * that the 'name' field exists in all package.json files that it discovers. This below is just + * making sure that we ignore those file package.json files instead of crashing. + */ + const { + PackageJsonLookup, + // eslint-disable-next-line import/no-extraneous-dependencies + } = require('@rushstack/node-core-library/lib/PackageJsonLookup'); + + const old = PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor; + PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor = + function tryGetPackageJsonFilePathForPatch(path: string) { + if ( + path.includes('@material-ui') && + !dirname(path).endsWith('@material-ui') + ) { + return undefined; + } + return old.call(this, path); + }; + + let success = true; + let compilerState; + for (const { extractorOptions, targetTypesDir } of workerConfigs) { + const extractorConfig = ExtractorConfig.prepare(extractorOptions); + + if (!compilerState) { + compilerState = CompilerState.create(extractorConfig, { + additionalEntryPoints: entryPoints, + }); + } + + const extractorResult = Extractor.invoke(extractorConfig, { + compilerState, + localBuild: false, + typescriptCompilerFolder, + showVerboseMessages: false, + showDiagnostics: false, + messageCallback: (message: any) => { + message.handled = true; + parentPort.postMessage({ type: 'message', message, targetTypesDir }); + }, + }); + + if (!extractorResult.succeeded) { + parentPort.postMessage({ + type: 'done', + error: new Error( + `Type definition build completed with ${extractorResult.errorCount} errors` + + ` and ${extractorResult.warningCount} warnings`, + ), + }); + success = false; + break; + } + } + + if (success) { + parentPort.postMessage({ type: 'done' }); + } +} diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 617e354864..0a214f1a7a 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -16,7 +16,7 @@ import chalk from 'chalk'; import fs from 'fs-extra'; -import { relative as relativePath } from 'path'; +import { relative as relativePath, resolve as resolvePath } from 'path'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; @@ -26,7 +26,7 @@ import svgr from '@svgr/rollup'; import dts from 'rollup-plugin-dts'; import json from '@rollup/plugin-json'; import yaml from '@rollup/plugin-yaml'; -import { RollupOptions, OutputOptions } from 'rollup'; +import { RollupOptions, OutputOptions, RollupWarning } from 'rollup'; import { forwardFileImports } from './plugins'; import { BuildOptions, Output } from './types'; @@ -37,6 +37,19 @@ export async function makeRollupConfigs( options: BuildOptions, ): Promise { const configs = new Array(); + const targetDir = options.targetDir ?? paths.targetDir; + const onwarn = ({ code, message }: RollupWarning) => { + if (code === 'EMPTY_BUNDLE') { + return; // We don't care about this one + } + if (options.logPrefix) { + console.log(options.logPrefix + message); + } else { + console.log(message); + } + }; + + const distDir = resolvePath(targetDir, 'dist'); if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) { const output = new Array(); @@ -44,7 +57,7 @@ export async function makeRollupConfigs( if (options.outputs.has(Output.cjs)) { output.push({ - dir: 'dist', + dir: distDir, entryFileNames: 'index.cjs.js', chunkFileNames: 'cjs/[name]-[hash].cjs.js', format: 'commonjs', @@ -53,7 +66,7 @@ export async function makeRollupConfigs( } if (options.outputs.has(Output.esm)) { output.push({ - dir: 'dist', + dir: distDir, entryFileNames: 'index.esm.js', chunkFileNames: 'esm/[name]-[hash].esm.js', format: 'module', @@ -64,12 +77,14 @@ export async function makeRollupConfigs( } configs.push({ - input: 'src/index.ts', + input: resolvePath(targetDir, 'src/index.ts'), output, + onwarn, preserveEntrySignatures: 'strict', external: require('module').builtinModules, plugins: [ peerDepsExternal({ + packageJsonPath: resolvePath(targetDir, 'package.json'), includeDependencies: true, }), resolve({ mainFields }), @@ -109,13 +124,13 @@ export async function makeRollupConfigs( if (options.outputs.has(Output.types) && !options.useApiExtractor) { const typesInput = paths.resolveTargetRoot( 'dist-types', - relativePath(paths.targetRoot, paths.targetDir), + relativePath(paths.targetRoot, targetDir), 'src/index.d.ts', ); const declarationsExist = await fs.pathExists(typesInput); if (!declarationsExist) { - const path = relativePath(paths.targetDir, typesInput); + const path = relativePath(targetDir, typesInput); throw new Error( `No declaration files found at ${path}, be sure to run ${chalk.bgRed.white( 'yarn tsc', @@ -126,9 +141,10 @@ export async function makeRollupConfigs( configs.push({ input: typesInput, output: { - file: 'dist/index.d.ts', + file: resolvePath(distDir, 'index.d.ts'), format: 'es', }, + onwarn, plugins: [dts()], }); } diff --git a/packages/cli/src/lib/builder/index.ts b/packages/cli/src/lib/builder/index.ts index c39d964ade..00cc463cfb 100644 --- a/packages/cli/src/lib/builder/index.ts +++ b/packages/cli/src/lib/builder/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export { buildPackage } from './packager'; +export { buildPackage, buildPackages, getOutputsForRole } from './packager'; export { Output } from './types'; export type { BuildOptions } from './types'; diff --git a/packages/cli/src/lib/builder/packager.ts b/packages/cli/src/lib/builder/packager.ts index 96c488fce2..2413662a17 100644 --- a/packages/cli/src/lib/builder/packager.ts +++ b/packages/cli/src/lib/builder/packager.ts @@ -17,11 +17,12 @@ import fs from 'fs-extra'; import { rollup, RollupOptions } from 'rollup'; import chalk from 'chalk'; -import { relative as relativePath } from 'path'; +import { relative as relativePath, resolve as resolvePath } from 'path'; import { paths } from '../paths'; import { makeRollupConfigs } from './config'; import { BuildOptions, Output } from './types'; import { buildTypeDefinitions } from './buildTypeDefinitions'; +import { getRoleInfo } from '../role'; export function formatErrorMessage(error: any) { let msg = ''; @@ -116,3 +117,47 @@ export const buildPackage = async (options: BuildOptions) => { await Promise.all(buildTasks); }; + +export const buildPackages = async (options: BuildOptions[]) => { + if (options.some(opt => !opt.targetDir)) { + throw new Error('targetDir must be set for all build options'); + } + const rollupConfigs = await Promise.all(options.map(makeRollupConfigs)); + + await Promise.all( + options.map(({ targetDir }) => fs.remove(resolvePath(targetDir!, 'dist'))), + ); + + const buildTasks = rollupConfigs.flat().map(rollupBuild); + + const typeDefinitionTargetDirs = options + .filter( + ({ outputs, useApiExtractor }) => + outputs.has(Output.types) && useApiExtractor, + ) + .map(_ => _.targetDir!); + + if (typeDefinitionTargetDirs.length > 0) { + buildTasks.push(buildTypeDefinitions(typeDefinitionTargetDirs)); + } + + await Promise.all(buildTasks); +}; + +export function getOutputsForRole(role: string): Set { + const outputs = new Set(); + + for (const output of getRoleInfo(role).output) { + if (output === 'cjs') { + outputs.add(Output.cjs); + } + if (output === 'esm') { + outputs.add(Output.esm); + } + if (output === 'types') { + outputs.add(Output.types); + } + } + + return outputs; +} diff --git a/packages/cli/src/lib/builder/types.ts b/packages/cli/src/lib/builder/types.ts index a02afef01a..f43d84cd92 100644 --- a/packages/cli/src/lib/builder/types.ts +++ b/packages/cli/src/lib/builder/types.ts @@ -21,6 +21,8 @@ export enum Output { } export type BuildOptions = { + logPrefix?: string; + targetDir?: string; outputs: Set; minify?: boolean; useApiExtractor?: boolean; diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index d465b3010f..3d4925c66b 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -15,55 +15,58 @@ */ import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; import { paths } from '../paths'; export type BundlingPathsOptions = { // bundle entrypoint, e.g. 'src/index' entry: string; + // Target directory, defaulting to paths.targetDir + targetDir?: string; }; export function resolveBundlingPaths(options: BundlingPathsOptions) { - const { entry } = options; + const { entry, targetDir = paths.targetDir } = options; const resolveTargetModule = (pathString: string) => { for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) { - const filePath = paths.resolveTarget(`${pathString}.${ext}`); + const filePath = resolvePath(targetDir, `${pathString}.${ext}`); if (fs.pathExistsSync(filePath)) { return filePath; } } - return paths.resolveTarget(`${pathString}.js`); + return resolvePath(targetDir, `${pathString}.js`); }; let targetPublic = undefined; - let targetHtml = paths.resolveTarget('public/index.html'); + let targetHtml = resolvePath(targetDir, 'public/index.html'); // Prefer public folder if (fs.pathExistsSync(targetHtml)) { - targetPublic = paths.resolveTarget('public'); + targetPublic = resolvePath(targetDir, 'public'); } else { - targetHtml = paths.resolveTarget(`${entry}.html`); + targetHtml = resolvePath(targetDir, `${entry}.html`); if (!fs.pathExistsSync(targetHtml)) { targetHtml = paths.resolveOwn('templates/serve_index.html'); } } // Backend plugin dev run file - const targetRunFile = paths.resolveTarget('src/run.ts'); + const targetRunFile = resolvePath(targetDir, 'src/run.ts'); const runFileExists = fs.pathExistsSync(targetRunFile); return { targetHtml, targetPublic, - targetPath: paths.resolveTarget('.'), + targetPath: resolvePath(targetDir, '.'), targetRunFile: runFileExists ? targetRunFile : undefined, - targetDist: paths.resolveTarget('dist'), - targetAssets: paths.resolveTarget('assets'), - targetSrc: paths.resolveTarget('src'), - targetDev: paths.resolveTarget('dev'), + targetDist: resolvePath(targetDir, 'dist'), + targetAssets: resolvePath(targetDir, 'assets'), + targetSrc: resolvePath(targetDir, 'src'), + targetDev: resolvePath(targetDir, 'dev'), targetEntry: resolveTargetModule(entry), targetTsConfig: paths.resolveTargetRoot('tsconfig.json'), - targetPackageJson: paths.resolveTarget('package.json'), + targetPackageJson: resolvePath(targetDir, 'package.json'), rootNodeModules: paths.resolveTargetRoot('node_modules'), root: paths.targetRoot, }; diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index eae0452d8d..14e60c8892 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -35,6 +35,8 @@ export type ServeOptions = BundlingPathsOptions & { }; export type BuildOptions = BundlingPathsOptions & { + // Target directory, defaulting to paths.targetDir + targetDir?: string; statsJsonEnabled: boolean; parallel?: ParallelOption; schema?: ConfigSchema;