diff --git a/flow-typed/npm/@tsconfig/node18_v1.x.x.js b/flow-typed/npm/@tsconfig/node18_v1.x.x.js new file mode 100644 index 00000000000000..3e7f193200ffcb --- /dev/null +++ b/flow-typed/npm/@tsconfig/node18_v1.x.x.js @@ -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; +} diff --git a/flow-typed/npm/typescript_v5.x.x.js b/flow-typed/npm/typescript_v5.x.x.js new file mode 100644 index 00000000000000..683b8589bf64ea --- /dev/null +++ b/flow-typed/npm/typescript_v5.x.x.js @@ -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, + ... + }>; + + declare type Program = $ReadOnly<{ + emit: () => EmitResult, + ... + }>; + + declare type TypeScriptAPI = { + createProgram(files: Array, compilerOptions: Object): Program, + flattenDiagnosticMessageText: (...messageText: Array) => string, + getLineAndCharacterOfPosition( + file: SourceFile, + start?: number, + ): $ReadOnly<{line: number, character: number}>, + ModuleResolutionKind: typeof ModuleResolutionKind, + ... + }; + + declare module.exports: TypeScriptAPI; +} diff --git a/package.json b/package.json index 983b9f055026f7..5d75feee2b6055 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/dev-middleware/src/index.js b/packages/dev-middleware/src/index.js index c95fbd8400e1d4..b1cd97b5d4555d 100644 --- a/packages/dev-middleware/src/index.js +++ b/packages/dev-middleware/src/index.js @@ -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'; diff --git a/scripts/build/babel/node.config.js b/scripts/build/babel/node.config.js new file mode 100644 index 00000000000000..95972f63b0695d --- /dev/null +++ b/scripts/build/babel/node.config.js @@ -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; diff --git a/scripts/build/build.js b/scripts/build/build.js index c7c65d83ea7cd0..13c1224d536d3b 100644 --- a/scripts/build/build.js +++ b/scripts/build/build.js @@ -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 PACKAGES_DIR /*: string */ = path.resolve(__dirname, '../../packages'); +const ts = require('typescript'); +const { + buildConfig, + getBabelConfig, + getBuildOptions, + getTypeScriptCompilerOptions, +} = require('./config'); + +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); + return; + } - if (/@flow/.test(fs.readFileSync(file, 'utf-8'))) { - fs.copyFileSync(file, buildPath + '.flow'); - } + const source = await fs.readFile(file, 'utf-8'); + const prettierConfig = {parser: 'babel'}; - logResult({copied: true}); + // 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(); } diff --git a/scripts/build/config.js b/scripts/build/config.js index 854d7bff628e76..a2014995b829d9 100644 --- a/scripts/build/config.js +++ b/scripts/build/config.js @@ -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 */, +) /*: Required */ { + return { + ...defaultBuildOptions, + ...buildConfig.packages[packageName], + }; +} + function getBabelConfig( packageName /*: $Keys */, ) /*: BabelCoreOptions */ { - const {target} = buildConfig.packages[packageName]; + const {target} = getBuildOptions(packageName); + + switch (target) { + case 'node': + return require('./babel/node.config.js'); + } +} + +function getTypeScriptCompilerOptions( + packageName /*: $Keys */, +) /*: 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, }; diff --git a/yarn.lock b/yarn.lock index f97611334f0014..cc5dda5747b437 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"