Add auto-generation of TypeScript definitions on build (#38990)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/38990

This PR adds auto-generation of Typescript definitions from Flow source code for packages using the shared monorepo build setup (https://github.com/facebook/react-native/pull/38718).

Today, these are the following Node.js packages:

- `packages/community-cli-plugin`
- `packages/dev-middleware` (⬅️ `emitTypeScriptDefs` enabled)

This also improves emitted Flow definitions (`.js.flow`), by using [`flow-api-translator`](https://www.npmjs.com/package/flow-api-translator) to strip implementations.

**All changes**

- Include `flow-api-translator` and configure this to emit type definitions as part of `yarn build`.
    - Add translation from Flow source to TypeScript definitions (`.d.ts`) adjacent to each built file.
    - Improve emitted Flow definitions (`.js.flow`), by using `flow-api-translator` to strip implementations (previously, source files were copied). The Flow and TS defs now mirror each other.
-  Add `emitFlowDefs` and `emitTypeScriptDefs` options to build config to configure the above.
- Integrate TypeScript compiler to perform program validation on emitted `.d.ts` files.
     - This is based on this guide: https://github.com/microsoft/TypeScript-wiki/blob/main/Using-the-Compiler-API.md#a-minimal-compiler.
- Throw an exception on the `rewritePackageExports` step if a package does not define an `"exports"` field.
- Add minimal `flow-typed` definitions for `typescript` 😄.

**Notes on [`flow-api-translator`](https://www.npmjs.com/package/flow-api-translator)**

This project is experimental but is in a more mature state than when we evaluated it earlier in 2023.
- It's now possible to run this tool on our new Node.js packages, since they are exclusively authored using `import`/`export` syntax (a requirement of the tool).
- As a safety net, we run the TypeScript compiler against the generated program, which will fail the build.

Changelog: [Internal]

Reviewed By: robhogan

Differential Revision: D48312463

fbshipit-source-id: 817edb35f911f52fa987946f2d8fc1a319078c9d
This commit is contained in:
Alex Hunt
2023-08-14 12:12:10 -07:00
committed by Facebook GitHub Bot
parent e44fdfed60
commit a978d343a6
8 changed files with 340 additions and 65 deletions
+13
View File
@@ -0,0 +1,13 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
*/
declare module '@tsconfig/node18/tsconfig.json' {
declare module.exports: any;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
*/
declare module 'typescript' {
declare enum ModuleResolutionKind {
Classic = 'Classic',
NodeJs = 'NodeJs',
Node10 = 'Node10',
Node16 = 'Node16',
NodeNext = 'NodeNext',
Bundler = 'Bundler',
}
declare type SourceFile = $ReadOnly<{
fileName: string,
text: string,
...
}>;
declare type Diagnostic = $ReadOnly<{
file?: SourceFile,
start?: number,
messageText: string,
...
}>;
declare type EmitResult = $ReadOnly<{
diagnostics: Array<Diagnostic>,
...
}>;
declare type Program = $ReadOnly<{
emit: () => EmitResult,
...
}>;
declare type TypeScriptAPI = {
createProgram(files: Array<string>, compilerOptions: Object): Program,
flattenDiagnosticMessageText: (...messageText: Array<string>) => string,
getLineAndCharacterOfPosition(
file: SourceFile,
start?: number,
): $ReadOnly<{line: number, character: number}>,
ModuleResolutionKind: typeof ModuleResolutionKind,
...
};
declare module.exports: TypeScriptAPI;
}
+2
View File
@@ -57,6 +57,7 @@
"@pkgjs/parseargs": "^0.11.0",
"@react-native/metro-babel-transformer": "^0.73.11",
"@react-native/metro-config": "^0.73.0",
"@tsconfig/node18": "1.0.1",
"@types/react": "^18.0.18",
"@typescript-eslint/parser": "^5.57.1",
"async": "^3.2.2",
@@ -81,6 +82,7 @@
"eslint-plugin-react-native": "^4.0.0",
"eslint-plugin-redundant-undefined": "^0.4.0",
"eslint-plugin-relay": "^1.8.3",
"flow-api-translator": "0.15.0",
"flow-bin": "^0.214.0",
"glob": "^7.1.1",
"hermes-eslint": "0.15.0",
+1 -1
View File
@@ -17,4 +17,4 @@ if (!process.env.BUILD_EXCLUDE_BABEL_REGISTER) {
require('../../../scripts/build/babel-register').registerForMonorepo();
}
module.exports = require('./index.flow');
export * from './index.flow';
+44
View File
@@ -0,0 +1,44 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
* @oncall react_native
*/
/*::
import type {BabelCoreOptions} from '@babel/core';
*/
const TARGET_NODE_VERSION = '18';
const config /*: BabelCoreOptions */ = {
presets: [
'@babel/preset-flow',
[
'@babel/preset-env',
{
targets: {
node: TARGET_NODE_VERSION,
},
},
],
],
plugins: [
[
'transform-define',
{
'process.env.BUILD_EXCLUDE_BABEL_REGISTER': true,
},
],
[
'minify-dead-code-elimination',
{keepFnName: true, keepFnArgs: true, keepClassName: true},
],
],
};
module.exports = config;
+124 -34
View File
@@ -12,14 +12,22 @@
const babel = require('@babel/core');
const {parseArgs} = require('@pkgjs/parseargs');
const chalk = require('chalk');
const translate = require('flow-api-translator');
const glob = require('glob');
const micromatch = require('micromatch');
const fs = require('fs');
const {promises: fs} = require('fs');
const path = require('path');
const prettier = require('prettier');
const {buildConfig, getBabelConfig} = require('./config');
const ts = require('typescript');
const {
buildConfig,
getBabelConfig,
getBuildOptions,
getTypeScriptCompilerOptions,
} = require('./config');
const PACKAGES_DIR /*: string */ = path.resolve(__dirname, '../../packages');
const REPO_ROOT = path.resolve(__dirname, '../..');
const PACKAGES_DIR /*: string */ = path.join(REPO_ROOT, 'packages');
const SRC_DIR = 'src';
const BUILD_DIR = 'dist';
const JS_FILES_PATTERN = '**/*.js';
@@ -32,7 +40,7 @@ const config = {
},
};
function build() {
async function build() {
const {
positionals: packageNames,
values: {help},
@@ -53,35 +61,48 @@ function build() {
console.log('\n' + chalk.bold.inverse('Building packages') + '\n');
if (packageNames.length) {
packageNames
.filter(packageName => packageName in buildConfig.packages)
.forEach(buildPackage);
} else {
Object.keys(buildConfig.packages).forEach(buildPackage);
const packagesToBuild = packageNames.length
? packageNames.filter(packageName => packageName in buildConfig.packages)
: Object.keys(buildConfig.packages);
for (const packageName of packagesToBuild) {
await buildPackage(packageName);
}
process.exitCode = 0;
}
function buildPackage(packageName /*: string */) {
async function buildPackage(packageName /*: string */) {
const {emitTypeScriptDefs} = getBuildOptions(packageName);
const files = glob.sync(
path.resolve(PACKAGES_DIR, packageName, SRC_DIR, '**/*'),
{nodir: true},
);
const packageJsonPath = path.join(PACKAGES_DIR, packageName, 'package.json');
process.stdout.write(
`${packageName} ${chalk.dim('.').repeat(72 - packageName.length)} `,
);
files.forEach(file => buildFile(path.normalize(file), true));
rewritePackageExports(packageJsonPath);
// Build all files matched for package
for (const file of files) {
await buildFile(path.normalize(file), true);
}
// Validate program for emitted .d.ts files
if (emitTypeScriptDefs) {
validateTypeScriptDefs(packageName);
}
// Rewrite package.json "exports" field (src -> dist)
await rewritePackageExports(packageName);
process.stdout.write(chalk.reset.inverse.bold.green(' DONE ') + '\n');
}
function buildFile(file /*: string */, silent /*: boolean */ = false) {
async function buildFile(file /*: string */, silent /*: boolean */ = false) {
const packageName = getPackageName(file);
const buildPath = getBuildPath(file);
const {emitFlowDefs, emitTypeScriptDefs} = getBuildOptions(packageName);
const logResult = ({copied, desc} /*: {copied: boolean, desc?: string} */) =>
silent ||
@@ -97,24 +118,43 @@ function buildFile(file /*: string */, silent /*: boolean */ = false) {
return;
}
fs.mkdirSync(path.dirname(buildPath), {recursive: true});
await fs.mkdir(path.dirname(buildPath), {recursive: true});
if (!micromatch.isMatch(file, JS_FILES_PATTERN)) {
fs.copyFileSync(file, buildPath);
await fs.copyFile(file, buildPath);
logResult({copied: true, desc: 'copy'});
} else {
const transformed = prettier.format(
babel.transformFileSync(file, getBabelConfig(packageName)).code,
{parser: 'babel'},
);
fs.writeFileSync(buildPath, transformed);
if (/@flow/.test(fs.readFileSync(file, 'utf-8'))) {
fs.copyFileSync(file, buildPath + '.flow');
}
logResult({copied: true});
return;
}
const source = await fs.readFile(file, 'utf-8');
const prettierConfig = {parser: 'babel'};
// Transform source file using Babel
const transformed = prettier.format(
(await babel.transformFileAsync(file, getBabelConfig(packageName))).code,
prettierConfig,
);
await fs.writeFile(buildPath, transformed);
// Translate source Flow types for each type definition target
if (/@flow/.test(source)) {
await Promise.all([
emitFlowDefs
? fs.writeFile(
buildPath + '.flow',
await translate.translateFlowToFlowDef(source, prettierConfig),
)
: null,
emitTypeScriptDefs
? fs.writeFile(
buildPath.replace(/\.js$/, '') + '.d.ts',
await translate.translateFlowToTSDef(source, prettierConfig),
)
: null,
]);
}
logResult({copied: true});
}
function getPackageName(file /*: string */) /*: string */ {
@@ -130,16 +170,22 @@ function getBuildPath(file /*: string */) /*: string */ {
);
}
function rewritePackageExports(packageJsonPath /*: string */) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, {encoding: 'utf8'}));
async function rewritePackageExports(packageName /*: string */) {
const packageJsonPath = path.join(PACKAGES_DIR, packageName, 'package.json');
const pkg = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
if (pkg.exports == null) {
return;
throw new Error(
packageName +
' does not define an "exports" field in its package.json. As part ' +
'of the build setup, this field must be used in order to rewrite ' +
'paths to built files in production.',
);
}
pkg.exports = rewriteExportsField(pkg.exports);
fs.writeFileSync(
await fs.writeFile(
packageJsonPath,
prettier.format(JSON.stringify(pkg), {parser: 'json'}),
);
@@ -173,6 +219,49 @@ function rewriteExportsTarget(target /*: string */) /*: string */ {
return target.replace('./' + SRC_DIR + '/', './' + BUILD_DIR + '/');
}
function validateTypeScriptDefs(packageName /*: string */) {
const files = glob.sync(
path.resolve(PACKAGES_DIR, packageName, BUILD_DIR, '**/*.d.ts'),
);
const compilerOptions = {
...getTypeScriptCompilerOptions(packageName),
noEmit: true,
skipLibCheck: false,
};
const program = ts.createProgram(files, compilerOptions);
const emitResult = program.emit();
if (emitResult.diagnostics.length) {
for (const diagnostic of emitResult.diagnostics) {
if (diagnostic.file != null) {
let {line, character} = ts.getLineAndCharacterOfPosition(
diagnostic.file,
diagnostic.start,
);
let message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
'\n',
);
console.log(
// $FlowIssue[incompatible-use] Type refined above
`${diagnostic.file.fileName} (${line + 1},${
character + 1
}): ${message}`,
);
} else {
console.log(
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
);
}
}
throw new Error(
'Failing build because TypeScript errors were encountered for ' +
'generated type definitions.',
);
}
}
module.exports = {
buildFile,
getBuildPath,
@@ -182,5 +271,6 @@ module.exports = {
};
if (require.main === module) {
build();
// eslint-disable-next-line no-void
void build();
}
+56 -29
View File
@@ -11,57 +11,82 @@
/*::
import type {BabelCoreOptions} from '@babel/core';
*/
const {ModuleResolutionKind} = require('typescript');
/*::
export type BuildOptions = $ReadOnly<{
// The target runtime to compile for.
target: 'node',
// Whether to emit Flow definition files (.js.flow) (default: true).
emitFlowDefs?: boolean,
// Whether to emit TypeScript definition files (.d.ts) (default: false).
emitTypeScriptDefs?: boolean,
}>;
export type BuildConfig = $ReadOnly<{
// The packages to include for build and their build options.
packages: $ReadOnly<{[packageName: string]: BuildOptions}>,
}>;
*/
const TARGET_NODE_VERSION = '18';
/**
* - BUILD CONFIG -
*
* Add packages here to configure them as part of the monorepo `yarn build`
* setup. These must use a consistent package structure and (today) target
* Node.js packages only.
*/
const buildConfig /*: BuildConfig */ = {
// The packages to include for build and their build options
packages: {
'community-cli-plugin': {target: 'node'},
'dev-middleware': {target: 'node'},
'community-cli-plugin': {
target: 'node',
},
'dev-middleware': {
target: 'node',
emitTypeScriptDefs: true,
},
},
};
const defaultBuildOptions = {
emitFlowDefs: true,
emitTypeScriptDefs: false,
};
function getBuildOptions(
packageName /*: $Keys<BuildConfig['packages']> */,
) /*: Required<BuildOptions> */ {
return {
...defaultBuildOptions,
...buildConfig.packages[packageName],
};
}
function getBabelConfig(
packageName /*: $Keys<BuildConfig['packages']> */,
) /*: BabelCoreOptions */ {
const {target} = buildConfig.packages[packageName];
const {target} = getBuildOptions(packageName);
switch (target) {
case 'node':
return require('./babel/node.config.js');
}
}
function getTypeScriptCompilerOptions(
packageName /*: $Keys<BuildConfig['packages']> */,
) /*: Object */ {
const {target} = getBuildOptions(packageName);
switch (target) {
case 'node':
return {
presets: [
'@babel/preset-flow',
[
'@babel/preset-env',
{
targets: {
node: TARGET_NODE_VERSION,
},
},
],
],
plugins: [
[
'transform-define',
{
'process.env.BUILD_EXCLUDE_BABEL_REGISTER': true,
},
],
[
'minify-dead-code-elimination',
{keepFnName: true, keepFnArgs: true, keepClassName: true},
],
],
...require('@tsconfig/node18/tsconfig.json').compilerOptions,
moduleResolution: ModuleResolutionKind.NodeJs,
};
}
}
@@ -69,4 +94,6 @@ function getBabelConfig(
module.exports = {
buildConfig,
getBabelConfig,
getBuildOptions,
getTypeScriptCompilerOptions,
};
+44 -1
View File
@@ -2817,6 +2817,11 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
"@tsconfig/node18@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@tsconfig/node18/-/node18-1.0.1.tgz#ea5b375a9ead6b09ccbd70c3894ea069829ea1bb"
integrity sha512-sNFeK6X2ATlhlvzyH4kKYQlfHXE2f2/wxtB9ClvYXevWpmwkUT7VaSrjIN9E76Qebz8qP5JOJJ9jD3QoD/Z9TA==
"@types/archiver@5.3.2":
version "5.3.2"
resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-5.3.2.tgz#a9f0bcb0f0b991400e7766d35f6e19d163bdadcc"
@@ -3328,6 +3333,11 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.5.tgz#e63c5952532306d97c6ea432cee0981f6d2258c7"
integrity sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==
"@typescript-eslint/types@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
"@typescript-eslint/typescript-estree@5.59.5":
version "5.59.5"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz#9b252ce55dd765e972a7a2f99233c439c5101e42"
@@ -3363,6 +3373,14 @@
"@typescript-eslint/types" "5.59.5"
eslint-visitor-keys "^3.3.0"
"@typescript-eslint/visitor-keys@^5.42.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==
dependencies:
"@typescript-eslint/types" "5.62.0"
eslint-visitor-keys "^3.3.0"
"@wdio/config@7.31.1":
version "7.31.1"
resolved "https://registry.yarnpkg.com/@wdio/config/-/config-7.31.1.tgz#53550a164c970403628525ecdc5e0c3f96a0ff30"
@@ -5689,7 +5707,7 @@ esprima@^4.0.0, esprima@~4.0.0:
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.4.2:
esquery@^1.4.0, esquery@^1.4.2:
version "1.5.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b"
integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==
@@ -6072,6 +6090,19 @@ flatted@^3.1.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
flow-api-translator@0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/flow-api-translator/-/flow-api-translator-0.15.0.tgz#3df5af2d45630251f12782679271dc2533bcf094"
integrity sha512-cH5Fo08kQO0vucJfcQqMAPc2De8ooVX/9AhBUiRDhe+Ob98HOSBJamaiFLgTmC9UVnSyIFRKgpG+RRopHWtjwg==
dependencies:
"@babel/code-frame" "^7.16.0"
"@typescript-eslint/visitor-keys" "^5.42.0"
flow-enums-runtime "^0.0.6"
hermes-eslint "0.15.0"
hermes-estree "0.15.0"
hermes-parser "0.15.0"
hermes-transform "0.15.0"
flow-bin@^0.214.0:
version "0.214.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.214.0.tgz#3ace7984a69309392e056f96cf3bf8623fa93d1c"
@@ -6579,6 +6610,18 @@ hermes-profile-transformer@^0.0.6:
dependencies:
source-map "^0.7.3"
hermes-transform@0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/hermes-transform/-/hermes-transform-0.15.0.tgz#5509cf6993abafbdc32528098d287f5c91eef52c"
integrity sha512-ACGdssuE2mcu/qSwSfie1yWQs5IBRYy7yokPbdCkHd4M0jPqCiQbwrlRzQP0+XgyZPooINXWHxkhsI5Iffhppw==
dependencies:
"@babel/code-frame" "^7.16.0"
esquery "^1.4.0"
flow-enums-runtime "^0.0.6"
hermes-eslint "0.15.0"
hermes-estree "0.15.0"
hermes-parser "0.15.0"
homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"