Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move metro-inspector-proxy into dev-middleware #39045

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
16 changes: 16 additions & 0 deletions flow-typed/npm/debug_v2.x.x.js
Original file line number Diff line number Diff line change
@@ -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;
}
77 changes: 74 additions & 3 deletions packages/dev-middleware/README.md
Original file line number Diff line number Diff line change
@@ -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

<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.
1 change: 1 addition & 0 deletions packages/dev-middleware/package.json
Original file line number Diff line number Diff line change
@@ -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"
},
34 changes: 26 additions & 8 deletions packages/dev-middleware/src/createDevMiddleware.js
Original file line number Diff line number Diff line change
@@ -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}`,
),
};
}
2 changes: 1 addition & 1 deletion packages/dev-middleware/src/index.flow.js
Original file line number Diff line number Diff line change
@@ -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
*/
648 changes: 648 additions & 0 deletions packages/dev-middleware/src/inspector-proxy/Device.js

Large diffs are not rendered by default.

230 changes: 230 additions & 0 deletions packages/dev-middleware/src/inspector-proxy/InspectorProxy.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
135 changes: 135 additions & 0 deletions packages/dev-middleware/src/inspector-proxy/types.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion packages/dev-middleware/src/utils/getDevServerUrl.js
Original file line number Diff line number Diff line change
@@ -28,5 +28,5 @@ export default function getDevServerUrl(req: IncomingMessage): string {
? `[${localAddress}]`
: localAddress;

return `${scheme}:${address}:${localPort}`;
return `${scheme}://${address}:${localPort}`;
}