From 2fd7733137b7d7ae44ec42ef146d758ceeefc23e Mon Sep 17 00:00:00 2001 From: Blake Friedman Date: Mon, 13 May 2024 14:06:33 -0700 Subject: [PATCH] Add support for building using Metro (#44464) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/44464 Adds `app` to allow building and serving your React Native app in a similar structure to the boostrap and build tasks. This is the more comprehensive followup to D57067040. Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D57067039 fbshipit-source-id: fdbe891657d826535cb779a4d1b71cfd13921684 --- packages/core-cli-utils/package.json | 2 +- packages/core-cli-utils/src/private/app.js | 233 +++++++++++++++++---- 2 files changed, 199 insertions(+), 36 deletions(-) diff --git a/packages/core-cli-utils/package.json b/packages/core-cli-utils/package.json index 69db11206d8..6aa10997521 100644 --- a/packages/core-cli-utils/package.json +++ b/packages/core-cli-utils/package.json @@ -3,7 +3,7 @@ "version": "0.75.0-main", "description": "React Native CLI library for Frameworks to build on", "license": "MIT", - "main": "./src/index.js", + "main": "./src/index.flow.js", "repository": { "type": "git", "url": "git+https://github.com/facebook/react-native.git", diff --git a/packages/core-cli-utils/src/private/app.js b/packages/core-cli-utils/src/private/app.js index 16b5aed5175..3b727e61283 100644 --- a/packages/core-cli-utils/src/private/app.js +++ b/packages/core-cli-utils/src/private/app.js @@ -13,59 +13,222 @@ import type {Task} from './types'; import type {ExecaPromise} from 'execa'; import {task} from './utils'; +import debug from 'debug'; import execa from 'execa'; +import fs from 'fs'; import path from 'path'; -type AppOptions = { +const log = debug('core-cli-utils'); + +type BundlerOptions = { + // Metro's config: https://metrobundler.dev/docs/configuration/ + config?: string, + // Typically index.{ios,android}.js + entryFile: string, + +platform: 'ios' | 'android' | string, + dev: boolean, + // Metro built main bundle + outputJsBundle: string, + minify: boolean, + optimize: boolean, + // Generate a source map file + outputSourceMap: string, + // Where to pass the final bundle. Typically this is the App's resource + // folder, however this is app specific. React Native will need to know where + // this is to bootstrap your application. See: + // - Android: https://reactnative.dev/docs/integration-with-existing-apps?language=kotlin#creating-a-release-build-in-android-studio + // - iOS: https://reactnative.dev/docs/integration-with-existing-apps?language=swift#2-event-handler + outputBundle: string, cwd: string, + + target: 'hermes' | 'jsc', + hermes?: HermesConfig, + + ...Bundler, }; -type BundleOptions = - | {mode: 'bundle', ...AppOptions} - | {mode: 'watch', callback?: (metro: ExecaPromise) => void, ...AppOptions}; +type HermesConfig = { + // iOS: Pods/hermes-engine/destroot/bin/hermesc + hermesc: string, +}; + +type BundlerWatch = { + +mode: 'watch', + callback?: (metro: ExecaPromise) => void, +}; + +type BundlerBuild = { + +mode: 'bundle', +}; + +type Bundler = BundlerWatch | BundlerBuild; const FIRST = 1, - SECOND = 2; + SECOND = 2, + THIRD = 3, + FOURTH = 4; function getNodePackagePath(packageName: string): string { - // $FlowFixMe[prop-missing] type definition is incomplete + // $FlowIgnore[prop-missing] type definition is incomplete return require.resolve(packageName, {cwd: [process.cwd(), ...module.paths]}); } function metro(...args: $ReadOnlyArray): ExecaPromise { - return execa('node', [ - getNodePackagePath(path.join('metro', 'src', 'cli.js')), - ...args, - ]); + const metroPath = getNodePackagePath(path.join('metro', 'src', 'cli.js')); + log(`🚇 ${metroPath} ${args.join(' ')} `); + return execa('node', [metroPath, ...args]); } -const noMetro = new Error('Metro is not available'); - export const tasks = { bundle: ( - options: BundleOptions, + options: BundlerOptions, ...args: $ReadOnlyArray - ): { - validate: Task, - run: Task, - } => ({ - /* eslint-disable sort-keys */ - validate: task(FIRST, 'Check if Metro is available', () => { - try { - require('metro'); - } catch { - throw noMetro; - } - }), + ): Bundle => { + const steps: Bundle = { + /* eslint-disable sort-keys */ + validate: task(FIRST, 'Check if Metro is available', () => { + try { + require('metro'); + } catch { + throw new Error('Metro is not available'); + } + }), + javascript: task(SECOND, 'Metro watching for changes', () => + metro('serve', ...args), + ), + }; - run: - options.mode === 'bundle' - ? task(SECOND, 'Metro generating an .jsbundle', () => - metro('bundle', ...args), - ) - : task(SECOND, 'Metro watching for changes', () => { - const proc = metro('serve', ...args); - return proc; - }), - }), + return options.mode === 'bundle' + ? Object.assign(steps, bundleApp(options, ...args)) + : steps; + }, +}; + +type Bundle = { + validate?: Task, + javascript: Task, + sourcemap?: Task, + validateHermesc?: Task, + convert?: Task, + compose?: Task, +}; + +const bundleApp = ( + options: BundlerOptions, + ...metroArgs: $ReadOnlyArray +) => { + if (options.outputJsBundle === options.outputBundle) { + throw new Error('outputJsBundle and outputBundle cannot be the same.'); + } + // When using Hermes, Metro should generate the JS bundle to an intermediate file + // to then be converted to bytecode in the outputBundle. Otherwise just write to + // the outputBundle directly. + let output = + options.target === 'hermes' ? options.outputJsBundle : options.outputBundle; + + // TODO: Fix this by not using Metro CLI, which appends a .js extension + if (output === options.outputJsBundle && !output.endsWith('.js')) { + log( + `Appending .js to outputBundle (because metro cli does it if it's missing): ${output}`, + ); + output += '.js'; + } + + const isSourceMaps = options.outputSourceMap != null; + const bundle: Bundle = { + javascript: task(SECOND, 'Metro generating an .jsbundle', () => { + const args = [ + '--platform', + options.platform, + '--dev', + options.dev ? 'true' : 'false', + '--reset-cache', + '--out', + output, + ]; + if (options.target === 'hermes' && !options.dev) { + // Hermes doesn't require JS minification + args.push('--minify', 'false'); + } else { + args.push('--minify', options.minify ? 'true' : 'false'); + } + if (isSourceMaps) { + args.push('--source-map'); + } + return metro('build', options.entryFile, ...args, ...metroArgs); + }), + }; + + if (options.target === 'jsc') { + return bundle; + } + + // $FlowIgnore[incompatible-use] We know it's a Hermes config + const hermesc: string = options.hermes.hermesc; + + /* + * Hermes only tasks: + */ + let composeSourceMaps; + if (isSourceMaps) { + bundle.sourcemap = task( + FIRST, + 'Check if SourceMap script available', + () => { + composeSourceMaps = getNodePackagePath( + 'react-native/scripts/compose-source-maps.js', + ); + }, + ); + } + + bundle.validateHermesc = task(FIRST, 'Check if Hermesc is available', () => + execa(hermesc, ['--version']), + ); + + bundle.convert = task( + THIRD, + 'Hermesc converting .jsbundle → bytecode', + () => { + const args = [ + '-emit-binary', + '-max-diagnostic-width=80', + options.dev === true ? '-Og' : '-O', + ]; + if (isSourceMaps) { + args.push('-output-source-map'); + } + args.push(`-out=${options.outputBundle}`, output); + return execa(hermesc, args, {cwd: options.cwd}); + }, + ); + + bundle.compose = task(FOURTH, 'Compose Hermes and Metro source maps', () => { + if (composeSourceMaps == null) { + throw new Error( + 'Unable to find the compose-source-map.js script in react-native', + ); + } + const metroSourceMap = output.replace(/(\.js)?$/, '.map'); + const hermesSourceMap = options.outputBundle + '.map'; + const compose = execa( + 'node', + [ + composeSourceMaps, + metroSourceMap, + hermesSourceMap, + `-o ${options.outputSourceMap}`, + ], + { + cwd: options.cwd, + }, + ); + compose.finally(() => { + fs.rmSync(metroSourceMap, {force: true}); + fs.rmSync(hermesSourceMap, {force: true}); + }); + return compose; + }); + + return bundle; };