cli: run API Extractor type builds in worker thread

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2022-01-29 12:20:27 +01:00
parent 4917c71d00
commit 03b39bbcc3
2 changed files with 163 additions and 94 deletions
@@ -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<void>((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`,
);
}
}
});
}
@@ -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' });
}
}