diff --git a/flow-typed/npm/@react-native-community/cli-tools_v12.x.x.js b/flow-typed/npm/@react-native-community/cli-tools_v12.x.x.js index 9c78411325744e..a855762f78c9b5 100644 --- a/flow-typed/npm/@react-native-community/cli-tools_v12.x.x.js +++ b/flow-typed/npm/@react-native-community/cli-tools_v12.x.x.js @@ -10,12 +10,38 @@ */ declare module '@react-native-community/cli-tools' { + declare export function addInteractionListener( + callback: (options: {pause: boolean, canEscape?: boolean}) => void, + ): void; + declare export class CLIError extends Error { constructor(msg: string, originalError?: Error | mixed | string): this; } + declare export function getPidFromPort(port: number): number | null; + + declare export function handlePortUnavailable( + initialPort: number, + projectRoot: string, + initialPackager?: boolean, + ): Promise<{ + port: number, + packager: boolean, + }>; + declare export function hookStdout(callback: Function): () => void; + declare export function isPackagerRunning( + packagerPort: string | number | void, + ): Promise< + | { + status: 'running', + root: string, + } + | 'not_running' + | 'unrecognized', + >; + declare export const logger: $ReadOnly<{ success: (...message: Array) => void, info: (...message: Array) => void, @@ -29,6 +55,8 @@ declare module '@react-native-community/cli-tools' { enable: () => void, }>; + declare export function logAlreadyRunningBundler(port: number): void; + declare export function resolveNodeModuleDir( root: string, packageName: string, diff --git a/packages/community-cli-plugin/launchPackager.bat b/packages/community-cli-plugin/launchPackager.bat deleted file mode 100644 index a76f4f06fe353f..00000000000000 --- a/packages/community-cli-plugin/launchPackager.bat +++ /dev/null @@ -1,7 +0,0 @@ -@echo off -title Metro -call .packager.bat -cd "%PROJECT_ROOT%" -node "%REACT_NATIVE_PATH%/cli.js" start -pause -exit diff --git a/packages/community-cli-plugin/launchPackager.command b/packages/community-cli-plugin/launchPackager.command deleted file mode 100755 index 5fb23c56e451ea..00000000000000 --- a/packages/community-cli-plugin/launchPackager.command +++ /dev/null @@ -1,10 +0,0 @@ -THIS_DIR=$(cd -P "$(dirname "$(readlink "${BASH_SOURCE[0]}" || echo "${BASH_SOURCE[0]}")")" && pwd) - -source "$THIS_DIR/.packager.env" -cd $PROJECT_ROOT -$REACT_NATIVE_PATH/cli.js start - -if [[ -z "$CI" ]]; then - echo "Process terminated. Press to close the window" - read -r -fi diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index c95a79108a59dc..027fe1d12be6a8 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -19,9 +19,7 @@ "./package.json": "./package.json" }, "files": [ - "dist", - "launchPackager.bat", - "launchPackager.command" + "dist" ], "dependencies": { "@react-native-community/cli-server-api": "12.0.0-alpha.9", diff --git a/packages/community-cli-plugin/src/commands/start/index.js b/packages/community-cli-plugin/src/commands/start/index.js index 8bb8da768ed257..abc7ac6431b5c6 100644 --- a/packages/community-cli-plugin/src/commands/start/index.js +++ b/packages/community-cli-plugin/src/commands/start/index.js @@ -93,5 +93,3 @@ export default { }, ], }; - -export {startServerInNewWindow} from './startServerInNewWindow'; diff --git a/packages/community-cli-plugin/src/commands/start/runServer.js b/packages/community-cli-plugin/src/commands/start/runServer.js index 92e47ee5eb825d..daadd410b5e623 100644 --- a/packages/community-cli-plugin/src/commands/start/runServer.js +++ b/packages/community-cli-plugin/src/commands/start/runServer.js @@ -16,6 +16,7 @@ import typeof TerminalReporter from 'metro/src/lib/TerminalReporter'; import type Server from 'metro/src/Server'; import type {Middleware} from 'metro-config'; +import chalk from 'chalk'; import Metro from 'metro'; import {Terminal} from 'metro-core'; import path from 'path'; @@ -23,9 +24,15 @@ import { createDevServerMiddleware, indexPageMiddleware, } from '@react-native-community/cli-server-api'; +import { + isPackagerRunning, + logger, + version, + logAlreadyRunningBundler, + handlePortUnavailable, +} from '@react-native-community/cli-tools'; import loadMetroConfig from '../../utils/loadMetroConfig'; -import {version} from '@react-native-community/cli-tools'; import enableWatchMode from './watchMode'; export type Args = { @@ -48,7 +55,43 @@ export type Args = { }; async function runServer(_argv: Array, ctx: Config, args: Args) { - let reportEvent: (event: any) => void; + let port = args.port ?? 8081; + let packager = true; + const packagerStatus = await isPackagerRunning(port); + + if ( + typeof packagerStatus === 'object' && + packagerStatus.status === 'running' + ) { + if (packagerStatus.root === ctx.root) { + packager = false; + logAlreadyRunningBundler(port); + } else { + const result = await handlePortUnavailable(port, ctx.root, packager); + [port, packager] = [result.port, result.packager]; + } + } else if (packagerStatus === 'unrecognized') { + const result = await handlePortUnavailable(port, ctx.root, packager); + [port, packager] = [result.port, result.packager]; + } + + if (packager === false) { + process.exit(); + } + + logger.info(`Starting dev server on port ${chalk.bold(String(port))}`); + + const metroConfig = await loadMetroConfig(ctx, { + config: args.config, + maxWorkers: args.maxWorkers, + port: port, + resetCache: args.resetCache, + watchFolders: args.watchFolders, + projectRoot: args.projectRoot, + sourceExts: args.sourceExts, + }); + + let reportEvent: (event: TerminalReportableEvent) => void; const terminal = new Terminal(process.stdout); const ReporterImpl = getReporterImpl(args.customLogReporterPath); const terminalReporter = new ReporterImpl(terminal); @@ -60,17 +103,8 @@ async function runServer(_argv: Array, ctx: Config, args: Args) { } }, }; - - const metroConfig = await loadMetroConfig(ctx, { - config: args.config, - maxWorkers: args.maxWorkers, - port: args.port, - resetCache: args.resetCache, - watchFolders: args.watchFolders, - projectRoot: args.projectRoot, - sourceExts: args.sourceExts, - reporter, - }); + // $FlowIgnore[cannot-write] Assigning to readonly property + metroConfig.reporter = reporter; if (args.assetPlugins) { // $FlowIgnore[cannot-write] Assigning to readonly property diff --git a/packages/community-cli-plugin/src/commands/start/startServerInNewWindow.js b/packages/community-cli-plugin/src/commands/start/startServerInNewWindow.js deleted file mode 100644 index 677512ce2148c9..00000000000000 --- a/packages/community-cli-plugin/src/commands/start/startServerInNewWindow.js +++ /dev/null @@ -1,125 +0,0 @@ -/** - * 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 {ExecaPromise, SyncResult} from 'execa'; - -import path from 'path'; -import fs from 'fs'; -import execa from 'execa'; -import { - CLIError, - logger, - resolveNodeModuleDir, -} from '@react-native-community/cli-tools'; - -export function startServerInNewWindow( - port: number, - terminal: string, - projectRoot: string, - reactNativePath: string, -): SyncResult | ExecaPromise | CLIError | void { - /** - * Set up OS-specific filenames and commands - */ - const isWindows = /^win/.test(process.platform); - const scriptFile = isWindows - ? 'launchPackager.bat' - : 'launchPackager.command'; - const packagerEnvFilename = isWindows ? '.packager.bat' : '.packager.env'; - const packagerEnvFileExportContent = isWindows - ? `set RCT_METRO_PORT=${port}\nset PROJECT_ROOT=${projectRoot}\nset REACT_NATIVE_PATH=${reactNativePath}` - : `export RCT_METRO_PORT=${port}\nexport PROJECT_ROOT=${projectRoot}\nexport REACT_NATIVE_PATH=${reactNativePath}`; - const nodeModulesPath = resolveNodeModuleDir(projectRoot, '.bin'); - const cliPluginMetroPath = path.join( - path.dirname( - require.resolve('@react-native/community-cli-plugin/package.json'), - ), - 'build', - ); - - /** - * Set up the `.packager.(env|bat)` file to ensure the packager starts on the right port and in right directory. - */ - const packagerEnvFile = path.join(nodeModulesPath, `${packagerEnvFilename}`); - - /** - * Set up the `launchPackager.(command|bat)` file. - * It lives next to `.packager.(bat|env)` - */ - const launchPackagerScript = path.join(nodeModulesPath, scriptFile); - const procConfig = {cwd: path.dirname(packagerEnvFile)}; - - /** - * Ensure we overwrite file by passing the `w` flag - */ - fs.writeFileSync(packagerEnvFile, packagerEnvFileExportContent, { - encoding: 'utf8', - flag: 'w', - }); - - /** - * Copy files into `node_modules/.bin`. - */ - - try { - if (isWindows) { - fs.copyFileSync( - path.join(cliPluginMetroPath, 'launchPackager.bat'), - path.join(nodeModulesPath, 'launchPackager.bat'), - ); - } else { - fs.copyFileSync( - path.join(cliPluginMetroPath, 'launchPackager.command'), - path.join(nodeModulesPath, 'launchPackager.command'), - ); - } - } catch (error) { - return new CLIError( - `Couldn't copy the script for running bundler. Please check if the "${scriptFile}" file exists in the "node_modules/@react-native/community-cli-plugin" folder and try again.`, - error, - ); - } - - if (process.platform === 'darwin') { - try { - return execa.sync( - 'open', - ['-a', terminal, launchPackagerScript], - procConfig, - ); - } catch (error) { - return execa.sync('open', [launchPackagerScript], procConfig); - } - } - if (process.platform === 'linux') { - try { - return execa.sync(terminal, ['-e', `sh ${launchPackagerScript}`], { - ...procConfig, - detached: true, - }); - } catch (error) { - // By default, the child shell process will be attached to the parent - return execa.sync('sh', [launchPackagerScript], procConfig); - } - } - if (isWindows) { - // Awaiting this causes the CLI to hang indefinitely, so this must execute without await. - return execa('cmd.exe', ['/C', launchPackagerScript], { - ...procConfig, - detached: true, - stdio: 'ignore', - }); - } - logger.error( - `Cannot start the packager. Unknown platform ${process.platform}`, - ); - return; -} diff --git a/packages/community-cli-plugin/src/commands/start/watchMode.js b/packages/community-cli-plugin/src/commands/start/watchMode.js index 42721e4ef1555f..2a2e15fb833488 100644 --- a/packages/community-cli-plugin/src/commands/start/watchMode.js +++ b/packages/community-cli-plugin/src/commands/start/watchMode.js @@ -12,9 +12,17 @@ import type {Config} from '@react-native-community/cli-types'; import readline from 'readline'; -import {logger, hookStdout} from '@react-native-community/cli-tools'; +import { + addInteractionListener, + logger, + hookStdout, +} from '@react-native-community/cli-tools'; import execa from 'execa'; import chalk from 'chalk'; +import {KeyPressHandler} from '../../utils/KeyPressHandler'; + +const CTRL_C = '\u0003'; +const CTRL_Z = '\u0026'; function printWatchModeInstructions() { logger.log( @@ -59,43 +67,44 @@ function enableWatchMode( } }); - process.stdin.on( - 'keypress', - (_key: string, data: {ctrl: boolean, name: string}) => { - const {ctrl, name} = data; - if (ctrl === true) { - switch (name) { - case 'c': - process.exit(); - break; - case 'z': - process.emit('SIGTSTP', 'SIGTSTP'); - break; - } - } else if (name === 'r') { + const onPressAsync = async (key: string) => { + switch (key) { + case 'r': messageSocket.broadcast('reload', null); logger.info('Reloading app...'); - } else if (name === 'd') { + break; + case 'd': messageSocket.broadcast('devMenu', null); - logger.info('Opening developer menu...'); - } else if (name === 'i' || name === 'a') { - logger.info( - `Opening the app on ${name === 'i' ? 'iOS' : 'Android'}...`, - ); - const params = - name === 'i' - ? ctx.project.ios?.watchModeCommandParams - : ctx.project.android?.watchModeCommandParams; + logger.info('Opening Dev Menu...'); + break; + case 'i': + logger.info('Opening app on iOS...'); execa('npx', [ 'react-native', - name === 'i' ? 'run-ios' : 'run-android', - ...(params ?? []), + 'run-ios', + ...(ctx.project.ios?.watchModeCommandParams ?? []), ]).stdout?.pipe(process.stdout); - } else { - console.log(_key); - } - }, - ); + break; + case 'a': + logger.info('Opening app on Android...'); + execa('npx', [ + 'react-native', + 'run-android', + ...(ctx.project.android?.watchModeCommandParams ?? []), + ]).stdout?.pipe(process.stdout); + break; + case CTRL_Z: + process.emit('SIGTSTP', 'SIGTSTP'); + break; + case CTRL_C: + process.exit(); + } + }; + + const keyPressHandler = new KeyPressHandler(onPressAsync); + const listener = keyPressHandler.createInteractionListener(); + addInteractionListener(listener); + keyPressHandler.startInterceptingKeyStrokes(); } export default enableWatchMode; diff --git a/packages/community-cli-plugin/src/utils/KeyPressHandler.js b/packages/community-cli-plugin/src/utils/KeyPressHandler.js new file mode 100644 index 00000000000000..2ae1b5d890ff01 --- /dev/null +++ b/packages/community-cli-plugin/src/utils/KeyPressHandler.js @@ -0,0 +1,89 @@ +/** + * 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 {CLIError, logger} from '@react-native-community/cli-tools'; + +const CTRL_C = '\u0003'; + +/** An abstract key stroke interceptor. */ +export class KeyPressHandler { + _isInterceptingKeyStrokes = false; + _isHandlingKeyPress = false; + _onPress: (key: string) => Promise; + + constructor(onPress: (key: string) => Promise) { + this._onPress = onPress; + } + + /** Start observing interaction pause listeners. */ + createInteractionListener(): ({pause: boolean, ...}) => void { + // Support observing prompts. + let wasIntercepting = false; + + const listener = ({pause}: {pause: boolean, ...}) => { + if (pause) { + // Track if we were already intercepting key strokes before pausing, so we can + // resume after pausing. + wasIntercepting = this._isInterceptingKeyStrokes; + this.stopInterceptingKeyStrokes(); + } else if (wasIntercepting) { + // Only start if we were previously intercepting. + this.startInterceptingKeyStrokes(); + } + }; + + return listener; + } + + _handleKeypress = async (key: string): Promise => { + // Prevent sending another event until the previous event has finished. + if (this._isHandlingKeyPress && key !== CTRL_C) { + return; + } + this._isHandlingKeyPress = true; + try { + logger.debug(`Key pressed: ${key}`); + await this._onPress(key); + } catch (error) { + return new CLIError('There was an error with the key press handler.'); + } finally { + this._isHandlingKeyPress = false; + return; + } + }; + + /** Start intercepting all key strokes and passing them to the input `onPress` method. */ + startInterceptingKeyStrokes() { + if (this._isInterceptingKeyStrokes) { + return; + } + this._isInterceptingKeyStrokes = true; + const {stdin} = process; + // $FlowFixMe[prop-missing] + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + stdin.on('data', this._handleKeypress); + } + + /** Stop intercepting all key strokes. */ + stopInterceptingKeyStrokes() { + if (!this._isInterceptingKeyStrokes) { + return; + } + this._isInterceptingKeyStrokes = false; + const {stdin} = process; + stdin.removeListener('data', this._handleKeypress); + // $FlowFixMe[prop-missing] + stdin.setRawMode(false); + stdin.resume(); + } +} diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index fb278955657175..65a327bcbf1d7b 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -10,7 +10,7 @@ */ import type {Config} from '@react-native-community/cli-types'; -import type {ConfigT, InputConfigT} from 'metro-config'; +import type {ConfigT, InputConfigT, YargArguments} from 'metro-config'; import path from 'path'; import {loadConfig, mergeConfig, resolveConfig} from 'metro-config'; @@ -69,17 +69,6 @@ function getOverrideConfig(ctx: ConfigLoadingContext): InputConfigT { }; } -export type ConfigOptionsT = $ReadOnly<{ - maxWorkers?: number, - port?: number, - projectRoot?: string, - resetCache?: boolean, - watchFolders?: string[], - sourceExts?: string[], - reporter?: any, - config?: string, -}>; - /** * Load Metro config. * @@ -88,17 +77,15 @@ export type ConfigOptionsT = $ReadOnly<{ */ export default async function loadMetroConfig( ctx: ConfigLoadingContext, - options: ConfigOptionsT = {}, + options: YargArguments = {}, ): Promise { const overrideConfig = getOverrideConfig(ctx); - if (options.reporter) { - overrideConfig.reporter = options.reporter; - } - const projectConfig = await resolveConfig(undefined, ctx.root); + const cwd = ctx.root; + const projectConfig = await resolveConfig(options.config, cwd); if (projectConfig.isEmpty) { - throw new CLIError(`No metro config found in ${ctx.root}`); + throw new CLIError(`No Metro config found in ${cwd}`); } logger.debug(`Reading Metro config from ${projectConfig.filepath}`); @@ -119,7 +106,10 @@ This warning will be removed in future (https://github.com/facebook/metro/issues } return mergeConfig( - await loadConfig({cwd: ctx.root, ...options}), + await loadConfig({ + cwd, + ...options, + }), overrideConfig, ); }