Skip to content

Commit a978d34

Browse files
huntiefacebook-github-bot
authored andcommitted
Add auto-generation of TypeScript definitions on build (#38990)
Summary: Pull Request resolved: #38990 This PR adds auto-generation of Typescript definitions from Flow source code for packages using the shared monorepo build setup (#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
1 parent e44fdfe commit a978d34

8 files changed

Lines changed: 339 additions & 64 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
* @format
9+
*/
10+
11+
declare module '@tsconfig/node18/tsconfig.json' {
12+
declare module.exports: any;
13+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
* @format
9+
*/
10+
11+
declare module 'typescript' {
12+
declare enum ModuleResolutionKind {
13+
Classic = 'Classic',
14+
NodeJs = 'NodeJs',
15+
Node10 = 'Node10',
16+
Node16 = 'Node16',
17+
NodeNext = 'NodeNext',
18+
Bundler = 'Bundler',
19+
}
20+
21+
declare type SourceFile = $ReadOnly<{
22+
fileName: string,
23+
text: string,
24+
...
25+
}>;
26+
27+
declare type Diagnostic = $ReadOnly<{
28+
file?: SourceFile,
29+
start?: number,
30+
messageText: string,
31+
...
32+
}>;
33+
34+
declare type EmitResult = $ReadOnly<{
35+
diagnostics: Array<Diagnostic>,
36+
...
37+
}>;
38+
39+
declare type Program = $ReadOnly<{
40+
emit: () => EmitResult,
41+
...
42+
}>;
43+
44+
declare type TypeScriptAPI = {
45+
createProgram(files: Array<string>, compilerOptions: Object): Program,
46+
flattenDiagnosticMessageText: (...messageText: Array<string>) => string,
47+
getLineAndCharacterOfPosition(
48+
file: SourceFile,
49+
start?: number,
50+
): $ReadOnly<{line: number, character: number}>,
51+
ModuleResolutionKind: typeof ModuleResolutionKind,
52+
...
53+
};
54+
55+
declare module.exports: TypeScriptAPI;
56+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@pkgjs/parseargs": "^0.11.0",
5858
"@react-native/metro-babel-transformer": "^0.73.11",
5959
"@react-native/metro-config": "^0.73.0",
60+
"@tsconfig/node18": "1.0.1",
6061
"@types/react": "^18.0.18",
6162
"@typescript-eslint/parser": "^5.57.1",
6263
"async": "^3.2.2",
@@ -81,6 +82,7 @@
8182
"eslint-plugin-react-native": "^4.0.0",
8283
"eslint-plugin-redundant-undefined": "^0.4.0",
8384
"eslint-plugin-relay": "^1.8.3",
85+
"flow-api-translator": "0.15.0",
8486
"flow-bin": "^0.214.0",
8587
"glob": "^7.1.1",
8688
"hermes-eslint": "0.15.0",

packages/dev-middleware/src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ if (!process.env.BUILD_EXCLUDE_BABEL_REGISTER) {
1717
require('../../../scripts/build/babel-register').registerForMonorepo();
1818
}
1919

20-
module.exports = require('./index.flow');
20+
export * from './index.flow';

scripts/build/babel/node.config.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
/*::
13+
import type {BabelCoreOptions} from '@babel/core';
14+
*/
15+
16+
const TARGET_NODE_VERSION = '18';
17+
18+
const config /*: BabelCoreOptions */ = {
19+
presets: [
20+
'@babel/preset-flow',
21+
[
22+
'@babel/preset-env',
23+
{
24+
targets: {
25+
node: TARGET_NODE_VERSION,
26+
},
27+
},
28+
],
29+
],
30+
plugins: [
31+
[
32+
'transform-define',
33+
{
34+
'process.env.BUILD_EXCLUDE_BABEL_REGISTER': true,
35+
},
36+
],
37+
[
38+
'minify-dead-code-elimination',
39+
{keepFnName: true, keepFnArgs: true, keepClassName: true},
40+
],
41+
],
42+
};
43+
44+
module.exports = config;

scripts/build/build.js

Lines changed: 123 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,22 @@
1212
const babel = require('@babel/core');
1313
const {parseArgs} = require('@pkgjs/parseargs');
1414
const chalk = require('chalk');
15+
const translate = require('flow-api-translator');
1516
const glob = require('glob');
1617
const micromatch = require('micromatch');
17-
const fs = require('fs');
18+
const {promises: fs} = require('fs');
1819
const path = require('path');
1920
const prettier = require('prettier');
20-
const {buildConfig, getBabelConfig} = require('./config');
21-
22-
const PACKAGES_DIR /*: string */ = path.resolve(__dirname, '../../packages');
21+
const ts = require('typescript');
22+
const {
23+
buildConfig,
24+
getBabelConfig,
25+
getBuildOptions,
26+
getTypeScriptCompilerOptions,
27+
} = require('./config');
28+
29+
const REPO_ROOT = path.resolve(__dirname, '../..');
30+
const PACKAGES_DIR /*: string */ = path.join(REPO_ROOT, 'packages');
2331
const SRC_DIR = 'src';
2432
const BUILD_DIR = 'dist';
2533
const JS_FILES_PATTERN = '**/*.js';
@@ -32,7 +40,7 @@ const config = {
3240
},
3341
};
3442

35-
function build() {
43+
async function build() {
3644
const {
3745
positionals: packageNames,
3846
values: {help},
@@ -53,35 +61,48 @@ function build() {
5361

5462
console.log('\n' + chalk.bold.inverse('Building packages') + '\n');
5563

56-
if (packageNames.length) {
57-
packageNames
58-
.filter(packageName => packageName in buildConfig.packages)
59-
.forEach(buildPackage);
60-
} else {
61-
Object.keys(buildConfig.packages).forEach(buildPackage);
64+
const packagesToBuild = packageNames.length
65+
? packageNames.filter(packageName => packageName in buildConfig.packages)
66+
: Object.keys(buildConfig.packages);
67+
68+
for (const packageName of packagesToBuild) {
69+
await buildPackage(packageName);
6270
}
6371

6472
process.exitCode = 0;
6573
}
6674

67-
function buildPackage(packageName /*: string */) {
75+
async function buildPackage(packageName /*: string */) {
76+
const {emitTypeScriptDefs} = getBuildOptions(packageName);
6877
const files = glob.sync(
6978
path.resolve(PACKAGES_DIR, packageName, SRC_DIR, '**/*'),
7079
{nodir: true},
7180
);
72-
const packageJsonPath = path.join(PACKAGES_DIR, packageName, 'package.json');
7381

7482
process.stdout.write(
7583
`${packageName} ${chalk.dim('.').repeat(72 - packageName.length)} `,
7684
);
77-
files.forEach(file => buildFile(path.normalize(file), true));
78-
rewritePackageExports(packageJsonPath);
85+
86+
// Build all files matched for package
87+
for (const file of files) {
88+
await buildFile(path.normalize(file), true);
89+
}
90+
91+
// Validate program for emitted .d.ts files
92+
if (emitTypeScriptDefs) {
93+
validateTypeScriptDefs(packageName);
94+
}
95+
96+
// Rewrite package.json "exports" field (src -> dist)
97+
await rewritePackageExports(packageName);
98+
7999
process.stdout.write(chalk.reset.inverse.bold.green(' DONE ') + '\n');
80100
}
81101

82-
function buildFile(file /*: string */, silent /*: boolean */ = false) {
102+
async function buildFile(file /*: string */, silent /*: boolean */ = false) {
83103
const packageName = getPackageName(file);
84104
const buildPath = getBuildPath(file);
105+
const {emitFlowDefs, emitTypeScriptDefs} = getBuildOptions(packageName);
85106

86107
const logResult = ({copied, desc} /*: {copied: boolean, desc?: string} */) =>
87108
silent ||
@@ -97,24 +118,43 @@ function buildFile(file /*: string */, silent /*: boolean */ = false) {
97118
return;
98119
}
99120

100-
fs.mkdirSync(path.dirname(buildPath), {recursive: true});
121+
await fs.mkdir(path.dirname(buildPath), {recursive: true});
101122

102123
if (!micromatch.isMatch(file, JS_FILES_PATTERN)) {
103-
fs.copyFileSync(file, buildPath);
124+
await fs.copyFile(file, buildPath);
104125
logResult({copied: true, desc: 'copy'});
105-
} else {
106-
const transformed = prettier.format(
107-
babel.transformFileSync(file, getBabelConfig(packageName)).code,
108-
{parser: 'babel'},
109-
);
110-
fs.writeFileSync(buildPath, transformed);
126+
return;
127+
}
111128

112-
if (/@flow/.test(fs.readFileSync(file, 'utf-8'))) {
113-
fs.copyFileSync(file, buildPath + '.flow');
114-
}
129+
const source = await fs.readFile(file, 'utf-8');
130+
const prettierConfig = {parser: 'babel'};
115131

116-
logResult({copied: true});
132+
// Transform source file using Babel
133+
const transformed = prettier.format(
134+
(await babel.transformFileAsync(file, getBabelConfig(packageName))).code,
135+
prettierConfig,
136+
);
137+
await fs.writeFile(buildPath, transformed);
138+
139+
// Translate source Flow types for each type definition target
140+
if (/@flow/.test(source)) {
141+
await Promise.all([
142+
emitFlowDefs
143+
? fs.writeFile(
144+
buildPath + '.flow',
145+
await translate.translateFlowToFlowDef(source, prettierConfig),
146+
)
147+
: null,
148+
emitTypeScriptDefs
149+
? fs.writeFile(
150+
buildPath.replace(/\.js$/, '') + '.d.ts',
151+
await translate.translateFlowToTSDef(source, prettierConfig),
152+
)
153+
: null,
154+
]);
117155
}
156+
157+
logResult({copied: true});
118158
}
119159

120160
function getPackageName(file /*: string */) /*: string */ {
@@ -130,16 +170,22 @@ function getBuildPath(file /*: string */) /*: string */ {
130170
);
131171
}
132172

133-
function rewritePackageExports(packageJsonPath /*: string */) {
134-
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, {encoding: 'utf8'}));
173+
async function rewritePackageExports(packageName /*: string */) {
174+
const packageJsonPath = path.join(PACKAGES_DIR, packageName, 'package.json');
175+
const pkg = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
135176

136177
if (pkg.exports == null) {
137-
return;
178+
throw new Error(
179+
packageName +
180+
' does not define an "exports" field in its package.json. As part ' +
181+
'of the build setup, this field must be used in order to rewrite ' +
182+
'paths to built files in production.',
183+
);
138184
}
139185

140186
pkg.exports = rewriteExportsField(pkg.exports);
141187

142-
fs.writeFileSync(
188+
await fs.writeFile(
143189
packageJsonPath,
144190
prettier.format(JSON.stringify(pkg), {parser: 'json'}),
145191
);
@@ -173,6 +219,49 @@ function rewriteExportsTarget(target /*: string */) /*: string */ {
173219
return target.replace('./' + SRC_DIR + '/', './' + BUILD_DIR + '/');
174220
}
175221

222+
function validateTypeScriptDefs(packageName /*: string */) {
223+
const files = glob.sync(
224+
path.resolve(PACKAGES_DIR, packageName, BUILD_DIR, '**/*.d.ts'),
225+
);
226+
const compilerOptions = {
227+
...getTypeScriptCompilerOptions(packageName),
228+
noEmit: true,
229+
skipLibCheck: false,
230+
};
231+
const program = ts.createProgram(files, compilerOptions);
232+
const emitResult = program.emit();
233+
234+
if (emitResult.diagnostics.length) {
235+
for (const diagnostic of emitResult.diagnostics) {
236+
if (diagnostic.file != null) {
237+
let {line, character} = ts.getLineAndCharacterOfPosition(
238+
diagnostic.file,
239+
diagnostic.start,
240+
);
241+
let message = ts.flattenDiagnosticMessageText(
242+
diagnostic.messageText,
243+
'\n',
244+
);
245+
console.log(
246+
// $FlowIssue[incompatible-use] Type refined above
247+
`${diagnostic.file.fileName} (${line + 1},${
248+
character + 1
249+
}): ${message}`,
250+
);
251+
} else {
252+
console.log(
253+
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
254+
);
255+
}
256+
}
257+
258+
throw new Error(
259+
'Failing build because TypeScript errors were encountered for ' +
260+
'generated type definitions.',
261+
);
262+
}
263+
}
264+
176265
module.exports = {
177266
buildFile,
178267
getBuildPath,
@@ -182,5 +271,6 @@ module.exports = {
182271
};
183272

184273
if (require.main === module) {
185-
build();
274+
// eslint-disable-next-line no-void
275+
void build();
186276
}

0 commit comments

Comments
 (0)