diff --git a/flow-typed/npm/@react-native-community/cli-server-api_v12.x.x.js b/flow-typed/npm/@react-native-community/cli-server-api_v12.x.x.js index 94200ed41cb642..bdaa66e863e903 100644 --- a/flow-typed/npm/@react-native-community/cli-server-api_v12.x.x.js +++ b/flow-typed/npm/@react-native-community/cli-server-api_v12.x.x.js @@ -23,9 +23,7 @@ declare module '@react-native-community/cli-server-api' { ): { middleware: Server, websocketEndpoints: { - '/debugger-proxy': ws$WebSocketServer, - '/message': ws$WebSocketServer, - '/events': ws$WebSocketServer, + [path: string]: ws$WebSocketServer, }, debuggerProxyEndpoint: { server: ws$WebSocketServer, diff --git a/packages/dev-middleware/README.md b/packages/dev-middleware/README.md index 0f4ee2b43241df..68cf4324ca1b07 100644 --- a/packages/dev-middleware/README.md +++ b/packages/dev-middleware/README.md @@ -1,11 +1,82 @@ # @react-native/dev-middleware -![https://img.shields.io/npm/v/@react-native/dev-middleware?color=brightgreen&label=npm%20package](https://www.npmjs.com/package/@react-native/dev-middleware) +![npm package](https://img.shields.io/npm/v/@react-native/dev-middleware?color=brightgreen&label=npm%20package) Dev server middleware supporting core React Native development features. This package is preconfigured in all React Native projects. -## Endpoints +## Usage -### `/open-debugger` +Middleware can be attached to a dev server (e.g. [Metro](https://facebook.github.io/metro/docs/getting-started)) using the `createDevMiddleware` API. + +```js +import { createDevMiddleware } from '@react-native/dev-middleware'; + +function myDevServerImpl(args) { + ... + + const {middleware, websocketEndpoints} = createDevMiddleware({ + host: args.host, + port: metroConfig.server.port, + projectRoot: metroConfig.projectRoot, + logger, + }); + + await Metro.runServer(metroConfig, { + host: args.host, + ..., + unstable_extraMiddleware: [ + middleware, + // Optionally extend with additional HTTP middleware + ], + websocketEndpoints: { + ...websocketEndpoints, + // Optionally extend with additional WebSocket endpoints + }, + }); +} +``` + +## Included middleware + +`@react-native/dev-middleware` is designed for integrators such as [`@expo/dev-server`](https://www.npmjs.com/package/@expo/dev-server) and [`@react-native/community-cli-plugin`](https://github.com/facebook/react-native/tree/main/packages/community-cli-plugin). It provides a common default implementation for core React Native dev server responsibilities. + +We intend to keep this to a narrow set of functionality, based around: + +- **Debugging** — The [Chrome DevTools protocol (CDP)](https://chromedevtools.github.io/devtools-protocol/) endpoints supported by React Native, including the Inspector Proxy, which facilitates connections with multiple devices. +- **Dev actions** — Endpoints implementing core [Dev Menu](https://reactnative.dev/docs/debugging#accessing-the-dev-menu) actions, e.g. reloading the app, opening the debugger frontend. + +### HTTP endpoints + +`DevMiddlewareAPI.middleware` + +These are exposed as a [`connect`](https://www.npmjs.com/package/connect) middleware handler, assignable to `Metro.runServer` or other compatible HTTP servers. + +#### GET `/json/list`, `/json` ([CDP](https://chromedevtools.github.io/devtools-protocol/#endpoints)) + +Returns the list of available WebSocket targets for all connected React Native app sessions. + +#### GET `/json/version` ([CDP](https://chromedevtools.github.io/devtools-protocol/#endpoints)) + +Returns version metadata used by Chrome DevTools. + +#### POST `/open-debugger` Open the JavaScript debugger for a given CDP target (direct Hermes debugging). + +
+Example + + curl -X POST 'http://localhost:8081/open-debugger?appId=com.meta.RNTester' +
+ +### WebSocket endpoints + +`DevMiddlewareAPI.websocketEndpoints` + +#### `/inspector/device` + +WebSocket handler for registering device connections. + +#### `/inspector/debug` + +WebSocket handler that proxies CDP messages to/from the corresponding device. diff --git a/packages/dev-middleware/src/createDevMiddleware.js b/packages/dev-middleware/src/createDevMiddleware.js index 982f87bc54faa5..ce93856a5188c1 100644 --- a/packages/dev-middleware/src/createDevMiddleware.js +++ b/packages/dev-middleware/src/createDevMiddleware.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * @flow strict-local * @format * @oncall react_native */ @@ -14,18 +14,36 @@ import type {Logger} from './types/Logger'; import connect from 'connect'; import openDebuggerMiddleware from './middleware/openDebuggerMiddleware'; +import InspectorProxy from './inspector-proxy/InspectorProxy'; type Options = $ReadOnly<{ + host: string, + port: number, + projectRoot: string, logger?: Logger, }>; -export default function createDevMiddleware({logger}: Options = {}): { +type DevMiddlewareAPI = $ReadOnly<{ middleware: NextHandleFunction, -} { - const middleware = connect().use( - '/open-debugger', - openDebuggerMiddleware({logger}), - ); + websocketEndpoints: {[path: string]: ws$WebSocketServer}, +}>; + +export default function createDevMiddleware({ + host, + port, + projectRoot, + logger, +}: Options): DevMiddlewareAPI { + const inspectorProxy = new InspectorProxy(projectRoot); + + const middleware = connect() + .use('/open-debugger', openDebuggerMiddleware({logger})) + .use((...args) => inspectorProxy.processRequest(...args)); - return {middleware}; + return { + middleware, + websocketEndpoints: inspectorProxy.createWebSocketListeners( + `${host}:${port}`, + ), + }; } diff --git a/packages/dev-middleware/src/index.flow.js b/packages/dev-middleware/src/index.flow.js index 26f17c2499d50d..4d7b092a1431ec 100644 --- a/packages/dev-middleware/src/index.flow.js +++ b/packages/dev-middleware/src/index.flow.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * @flow strict-local * @format * @oncall react_native */ diff --git a/packages/dev-middleware/src/inspector-proxy/Device.js b/packages/dev-middleware/src/inspector-proxy/Device.js new file mode 100644 index 00000000000000..22862fa682fa5f --- /dev/null +++ b/packages/dev-middleware/src/inspector-proxy/Device.js @@ -0,0 +1,648 @@ +/** + * 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 { + DebuggerRequest, + ErrorResponse, + GetScriptSourceRequest, + GetScriptSourceResponse, + MessageFromDevice, + MessageToDevice, + Page, + SetBreakpointByUrlRequest, +} from './types'; + +import * as fs from 'fs'; +import * as path from 'path'; +import fetch from 'node-fetch'; +import WS from 'ws'; + +const debug = require('debug')('Metro:InspectorProxy'); + +const PAGES_POLLING_INTERVAL = 1000; + +// Android's stock emulator and other emulators such as genymotion use a standard localhost alias. +const EMULATOR_LOCALHOST_ADDRESSES: Array = ['10.0.2.2', '10.0.3.2']; + +// Prefix for script URLs that are alphanumeric IDs. See comment in _processMessageFromDevice method for +// more details. +const FILE_PREFIX = 'file://'; + +type DebuggerInfo = { + // Debugger web socket connection + socket: WS, + // If we replaced address (like '10.0.2.2') to localhost we need to store original + // address because Chrome uses URL or urlRegex params (instead of scriptId) to set breakpoints. + originalSourceURLAddress?: string, + prependedFilePrefix: boolean, + pageId: string, + ... +}; + +const REACT_NATIVE_RELOADABLE_PAGE_ID = '-1'; + +/** + * Device class represents single device connection to Inspector Proxy. Each device + * can have multiple inspectable pages. + */ +export default class Device { + // ID of the device. + _id: string; + + // Name of the device. + _name: string; + + // Package name of the app. + _app: string; + + // Stores socket connection between Inspector Proxy and device. + _deviceSocket: WS; + + // Stores last list of device's pages. + _pages: Array; + + // Stores information about currently connected debugger (if any). + _debuggerConnection: ?DebuggerInfo = null; + + // Last known Page ID of the React Native page. + // This is used by debugger connections that don't have PageID specified + // (and will interact with the latest React Native page). + _lastConnectedReactNativePage: ?Page = null; + + // Whether we are in the middle of a reload in the REACT_NATIVE_RELOADABLE_PAGE. + _isReloading: boolean = false; + + // The previous "GetPages" message, for deduplication in debug logs. + _lastGetPagesMessage: string = ''; + + // Mapping built from scriptParsed events and used to fetch file content in `Debugger.getScriptSource`. + _scriptIdToSourcePathMapping: Map = new Map(); + + // Root of the project used for relative to absolute source path conversion. + _projectRoot: string; + + constructor( + id: string, + name: string, + app: string, + socket: WS, + projectRoot: string, + ) { + this._id = id; + this._name = name; + this._app = app; + this._pages = []; + this._deviceSocket = socket; + this._projectRoot = projectRoot; + + // $FlowFixMe[incompatible-call] + this._deviceSocket.on('message', (message: string) => { + const parsedMessage = JSON.parse(message); + if (parsedMessage.event === 'getPages') { + // There's a 'getPages' message every second, so only show them if they change + if (message !== this._lastGetPagesMessage) { + debug( + '(Debugger) (Proxy) <- (Device), getPages ping has changed: ' + + message, + ); + this._lastGetPagesMessage = message; + } + } else { + debug('(Debugger) (Proxy) <- (Device): ' + message); + } + this._handleMessageFromDevice(parsedMessage); + }); + this._deviceSocket.on('close', () => { + // Device disconnected - close debugger connection. + if (this._debuggerConnection) { + this._debuggerConnection.socket.close(); + this._debuggerConnection = null; + } + }); + + this._setPagesPolling(); + } + + getName(): string { + return this._name; + } + + getApp(): string { + return this._app; + } + + getPagesList(): Array { + if (this._lastConnectedReactNativePage) { + const reactNativeReloadablePage = { + id: REACT_NATIVE_RELOADABLE_PAGE_ID, + title: 'React Native Experimental (Improved Chrome Reloads)', + vm: "don't use", + app: this._app, + }; + return this._pages.concat(reactNativeReloadablePage); + } else { + return this._pages; + } + } + + // Handles new debugger connection to this device: + // 1. Sends connect event to device + // 2. Forwards all messages from the debugger to device as wrappedEvent + // 3. Sends disconnect event to device when debugger connection socket closes. + handleDebuggerConnection(socket: WS, pageId: string) { + // Disconnect current debugger if we already have debugger connected. + if (this._debuggerConnection) { + this._debuggerConnection.socket.close(); + this._debuggerConnection = null; + } + + const debuggerInfo = { + socket, + prependedFilePrefix: false, + pageId, + }; + this._debuggerConnection = debuggerInfo; + + debug(`Got new debugger connection for page ${pageId} of ${this._name}`); + + this._sendMessageToDevice({ + event: 'connect', + payload: { + pageId: this._mapToDevicePageId(pageId), + }, + }); + + // $FlowFixMe[incompatible-call] + socket.on('message', (message: string) => { + debug('(Debugger) -> (Proxy) (Device): ' + message); + const debuggerRequest = JSON.parse(message); + const handled = this._interceptMessageFromDebugger( + debuggerRequest, + debuggerInfo, + socket, + ); + + if (!handled) { + this._sendMessageToDevice({ + event: 'wrappedEvent', + payload: { + pageId: this._mapToDevicePageId(pageId), + wrappedEvent: JSON.stringify(debuggerRequest), + }, + }); + } + }); + socket.on('close', () => { + debug(`Debugger for page ${pageId} and ${this._name} disconnected.`); + this._sendMessageToDevice({ + event: 'disconnect', + payload: { + pageId: this._mapToDevicePageId(pageId), + }, + }); + this._debuggerConnection = null; + }); + + // $FlowFixMe[method-unbinding] + const sendFunc = socket.send; + // $FlowFixMe[cannot-write] + socket.send = function (message: string) { + debug('(Debugger) <- (Proxy) (Device): ' + message); + return sendFunc.call(socket, message); + }; + } + + /** + * Handles cleaning up a duplicate device connection, by client-side device ID. + * 1. Checks if the same device is attempting to reconnect for the same app. + * 2. If not, close both the device and debugger socket. + * 3. If the debugger connection can be reused, close the device socket only. + * + * This allows users to reload the app, either as result of a crash, or manually + * reloading, without having to restart the debugger. + */ + handleDuplicateDeviceConnection(newDevice: Device) { + if ( + this._app !== newDevice.getApp() || + this._name !== newDevice.getName() + ) { + this._deviceSocket.close(); + this._debuggerConnection?.socket.close(); + } + + const oldDebugger = this._debuggerConnection; + this._debuggerConnection = null; + + if (oldDebugger) { + oldDebugger.socket.removeAllListeners(); + this._deviceSocket.close(); + newDevice.handleDebuggerConnection( + oldDebugger.socket, + oldDebugger.pageId, + ); + } + } + + // Handles messages received from device: + // 1. For getPages responses updates local _pages list. + // 2. All other messages are forwarded to debugger as wrappedEvent. + // + // In the future more logic will be added to this method for modifying + // some of the messages (like updating messages with source maps and file + // locations). + _handleMessageFromDevice(message: MessageFromDevice) { + if (message.event === 'getPages') { + this._pages = message.payload; + + // Check if device have new React Native page. + // There is usually no more than 2-3 pages per device so this operation + // is not expensive. + // TODO(hypuk): It is better for VM to send update event when new page is + // created instead of manually checking this on every getPages result. + for (let i = 0; i < this._pages.length; ++i) { + if (this._pages[i].title.indexOf('React') >= 0) { + if (this._pages[i].id != this._lastConnectedReactNativePage?.id) { + this._newReactNativePage(this._pages[i]); + break; + } + } + } + } else if (message.event === 'disconnect') { + // Device sends disconnect events only when page is reloaded or + // if debugger socket was disconnected. + const pageId = message.payload.pageId; + const debuggerSocket = this._debuggerConnection + ? this._debuggerConnection.socket + : null; + if (debuggerSocket && debuggerSocket.readyState === WS.OPEN) { + if ( + this._debuggerConnection != null && + this._debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID + ) { + debug(`Page ${pageId} is reloading.`); + debuggerSocket.send(JSON.stringify({method: 'reload'})); + } + } + } else if (message.event === 'wrappedEvent') { + if (this._debuggerConnection == null) { + return; + } + + // FIXME: Is it possible that we received message for pageID that does not + // correspond to current debugger connection? + + const debuggerSocket = this._debuggerConnection.socket; + if (debuggerSocket == null || debuggerSocket.readyState !== WS.OPEN) { + // TODO(hypuk): Send error back to device? + return; + } + + const parsedPayload = JSON.parse(message.payload.wrappedEvent); + + if (this._debuggerConnection) { + // Wrapping just to make flow happy :) + // $FlowFixMe[unused-promise] + this._processMessageFromDevice( + parsedPayload, + this._debuggerConnection, + ).then(() => { + const messageToSend = JSON.stringify(parsedPayload); + debuggerSocket.send(messageToSend); + }); + } + } + } + + // Sends single message to device. + _sendMessageToDevice(message: MessageToDevice) { + try { + if (message.event !== 'getPages') { + debug('(Debugger) (Proxy) -> (Device): ' + JSON.stringify(message)); + } + this._deviceSocket.send(JSON.stringify(message)); + } catch (error) {} + } + + // Sends 'getPages' request to device every PAGES_POLLING_INTERVAL milliseconds. + _setPagesPolling() { + setInterval( + () => this._sendMessageToDevice({event: 'getPages'}), + PAGES_POLLING_INTERVAL, + ); + } + + // We received new React Native Page ID. + _newReactNativePage(page: Page) { + debug(`React Native page updated to ${page.id}`); + if ( + this._debuggerConnection == null || + this._debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID + ) { + // We can just remember new page ID without any further actions if no + // debugger is currently attached or attached debugger is not + // "Reloadable React Native" connection. + this._lastConnectedReactNativePage = page; + return; + } + const oldPageId = this._lastConnectedReactNativePage?.id; + this._lastConnectedReactNativePage = page; + this._isReloading = true; + + // We already had a debugger connected to React Native page and a + // new one appeared - in this case we need to emulate execution context + // detroy and resend Debugger.enable and Runtime.enable commands to new + // page. + + if (oldPageId != null) { + this._sendMessageToDevice({ + event: 'disconnect', + payload: { + pageId: oldPageId, + }, + }); + } + + this._sendMessageToDevice({ + event: 'connect', + payload: { + pageId: page.id, + }, + }); + + const toSend = [ + {method: 'Runtime.enable', id: 1e9}, + {method: 'Debugger.enable', id: 1e9}, + ]; + + for (const message of toSend) { + this._sendMessageToDevice({ + event: 'wrappedEvent', + payload: { + pageId: this._mapToDevicePageId(page.id), + wrappedEvent: JSON.stringify(message), + }, + }); + } + } + + // Allows to make changes in incoming message from device. + async _processMessageFromDevice( + payload: {method: string, params: {sourceMapURL: string, url: string}}, + debuggerInfo: DebuggerInfo, + ) { + // Replace Android addresses for scriptParsed event. + if (payload.method === 'Debugger.scriptParsed') { + const params = payload.params || {}; + if ('sourceMapURL' in params) { + for (let i = 0; i < EMULATOR_LOCALHOST_ADDRESSES.length; ++i) { + const address = EMULATOR_LOCALHOST_ADDRESSES[i]; + if (params.sourceMapURL.indexOf(address) >= 0) { + payload.params.sourceMapURL = params.sourceMapURL.replace( + address, + 'localhost', + ); + debuggerInfo.originalSourceURLAddress = address; + } + } + + const sourceMapURL = this._tryParseHTTPURL(params.sourceMapURL); + if (sourceMapURL) { + // Some debug clients do not support fetching HTTP URLs. If the + // message headed to the debug client identifies the source map with + // an HTTP URL, fetch the content here and convert the content to a + // Data URL (which is more widely supported) before passing the + // message to the debug client. + try { + const sourceMap = await this._fetchText(sourceMapURL); + payload.params.sourceMapURL = + 'data:application/json;charset=utf-8;base64,' + + new Buffer(sourceMap).toString('base64'); + } catch (exception) { + this._sendErrorToDebugger( + `Failed to fetch source map ${params.sourceMapURL}: ${exception.message}`, + ); + } + } + } + if ('url' in params) { + for (let i = 0; i < EMULATOR_LOCALHOST_ADDRESSES.length; ++i) { + const address = EMULATOR_LOCALHOST_ADDRESSES[i]; + if (params.url.indexOf(address) >= 0) { + payload.params.url = params.url.replace(address, 'localhost'); + debuggerInfo.originalSourceURLAddress = address; + } + } + + // Chrome doesn't download source maps if URL param is not a valid + // URL. Some frameworks pass alphanumeric script ID instead of URL which causes + // Chrome to not download source maps. In this case we want to prepend script ID + // with 'file://' prefix. + if (payload.params.url.match(/^[0-9a-z]+$/)) { + payload.params.url = FILE_PREFIX + payload.params.url; + debuggerInfo.prependedFilePrefix = true; + } + + // $FlowFixMe[prop-missing] + if (params.scriptId != null) { + this._scriptIdToSourcePathMapping.set(params.scriptId, params.url); + } + } + } + + if ( + payload.method === 'Runtime.executionContextCreated' && + this._isReloading + ) { + // The new context is ready. First notify Chrome that we've reloaded so + // it'll resend its breakpoints. If we do this earlier, we may not be + // ready to receive them. + debuggerInfo.socket.send( + JSON.stringify({method: 'Runtime.executionContextsCleared'}), + ); + + // The VM starts in a paused mode. Ask it to resume. + // Note that if setting breakpoints in early initialization functions, + // there's a currently race condition between these functions executing + // and Chrome re-applying the breakpoints due to the message above. + // + // This is not an issue in VSCode/Nuclide where the IDE knows to resume + // at its convenience. + this._sendMessageToDevice({ + event: 'wrappedEvent', + payload: { + pageId: this._mapToDevicePageId(debuggerInfo.pageId), + wrappedEvent: JSON.stringify({method: 'Debugger.resume', id: 0}), + }, + }); + + this._isReloading = false; + } + } + + // Allows to make changes in incoming messages from debugger. Returns a boolean + // indicating whether the message has been handled locally (i.e. does not need + // to be forwarded to the target). + _interceptMessageFromDebugger( + req: DebuggerRequest, + debuggerInfo: DebuggerInfo, + socket: WS, + ): boolean { + if (req.method === 'Debugger.setBreakpointByUrl') { + this._processDebuggerSetBreakpointByUrl(req, debuggerInfo); + } else if (req.method === 'Debugger.getScriptSource') { + this._processDebuggerGetScriptSource(req, socket); + return true; + } + return false; + } + + _processDebuggerSetBreakpointByUrl( + req: SetBreakpointByUrlRequest, + debuggerInfo: DebuggerInfo, + ) { + // If we replaced Android emulator's address to localhost we need to change it back. + if (debuggerInfo.originalSourceURLAddress != null) { + if (req.params.url != null) { + req.params.url = req.params.url.replace( + 'localhost', + debuggerInfo.originalSourceURLAddress, + ); + + if ( + req.params.url && + req.params.url.startsWith(FILE_PREFIX) && + debuggerInfo.prependedFilePrefix + ) { + // Remove fake URL prefix if we modified URL in _processMessageFromDevice. + // $FlowFixMe[incompatible-use] + req.params.url = req.params.url.slice(FILE_PREFIX.length); + } + } + if (req.params.urlRegex != null) { + req.params.urlRegex = req.params.urlRegex.replace( + /localhost/g, + // $FlowFixMe[incompatible-call] + debuggerInfo.originalSourceURLAddress, + ); + } + } + } + + _processDebuggerGetScriptSource(req: GetScriptSourceRequest, socket: WS) { + const sendSuccessResponse = (scriptSource: string) => { + const result: GetScriptSourceResponse = {scriptSource}; + socket.send(JSON.stringify({id: req.id, result})); + }; + const sendErrorResponse = (error: string) => { + // Tell the client that the request failed + const result: ErrorResponse = {error: {message: error}}; + socket.send(JSON.stringify({id: req.id, result})); + + // Send to the console as well, so the user can see it + this._sendErrorToDebugger(error); + }; + + const pathToSource = this._scriptIdToSourcePathMapping.get( + req.params.scriptId, + ); + if (pathToSource != null) { + const httpURL = this._tryParseHTTPURL(pathToSource); + if (httpURL) { + this._fetchText(httpURL).then( + text => sendSuccessResponse(text), + err => + sendErrorResponse( + `Failed to fetch source url ${pathToSource}: ${err.message}`, + ), + ); + } else { + let file; + try { + file = fs.readFileSync( + path.resolve(this._projectRoot, pathToSource), + 'utf8', + ); + } catch (err) { + sendErrorResponse( + `Failed to fetch source file ${pathToSource}: ${err.message}`, + ); + } + if (file != null) { + sendSuccessResponse(file); + } + } + } + } + + _mapToDevicePageId(pageId: string): string { + if ( + pageId === REACT_NATIVE_RELOADABLE_PAGE_ID && + this._lastConnectedReactNativePage != null + ) { + return this._lastConnectedReactNativePage.id; + } else { + return pageId; + } + } + + _tryParseHTTPURL(url: string): ?URL { + let parsedURL: ?URL; + try { + parsedURL = new URL(url); + } catch {} + + const protocol = parsedURL?.protocol; + if (protocol !== 'http:' && protocol !== 'https:') { + parsedURL = undefined; + } + + return parsedURL; + } + + // Fetch text, raising an exception if the text could not be fetched, + // or is too large. + async _fetchText(url: URL): Promise { + if (url.hostname !== 'localhost') { + throw new Error('remote fetches not permitted'); + } + + // $FlowFixMe[incompatible-call] Suppress arvr node-fetch flow error + const response = await fetch(url); + const text = await response.text(); + // Restrict the length to well below the 500MB limit for nodejs (leaving + // room some some later manipulation, e.g. base64 or wrapping in JSON) + if (text.length > 350000000) { + throw new Error('file too large to fetch via HTTP'); + } + return text; + } + + _sendErrorToDebugger(message: string) { + const debuggerSocket = this._debuggerConnection?.socket; + if (debuggerSocket && debuggerSocket.readyState === WS.OPEN) { + debuggerSocket.send( + JSON.stringify({ + method: 'Runtime.consoleAPICalled', + params: { + args: [ + { + type: 'string', + value: message, + }, + ], + executionContextId: 0, + type: 'error', + }, + }), + ); + } + } +} diff --git a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js new file mode 100644 index 00000000000000..a9b62a69c6eb7e --- /dev/null +++ b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js @@ -0,0 +1,230 @@ +/** + * 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 { + JsonPagesListResponse, + JsonVersionResponse, + Page, + PageDescription, +} from './types'; +import type {IncomingMessage, ServerResponse} from 'http'; + +import Device from './Device'; +import url from 'url'; +import WS from 'ws'; + +const debug = require('debug')('Metro:InspectorProxy'); + +const WS_DEVICE_URL = '/inspector/device'; +const WS_DEBUGGER_URL = '/inspector/debug'; +const PAGES_LIST_JSON_URL = '/json'; +const PAGES_LIST_JSON_URL_2 = '/json/list'; +const PAGES_LIST_JSON_VERSION_URL = '/json/version'; + +const INTERNAL_ERROR_CODE = 1011; + +/** + * Main Inspector Proxy class that connects JavaScript VM inside Android/iOS apps and JS debugger. + */ +export default class InspectorProxy { + // Root of the project used for relative to absolute source path conversion. + _projectRoot: string; + + // Maps device ID to Device instance. + _devices: Map; + + // Internal counter for device IDs -- just gets incremented for each new device. + _deviceCounter: number = 0; + + // We store server's address with port (like '127.0.0.1:8081') to be able to build URLs + // (devtoolsFrontendUrl and webSocketDebuggerUrl) for page descriptions. These URLs are used + // by debugger to know where to connect. + _serverBaseUrl: string = ''; + + constructor(projectRoot: string) { + this._projectRoot = projectRoot; + this._devices = new Map(); + } + + // Process HTTP request sent to server. We only respond to 2 HTTP requests: + // 1. /json/version returns Chrome debugger protocol version that we use + // 2. /json and /json/list returns list of page descriptions (list of inspectable apps). + // This list is combined from all the connected devices. + processRequest( + request: IncomingMessage, + response: ServerResponse, + next: (?Error) => mixed, + ) { + if ( + request.url === PAGES_LIST_JSON_URL || + request.url === PAGES_LIST_JSON_URL_2 + ) { + // Build list of pages from all devices. + let result: Array = []; + Array.from(this._devices.entries()).forEach(([deviceId, device]) => { + result = result.concat( + device + .getPagesList() + .map((page: Page) => + this._buildPageDescription(deviceId, device, page), + ), + ); + }); + + this._sendJsonResponse(response, result); + } else if (request.url === PAGES_LIST_JSON_VERSION_URL) { + this._sendJsonResponse(response, { + Browser: 'Mobile JavaScript', + 'Protocol-Version': '1.1', + }); + } else { + next(); + } + } + + createWebSocketListeners(devServerBaseUrl: string): { + [path: string]: WS.Server, + } { + this._serverBaseUrl = devServerBaseUrl; + + return { + [WS_DEVICE_URL]: this._createDeviceConnectionWSServer(), + [WS_DEBUGGER_URL]: this._createDebuggerConnectionWSServer(), + }; + } + + // Converts page information received from device into PageDescription object + // that is sent to debugger. + _buildPageDescription( + deviceId: string, + device: Device, + page: Page, + ): PageDescription { + const debuggerUrl = `${this._serverBaseUrl}${WS_DEBUGGER_URL}?device=${deviceId}&page=${page.id}`; + const webSocketDebuggerUrl = 'ws://' + debuggerUrl; + const devtoolsFrontendUrl = + 'devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=' + + encodeURIComponent(debuggerUrl); + return { + id: `${deviceId}-${page.id}`, + description: page.app, + title: page.title, + faviconUrl: 'https://reactjs.org/favicon.ico', + devtoolsFrontendUrl, + type: 'node', + webSocketDebuggerUrl, + vm: page.vm, + deviceName: device.getName(), + }; + } + + // Sends object as response to HTTP request. + // Just serializes object using JSON and sets required headers. + _sendJsonResponse( + response: ServerResponse, + object: JsonPagesListResponse | JsonVersionResponse, + ) { + const data = JSON.stringify(object, null, 2); + response.writeHead(200, { + 'Content-Type': 'application/json; charset=UTF-8', + 'Cache-Control': 'no-cache', + 'Content-Length': data.length.toString(), + Connection: 'close', + }); + response.end(data); + } + + // Adds websocket handler for device connections. + // Device connects to /inspector/device and passes device and app names as + // HTTP GET params. + // For each new websocket connection we parse device and app names and create + // new instance of Device class. + _createDeviceConnectionWSServer(): ws$WebSocketServer { + const wss = new WS.Server({ + noServer: true, + perMessageDeflate: true, + }); + // $FlowFixMe[value-as-type] + wss.on('connection', async (socket: WS, req) => { + try { + const fallbackDeviceId = String(this._deviceCounter++); + + const query = url.parse(req.url || '', true).query || {}; + const deviceId = query.device || fallbackDeviceId; + const deviceName = query.name || 'Unknown'; + const appName = query.app || 'Unknown'; + + const oldDevice = this._devices.get(deviceId); + const newDevice = new Device( + deviceId, + deviceName, + appName, + socket, + this._projectRoot, + ); + + if (oldDevice) { + oldDevice.handleDuplicateDeviceConnection(newDevice); + } + + this._devices.set(deviceId, newDevice); + + debug( + `Got new connection: name=${deviceName}, app=${appName}, device=${deviceId}`, + ); + + socket.on('close', () => { + this._devices.delete(deviceId); + debug(`Device ${deviceName} disconnected.`); + }); + } catch (e) { + console.error('error', e); + socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? 'Unknown error'); + } + }); + return wss; + } + + // Returns websocket handler for debugger connections. + // Debugger connects to webSocketDebuggerUrl that we return as part of page description + // in /json response. + // When debugger connects we try to parse device and page IDs from the query and pass + // websocket object to corresponding Device instance. + _createDebuggerConnectionWSServer(): ws$WebSocketServer { + const wss = new WS.Server({ + noServer: true, + perMessageDeflate: false, + }); + // $FlowFixMe[value-as-type] + wss.on('connection', async (socket: WS, req) => { + try { + const query = url.parse(req.url || '', true).query || {}; + const deviceId = query.device; + const pageId = query.page; + + if (deviceId == null || pageId == null) { + throw new Error('Incorrect URL - must provide device and page IDs'); + } + + const device = this._devices.get(deviceId); + if (device == null) { + throw new Error('Unknown device with ID ' + deviceId); + } + + device.handleDebuggerConnection(socket, pageId); + } catch (e) { + console.error(e); + socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? 'Unknown error'); + } + }); + return wss; + } +} diff --git a/packages/dev-middleware/src/inspector-proxy/types.js b/packages/dev-middleware/src/inspector-proxy/types.js new file mode 100644 index 00000000000000..94b21aba10e84b --- /dev/null +++ b/packages/dev-middleware/src/inspector-proxy/types.js @@ -0,0 +1,135 @@ +/** + * 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 + */ + +// Page information received from the device. New page is created for +// each new instance of VM and can appear when user reloads React Native +// application. +export type Page = { + id: string, + title: string, + vm: string, + app: string, + ... +}; + +// Chrome Debugger Protocol message/event passed between device and debugger. +export type WrappedEvent = { + event: 'wrappedEvent', + payload: { + pageId: string, + wrappedEvent: string, + ... + }, + ... +}; + +// Request sent from Inspector Proxy to Device when new debugger is connected +// to particular page. +export type ConnectRequest = { + event: 'connect', + payload: {pageId: string, ...}, + ... +}; + +// Request sent from Inspector Proxy to Device to notify that debugger is +// disconnected. +export type DisconnectRequest = { + event: 'disconnect', + payload: {pageId: string, ...}, + ... +}; + +// Request sent from Inspector Proxy to Device to get a list of pages. +export type GetPagesRequest = {event: 'getPages', ...}; + +// Response to GetPagesRequest containing a list of page infos. +export type GetPagesResponse = { + event: 'getPages', + payload: Array, + ... +}; + +// Union type for all possible messages sent from device to Inspector Proxy. +export type MessageFromDevice = + | GetPagesResponse + | WrappedEvent + | DisconnectRequest; + +// Union type for all possible messages sent from Inspector Proxy to device. +export type MessageToDevice = + | GetPagesRequest + | WrappedEvent + | ConnectRequest + | DisconnectRequest; + +// Page description object that is sent in response to /json HTTP request from debugger. +export type PageDescription = { + id: string, + description: string, + title: string, + faviconUrl: string, + devtoolsFrontendUrl: string, + type: string, + webSocketDebuggerUrl: string, + ... +}; +export type JsonPagesListResponse = Array; + +// Response to /json/version HTTP request from the debugger specifying browser type and +// Chrome protocol version. +export type JsonVersionResponse = { + Browser: string, + 'Protocol-Version': string, + ... +}; + +/** + * Types were exported from https://github.com/ChromeDevTools/devtools-protocol/blob/master/types/protocol.d.ts + */ + +export type SetBreakpointByUrlRequest = { + id: number, + method: 'Debugger.setBreakpointByUrl', + params: { + lineNumber: number, + url?: string, + urlRegex?: string, + scriptHash?: string, + columnNumber?: number, + condition?: string, + }, +}; + +export type GetScriptSourceRequest = { + id: number, + method: 'Debugger.getScriptSource', + params: { + scriptId: string, + }, +}; + +export type GetScriptSourceResponse = { + scriptSource: string, + /** + * Wasm bytecode. + */ + bytecode?: string, +}; + +export type ErrorResponse = { + error: { + message: string, + }, +}; + +export type DebuggerRequest = + | SetBreakpointByUrlRequest + | GetScriptSourceRequest; diff --git a/packages/dev-middleware/src/utils/getDevServerUrl.js b/packages/dev-middleware/src/utils/getDevServerUrl.js index 370bfe2e4ea945..3a62134da515a5 100644 --- a/packages/dev-middleware/src/utils/getDevServerUrl.js +++ b/packages/dev-middleware/src/utils/getDevServerUrl.js @@ -28,5 +28,5 @@ export default function getDevServerUrl(req: IncomingMessage): string { ? `[${localAddress}]` : localAddress; - return `${scheme}:${address}:${localPort}`; + return `${scheme}://${address}:${localPort}`; }