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
This commit is contained in:
Blake Friedman
2024-05-13 14:06:33 -07:00
committed by Facebook GitHub Bot
parent 6b56fb0d0b
commit 2fd7733137
2 changed files with 199 additions and 36 deletions
+1 -1
View File
@@ -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",
+198 -35
View File
@@ -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<string>): 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<string>
): {
validate: Task<void>,
run: Task<ExecaPromise>,
} => ({
/* 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<void>,
javascript: Task<ExecaPromise>,
sourcemap?: Task<void>,
validateHermesc?: Task<ExecaPromise>,
convert?: Task<ExecaPromise>,
compose?: Task<ExecaPromise>,
};
const bundleApp = (
options: BundlerOptions,
...metroArgs: $ReadOnlyArray<string>
) => {
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;
};