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..3838495207012f
--- /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}`;
}