|
| 1 | +/** |
| 2 | + * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | + * |
| 4 | + * This source code is licensed under the MIT license found in the |
| 5 | + * LICENSE file in the root directory of this source tree. |
| 6 | + * |
| 7 | + * @flow strict-local |
| 8 | + * @format |
| 9 | + * @oncall react_native |
| 10 | + */ |
| 11 | + |
| 12 | +import type { |
| 13 | + JsonPagesListResponse, |
| 14 | + JsonVersionResponse, |
| 15 | + Page, |
| 16 | + PageDescription, |
| 17 | +} from './types'; |
| 18 | +import type {IncomingMessage, ServerResponse} from 'http'; |
| 19 | + |
| 20 | +import Device from './Device'; |
| 21 | +import url from 'url'; |
| 22 | +import WS from 'ws'; |
| 23 | + |
| 24 | +const debug = require('debug')('Metro:InspectorProxy'); |
| 25 | + |
| 26 | +const WS_DEVICE_URL = '/inspector/device'; |
| 27 | +const WS_DEBUGGER_URL = '/inspector/debug'; |
| 28 | +const PAGES_LIST_JSON_URL = '/json'; |
| 29 | +const PAGES_LIST_JSON_URL_2 = '/json/list'; |
| 30 | +const PAGES_LIST_JSON_VERSION_URL = '/json/version'; |
| 31 | + |
| 32 | +const INTERNAL_ERROR_CODE = 1011; |
| 33 | + |
| 34 | +/** |
| 35 | + * Main Inspector Proxy class that connects JavaScript VM inside Android/iOS apps and JS debugger. |
| 36 | + */ |
| 37 | +class InspectorProxy { |
| 38 | + // Root of the project used for relative to absolute source path conversion. |
| 39 | + _projectRoot: string; |
| 40 | + |
| 41 | + // Maps device ID to Device instance. |
| 42 | + _devices: Map<string, Device>; |
| 43 | + |
| 44 | + // Internal counter for device IDs -- just gets incremented for each new device. |
| 45 | + _deviceCounter: number = 0; |
| 46 | + |
| 47 | + // We store server's address with port (like '127.0.0.1:8081') to be able to build URLs |
| 48 | + // (devtoolsFrontendUrl and webSocketDebuggerUrl) for page descriptions. These URLs are used |
| 49 | + // by debugger to know where to connect. |
| 50 | + _serverBaseUrl: string = ''; |
| 51 | + |
| 52 | + constructor(projectRoot: string) { |
| 53 | + this._projectRoot = projectRoot; |
| 54 | + this._devices = new Map(); |
| 55 | + } |
| 56 | + |
| 57 | + // Process HTTP request sent to server. We only respond to 2 HTTP requests: |
| 58 | + // 1. /json/version returns Chrome debugger protocol version that we use |
| 59 | + // 2. /json and /json/list returns list of page descriptions (list of inspectable apps). |
| 60 | + // This list is combined from all the connected devices. |
| 61 | + processRequest( |
| 62 | + request: IncomingMessage, |
| 63 | + response: ServerResponse, |
| 64 | + next: (?Error) => mixed, |
| 65 | + ) { |
| 66 | + if ( |
| 67 | + request.url === PAGES_LIST_JSON_URL || |
| 68 | + request.url === PAGES_LIST_JSON_URL_2 |
| 69 | + ) { |
| 70 | + // Build list of pages from all devices. |
| 71 | + let result: Array<PageDescription> = []; |
| 72 | + Array.from(this._devices.entries()).forEach(([deviceId, device]) => { |
| 73 | + result = result.concat( |
| 74 | + device |
| 75 | + .getPagesList() |
| 76 | + .map((page: Page) => |
| 77 | + this._buildPageDescription(deviceId, device, page), |
| 78 | + ), |
| 79 | + ); |
| 80 | + }); |
| 81 | + |
| 82 | + this._sendJsonResponse(response, result); |
| 83 | + } else if (request.url === PAGES_LIST_JSON_VERSION_URL) { |
| 84 | + this._sendJsonResponse(response, { |
| 85 | + Browser: 'Mobile JavaScript', |
| 86 | + 'Protocol-Version': '1.1', |
| 87 | + }); |
| 88 | + } else { |
| 89 | + next(); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + createWebSocketListeners(devServerBaseUrl: string): { |
| 94 | + [path: string]: WS.Server, |
| 95 | + } { |
| 96 | + this._serverBaseUrl = devServerBaseUrl; |
| 97 | + |
| 98 | + return { |
| 99 | + [WS_DEVICE_URL]: this._createDeviceConnectionWSServer(), |
| 100 | + [WS_DEBUGGER_URL]: this._createDebuggerConnectionWSServer(), |
| 101 | + }; |
| 102 | + } |
| 103 | + |
| 104 | + // Converts page information received from device into PageDescription object |
| 105 | + // that is sent to debugger. |
| 106 | + _buildPageDescription( |
| 107 | + deviceId: string, |
| 108 | + device: Device, |
| 109 | + page: Page, |
| 110 | + ): PageDescription { |
| 111 | + const debuggerUrl = `${this._serverBaseUrl}${WS_DEBUGGER_URL}?device=${deviceId}&page=${page.id}`; |
| 112 | + const webSocketDebuggerUrl = 'ws://' + debuggerUrl; |
| 113 | + const devtoolsFrontendUrl = |
| 114 | + 'devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=' + |
| 115 | + encodeURIComponent(debuggerUrl); |
| 116 | + return { |
| 117 | + id: `${deviceId}-${page.id}`, |
| 118 | + description: page.app, |
| 119 | + title: page.title, |
| 120 | + faviconUrl: 'https://reactjs.org/favicon.ico', |
| 121 | + devtoolsFrontendUrl, |
| 122 | + type: 'node', |
| 123 | + webSocketDebuggerUrl, |
| 124 | + vm: page.vm, |
| 125 | + deviceName: device.getName(), |
| 126 | + }; |
| 127 | + } |
| 128 | + |
| 129 | + // Sends object as response to HTTP request. |
| 130 | + // Just serializes object using JSON and sets required headers. |
| 131 | + _sendJsonResponse( |
| 132 | + response: ServerResponse, |
| 133 | + object: JsonPagesListResponse | JsonVersionResponse, |
| 134 | + ) { |
| 135 | + const data = JSON.stringify(object, null, 2); |
| 136 | + response.writeHead(200, { |
| 137 | + 'Content-Type': 'application/json; charset=UTF-8', |
| 138 | + 'Cache-Control': 'no-cache', |
| 139 | + 'Content-Length': data.length.toString(), |
| 140 | + Connection: 'close', |
| 141 | + }); |
| 142 | + response.end(data); |
| 143 | + } |
| 144 | + |
| 145 | + // Adds websocket handler for device connections. |
| 146 | + // Device connects to /inspector/device and passes device and app names as |
| 147 | + // HTTP GET params. |
| 148 | + // For each new websocket connection we parse device and app names and create |
| 149 | + // new instance of Device class. |
| 150 | + _createDeviceConnectionWSServer(): ws$WebSocketServer { |
| 151 | + const wss = new WS.Server({ |
| 152 | + noServer: true, |
| 153 | + perMessageDeflate: true, |
| 154 | + }); |
| 155 | + // $FlowFixMe[value-as-type] |
| 156 | + wss.on('connection', async (socket: WS, req) => { |
| 157 | + try { |
| 158 | + const fallbackDeviceId = String(this._deviceCounter++); |
| 159 | + |
| 160 | + const query = url.parse(req.url || '', true).query || {}; |
| 161 | + const deviceId = query.device || fallbackDeviceId; |
| 162 | + const deviceName = query.name || 'Unknown'; |
| 163 | + const appName = query.app || 'Unknown'; |
| 164 | + |
| 165 | + const oldDevice = this._devices.get(deviceId); |
| 166 | + const newDevice = new Device( |
| 167 | + deviceId, |
| 168 | + deviceName, |
| 169 | + appName, |
| 170 | + socket, |
| 171 | + this._projectRoot, |
| 172 | + ); |
| 173 | + |
| 174 | + if (oldDevice) { |
| 175 | + oldDevice.handleDuplicateDeviceConnection(newDevice); |
| 176 | + } |
| 177 | + |
| 178 | + this._devices.set(deviceId, newDevice); |
| 179 | + |
| 180 | + debug( |
| 181 | + `Got new connection: name=${deviceName}, app=${appName}, device=${deviceId}`, |
| 182 | + ); |
| 183 | + |
| 184 | + socket.on('close', () => { |
| 185 | + this._devices.delete(deviceId); |
| 186 | + debug(`Device ${deviceName} disconnected.`); |
| 187 | + }); |
| 188 | + } catch (e) { |
| 189 | + console.error('error', e); |
| 190 | + socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? 'Unknown error'); |
| 191 | + } |
| 192 | + }); |
| 193 | + return wss; |
| 194 | + } |
| 195 | + |
| 196 | + // Returns websocket handler for debugger connections. |
| 197 | + // Debugger connects to webSocketDebuggerUrl that we return as part of page description |
| 198 | + // in /json response. |
| 199 | + // When debugger connects we try to parse device and page IDs from the query and pass |
| 200 | + // websocket object to corresponding Device instance. |
| 201 | + _createDebuggerConnectionWSServer(): ws$WebSocketServer { |
| 202 | + const wss = new WS.Server({ |
| 203 | + noServer: true, |
| 204 | + perMessageDeflate: false, |
| 205 | + }); |
| 206 | + // $FlowFixMe[value-as-type] |
| 207 | + wss.on('connection', async (socket: WS, req) => { |
| 208 | + try { |
| 209 | + const query = url.parse(req.url || '', true).query || {}; |
| 210 | + const deviceId = query.device; |
| 211 | + const pageId = query.page; |
| 212 | + |
| 213 | + if (deviceId == null || pageId == null) { |
| 214 | + throw new Error('Incorrect URL - must provide device and page IDs'); |
| 215 | + } |
| 216 | + |
| 217 | + const device = this._devices.get(deviceId); |
| 218 | + if (device == null) { |
| 219 | + throw new Error('Unknown device with ID ' + deviceId); |
| 220 | + } |
| 221 | + |
| 222 | + device.handleDebuggerConnection(socket, pageId); |
| 223 | + } catch (e) { |
| 224 | + console.error(e); |
| 225 | + socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? 'Unknown error'); |
| 226 | + } |
| 227 | + }); |
| 228 | + return wss; |
| 229 | + } |
| 230 | +} |
| 231 | + |
| 232 | +module.exports = InspectorProxy; |
0 commit comments