diff --git a/packages/cli/src/lib/builder/buildTypeDefinitions.ts b/packages/cli/src/lib/builder/buildTypeDefinitions.ts index 6822e22ccc..85e54850e4 100644 --- a/packages/cli/src/lib/builder/buildTypeDefinitions.ts +++ b/packages/cli/src/lib/builder/buildTypeDefinitions.ts @@ -16,92 +16,47 @@ 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( targetDirs: string[] = [paths.targetDir], ) { - const { Extractor, ExtractorConfig, CompilerState } = prepareApiExtractor(); - const packageDirs = targetDirs.map(dir => relativePath(paths.targetRoot, dir), ); - const entryPoints = packageDirs.map(dir => - paths.resolveTargetRoot('dist-types', dir, '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) { + 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; + }), ); - let compilerState; - - for (const packageDir of packageDirs) { + const workerConfigs = packageDirs.map(packageDir => { const targetDir = paths.resolveTargetRoot(packageDir); const targetTypesDir = paths.resolveTargetRoot('dist-types', packageDir); - const entryPoint = resolvePath(targetTypesDir, 'src/index.d.ts'); - - 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`, - ); - } - - const extractorConfig = ExtractorConfig.prepare({ + const extractorOptions = { configObject: { - mainEntryPointFilePath: entryPoint, + mainEntryPointFilePath: resolvePath(targetTypesDir, 'src/index.d.ts'), bundledPackages: [], compiler: { @@ -122,24 +77,40 @@ export async function buildTypeDefinitions( }, configObjectFullPath: targetDir, packageJsonFullPath: resolvePath(targetDir, 'package.json'), + }; + return { extractorOptions, targetTypesDir }; + }); + + const typescriptDir = paths.resolveTargetRoot('node_modules/typescript'); + const hasTypescript = await fs.pathExists(typescriptDir); + const typescriptCompilerFolder = hasTypescript ? typescriptDir : undefined; + + const worker = new Worker(`(${buildTypeDefinitionsWorker})()`, { + eval: true, + workerData: { + entryPoints, + workerConfigs, + typescriptCompilerFolder, + }, + }); + + 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 (!compilerState) { - compilerState = CompilerState.create(extractorConfig, { - additionalEntryPoints: entryPoints, - }); - } - - const typescriptDir = paths.resolveTargetRoot('node_modules/typescript'); - const hasTypescript = await fs.pathExists(typescriptDir); - const extractorResult = Extractor.invoke(extractorConfig, { - typescriptCompilerFolder: hasTypescript ? typescriptDir : undefined, - compilerState, - localBuild: false, - showVerboseMessages: false, - showDiagnostics: false, - messageCallback(message) { - message.handled = true; if (ignoredMessages.has(message.messageId)) { return; } @@ -165,14 +136,7 @@ export async function buildTypeDefinitions( } else { console.log(text); } - }, + } }); - - if (!extractorResult.succeeded) { - throw new Error( - `Type definition build completed with ${extractorResult.errorCount} errors` + - ` and ${extractorResult.warningCount} warnings`, - ); - } - } + }); } 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' }); + } +}