From aa55d765e5b0c3d6bb2a16abee7d2ee57aa5474b Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Tue, 1 Oct 2024 11:27:41 -0700 Subject: [PATCH] Add CLI selection of multiple debug targets (#46627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/46627 IMPORTANT: Requires a Metro bump, CI will fail until updated. Introduces a target selection API for launching React Native DevTools when more than one device is connected. Credit to robhogan for the initial internal implementation of `OpenDebuggerKeyboardHandler`! (This leverages recent additions to Metro's reporter API — which we should follow up on to use for the rest of `community-cli-plugin`. Notably, using `TerminalReporter` ensures server output won't conflict with Metro's own event and progress logs.) Changelog: [Internal] Reviewed By: hoxyq Differential Revision: D63255295 fbshipit-source-id: da93500358791eabe4cab433cad31b82d518fb5f --- .../start/OpenDebuggerKeyboardHandler.js | 174 ++++++++++++++++++ .../src/commands/start/attachKeyHandlers.js | 18 +- .../src/commands/start/runServer.js | 1 + 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 packages/community-cli-plugin/src/commands/start/OpenDebuggerKeyboardHandler.js diff --git a/packages/community-cli-plugin/src/commands/start/OpenDebuggerKeyboardHandler.js b/packages/community-cli-plugin/src/commands/start/OpenDebuggerKeyboardHandler.js new file mode 100644 index 00000000000000..a52f843631aa45 --- /dev/null +++ b/packages/community-cli-plugin/src/commands/start/OpenDebuggerKeyboardHandler.js @@ -0,0 +1,174 @@ +/** + * 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 strict-local + * @format + * @oncall react_native + */ + +import type TerminalReporter from 'metro/src/lib/TerminalReporter'; + +import chalk from 'chalk'; +import fetch from 'node-fetch'; + +type PageDescription = $ReadOnly<{ + id: string, + title: string, + description: string, + deviceName: string, + ... +}>; + +export default class OpenDebuggerKeyboardHandler { + #devServerUrl: string; + #reporter: TerminalReporter; + #targetsShownForSelection: ?$ReadOnlyArray = null; + + constructor({ + devServerUrl, + reporter, + }: { + devServerUrl: string, + reporter: TerminalReporter, + }) { + this.#devServerUrl = devServerUrl; + this.#reporter = reporter; + } + + async #tryOpenDebuggerForTarget(target: PageDescription): Promise { + this.#targetsShownForSelection = null; + this.#clearTerminalMenu(); + + try { + await fetch( + new URL( + '/open-debugger?target=' + encodeURIComponent(target.id), + this.#devServerUrl, + ).href, + {method: 'POST'}, + ); + } catch (e) { + this.#log( + 'error', + 'Failed to open debugger for %s on %s debug targets: %s', + target.description, + target.deviceName, + e.message, + ); + this.#clearTerminalMenu(); + } + } + + /** + * Used in response to 'j' to debug - fetch the available debug targets and: + * - If no targets, warn + * - If one target, open it + * - If more, show a list. The keyboard listener should run subsequent key + * presses through maybeHandleTargetSelection, which will launch the + * debugger if a match is made. + */ + async handleOpenDebugger(): Promise { + this.#setTerminalMenu('Fetching available debugging targets...'); + this.#targetsShownForSelection = null; + + try { + const res = await fetch(this.#devServerUrl + '/json/list', { + method: 'POST', + }); + + if (res.status !== 200) { + throw new Error(`Unexpected status code: ${res.status}`); + } + const targets = (await res.json()) as $ReadOnlyArray; + if (!Array.isArray(targets)) { + throw new Error('Expected array.'); + } + + if (targets.length === 0) { + this.#log('warn', 'No connected targets'); + this.#clearTerminalMenu(); + } else if (targets.length === 1) { + const target = targets[0]; + // eslint-disable-next-line no-void + void this.#tryOpenDebuggerForTarget(target); + } else { + this.#targetsShownForSelection = targets; + + if (targets.length > 9) { + this.#log( + 'warn', + '10 or more debug targets available, showing the first 9.', + ); + } + + this.#setTerminalMenu( + `Multiple debug targets available, please select:\n ${targets + .slice(0, 9) + .map( + ({description, deviceName}, i) => + `${chalk.white.inverse(` ${i + 1} `)} - "${description}" on "${deviceName}"`, + ) + .join('\n ')}`, + ); + } + } catch (e) { + this.#log('error', `Failed to fetch debug targets: ${e.message}`); + this.#clearTerminalMenu(); + } + } + + /** + * Handle key presses that correspond to a valid selection from a visible + * selection list. + * + * @return true if we've handled the key as a target selection, false if the + * caller should handle the key. + */ + maybeHandleTargetSelection(keyName: string): boolean { + if (keyName >= '1' && keyName <= '9') { + const targetIndex = Number(keyName) - 1; + if ( + this.#targetsShownForSelection != null && + targetIndex < this.#targetsShownForSelection.length + ) { + const target = this.#targetsShownForSelection[targetIndex]; + // eslint-disable-next-line no-void + void this.#tryOpenDebuggerForTarget(target); + return true; + } + } + return false; + } + + /** + * Dismiss any target selection UI, if shown. + */ + dismiss() { + this.#clearTerminalMenu(); + this.#targetsShownForSelection = null; + } + + #log(level: 'info' | 'warn' | 'error', ...data: Array): void { + this.#reporter.update({ + type: 'unstable_server_log', + level, + data, + }); + } + + #setTerminalMenu(message: string) { + this.#reporter.update({ + type: 'unstable_server_menu_updated', + message, + }); + } + + #clearTerminalMenu() { + this.#reporter.update({ + type: 'unstable_server_menu_cleared', + }); + } +} diff --git a/packages/community-cli-plugin/src/commands/start/attachKeyHandlers.js b/packages/community-cli-plugin/src/commands/start/attachKeyHandlers.js index 82af11a671207a..7b5398d80397a9 100644 --- a/packages/community-cli-plugin/src/commands/start/attachKeyHandlers.js +++ b/packages/community-cli-plugin/src/commands/start/attachKeyHandlers.js @@ -10,12 +10,13 @@ */ import type {Config} from '@react-native-community/cli-types'; +import type TerminalReporter from 'metro/src/lib/TerminalReporter'; import {KeyPressHandler} from '../../utils/KeyPressHandler'; import {logger} from '../../utils/logger'; +import OpenDebuggerKeyboardHandler from './OpenDebuggerKeyboardHandler'; import chalk from 'chalk'; import execa from 'execa'; -import fetch from 'node-fetch'; const CTRL_C = '\u0003'; const CTRL_D = '\u0004'; @@ -36,6 +37,7 @@ export default function attachKeyHandlers({ cliConfig, devServerUrl, messageSocket, + reporter, }: { cliConfig: Config, devServerUrl: string, @@ -43,6 +45,7 @@ export default function attachKeyHandlers({ broadcast: (type: string, params?: Record | null) => void, ... }>, + reporter: TerminalReporter, }) { if (process.stdin.isTTY !== true) { logger.debug('Interactive mode is not supported in this environment'); @@ -58,7 +61,16 @@ export default function attachKeyHandlers({ messageSocket.broadcast('reload', null); }, RELOAD_TIMEOUT); + const openDebuggerKeyboardHandler = new OpenDebuggerKeyboardHandler({ + reporter, + devServerUrl, + }); + const onPress = async (key: string) => { + if (openDebuggerKeyboardHandler.maybeHandleTargetSelection(key)) { + return; + } + switch (key.toLowerCase()) { case 'r': reload(); @@ -92,11 +104,11 @@ export default function attachKeyHandlers({ ).stdout?.pipe(process.stdout); break; case 'j': - // TODO(T192878199): Add multi-target selection - await fetch(devServerUrl + '/open-debugger', {method: 'POST'}); + await openDebuggerKeyboardHandler.handleOpenDebugger(); break; case CTRL_C: case CTRL_D: + openDebuggerKeyboardHandler.dismiss(); logger.info('Stopping server'); keyPressHandler.stopInterceptingKeyStrokes(); process.emit('SIGINT'); diff --git a/packages/community-cli-plugin/src/commands/start/runServer.js b/packages/community-cli-plugin/src/commands/start/runServer.js index 407800a72eadaa..60829c6adfbec5 100644 --- a/packages/community-cli-plugin/src/commands/start/runServer.js +++ b/packages/community-cli-plugin/src/commands/start/runServer.js @@ -130,6 +130,7 @@ async function runServer( cliConfig: ctx, devServerUrl, messageSocket: messageSocketEndpoint, + reporter: terminalReporter, }); } },