mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Move metro-inspector-proxy into dev-middleware (#39045)
Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/39045 ## Context RFC: Decoupling Flipper from React Native core: https://github.com/react-native-community/discussions-and-proposals/pull/641 ## Changes - Relocates `metro-inspector-proxy` source from the Metro repo into the React Native repo as part of the `react-native/dev-middleware` package. - Drops the `runInspectorProxy` entry point. - Attaches the Inspector Proxy to the `createDevMiddleware()` API as the new integration point for this functionality. - Documents migrated endpoints + usage of `createDevMiddleware()` in README. Changelog: [Internal] Metro changelog: None (`metro-inspector-proxy` is now an internal component of `react-native`, covered in the [release notes for 0.78.1](https://github.com/facebook/metro/releases/tag/v0.78.1)) Reviewed By: motiz88, blakef Differential Revision: D48066213 fbshipit-source-id: 3fbef5d881f6f451cb5955dcbbc362c53347437e
This commit is contained in:
committed by
Facebook GitHub Bot
parent
2ec920e65f
commit
c82cf64a22
@@ -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,
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
// https://github.com/visionmedia/debug
|
||||
// https://www.npmjs.com/package/debug
|
||||
|
||||
declare module 'debug' {
|
||||
declare module.exports: (namespace: string) => (...Array<mixed>) => void;
|
||||
}
|
||||
@@ -1,11 +1,82 @@
|
||||
# @react-native/dev-middleware
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||
<small>`DevMiddlewareAPI.middleware`</small>
|
||||
|
||||
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).
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
|
||||
curl -X POST 'http://localhost:8081/open-debugger?appId=com.meta.RNTester'
|
||||
</details>
|
||||
|
||||
### WebSocket endpoints
|
||||
|
||||
<small>`DevMiddlewareAPI.websocketEndpoints`</small>
|
||||
|
||||
#### `/inspector/device`
|
||||
|
||||
WebSocket handler for registering device connections.
|
||||
|
||||
#### `/inspector/debug`
|
||||
|
||||
WebSocket handler that proxies CDP messages to/from the corresponding device.
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"dependencies": {
|
||||
"chrome-launcher": "^0.15.2",
|
||||
"connect": "^3.6.5",
|
||||
"debug": "^2.2.0",
|
||||
"node-fetch": "^2.2.0",
|
||||
"temp-dir": "^2.0.0"
|
||||
},
|
||||
|
||||
@@ -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},
|
||||
}>;
|
||||
|
||||
return {middleware};
|
||||
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,
|
||||
websocketEndpoints: inspectorProxy.createWebSocketListeners(
|
||||
`${host}:${port}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<string> = ['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<Page>;
|
||||
|
||||
// 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<string, string> = 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<Page> {
|
||||
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<string> {
|
||||
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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, Device>;
|
||||
|
||||
// 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<PageDescription> = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Page>,
|
||||
...
|
||||
};
|
||||
|
||||
// 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<PageDescription>;
|
||||
|
||||
// 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;
|
||||
@@ -28,5 +28,5 @@ export default function getDevServerUrl(req: IncomingMessage): string {
|
||||
? `[${localAddress}]`
|
||||
: localAddress;
|
||||
|
||||
return `${scheme}:${address}:${localPort}`;
|
||||
return `${scheme}://${address}:${localPort}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user