From 5234fe38ffae9a63dae4c4554a72f672742f8575 Mon Sep 17 00:00:00 2001 From: Cedric van Putten Date: Wed, 8 Mar 2023 18:16:56 +0100 Subject: [PATCH] feature(cli): add custom inspector proxy based on `metro-inspector-proxy` (#21449) # Why Fixes ENG-7467 Related #21265 This is an initial draft to extend the CDP functionality of `metro-inspector-proxy`. # How The implementation is slightly wonky around the `ExpoInspectorDevice`. We want to reuse as much as possible from `metro-inspector-proxy`, but we need to add stateful data per device. In order to achieve that, we generate a new class type, based on the user's installed `metro-inspector-proxy`. This makes everything less readable but should include future updates in these classes. As for the `ExpoInspectorProxy`, to avoid having to do the same thing, we just wrap the whole inspector class and reuse what we can. The device map is "linked" within the original inspector proxy instance, making the data available to all methods that need it. # Test Plan Enable this feature with `EXPO_USE_CUSTOM_INSPECTOR_PROXY=1` - [x] See tests for the actual CDP events we handle. - [ ] See tests on the "bootstrapping code" to create the inspector and devices. # Checklist - [ ] Documentation is up to date to reflect these changes (eg: https://docs.expo.dev and README.md). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) - [ ] This diff will work correctly for `expo prebuild` & EAS Build (eg: updated a module plugin). --------- Co-authored-by: Evan Bacon --- docs/pages/workflow/expo-cli.mdx | 1 + packages/@expo/cli/CHANGELOG.md | 1 + packages/@expo/cli/package.json | 2 + .../inspector-proxy/__tests__/device.test.ts | 122 ++++++++++++ .../inspector-proxy/__tests__/proxy.test.ts | 178 +++++++++++++++++ .../server/metro/inspector-proxy/device.ts | 41 ++++ .../handlers/NetworkResponse.ts | 60 ++++++ .../__tests__/NetworkResponse.test.ts | 60 ++++++ .../metro/inspector-proxy/handlers/types.ts | 41 ++++ .../server/metro/inspector-proxy/index.ts | 29 +++ .../server/metro/inspector-proxy/proxy.ts | 179 +++++++++++++++++ .../start/server/metro/resolveFromProject.ts | 19 +- .../src/start/server/metro/runServer-fork.ts | 8 +- packages/@expo/cli/src/utils/env.ts | 5 + .../metro-inspector-proxy/index.d.ts | 183 +++++++++++++++++- 15 files changed, 916 insertions(+), 13 deletions(-) create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/device.test.ts create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/proxy.test.ts create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/device.ts create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/NetworkResponse.ts create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/__tests__/NetworkResponse.test.ts create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/types.ts create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/index.ts create mode 100644 packages/@expo/cli/src/start/server/metro/inspector-proxy/proxy.ts diff --git a/docs/pages/workflow/expo-cli.mdx b/docs/pages/workflow/expo-cli.mdx index 61e4d6b01955a..f0cae63e7dae3 100644 --- a/docs/pages/workflow/expo-cli.mdx +++ b/docs/pages/workflow/expo-cli.mdx @@ -369,6 +369,7 @@ From here, you can choose to generate basic project files like: - `EXPO_TUNNEL_SUBDOMAIN` (**boolean**) **Experimental** disable using `exp.direct` as the hostname for `--tunnel` connections. This enables **https://** forwarding which can be used to test universal links on iOS. This may cause unexpected issues with `expo-linking` and Expo Go. Select the exact subdomain to use by passing a `string` value that is not one of: `true`, `false`, `1`, `0`. - `EXPO_METRO_NO_MAIN_FIELD_OVERRIDE` (**boolean**) force Expo CLI to use the [`resolver.resolverMainFields`](https://facebook.github.io/metro/docs/configuration/#resolvermainfields) from the project's **metro.config.js** for all platforms. By default, Expo CLI will use `['browser', 'module', 'main']`, which is the default for webpack, for the web and the user-defined main fields for other platforms. - `EXPO_USE_PATH_ALIASES` (**boolean**) **Experimental:** _SDK 49+_ Allow Metro to use the `compilerOptions.paths` and `compilerOptions.baseUrl` features from `tsconfig.json` (or `jsconfig.json`) to enable import aliases and absolute imports. +- `EXPO_USE_CUSTOM_INSPECTOR_PROXY` (**boolean**) **Experimental:** _SDK 49+_ Use a customized inspector proxy with improved support for the Chrome DevTools protocol. This includes support for the network inspector. ## Telemetry diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index 5bc92c8b3939e..cd38e1ed12f50 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -15,6 +15,7 @@ - Reduce install prompt. ([#21264](https://github.com/expo/expo/pull/21264) by [@EvanBacon](https://github.com/EvanBacon)) - Improve multi-target iOS scheme resolution for `expo run:ios`. ([#21240](https://github.com/expo/expo/pull/21240) by [@EvanBacon](https://github.com/EvanBacon)) - Added experimental react-devtools integration. ([#21462](https://github.com/expo/expo/pull/21462) by [@kudo](https://github.com/kudo)) +- Add experimental inspector proxy to handle more CDP requests. ([#21449](https://github.com/expo/expo/pull/21449) by [@byCedric](https://github.com/byCedric)) ### 🐛 Bug fixes diff --git a/packages/@expo/cli/package.json b/packages/@expo/cli/package.json index d764ad0e423fd..b8094343f362b 100644 --- a/packages/@expo/cli/package.json +++ b/packages/@expo/cli/package.json @@ -133,6 +133,8 @@ "@types/webpack": "~4.41.32", "@types/webpack-dev-server": "3.11.0", "@types/wrap-ansi": "^8.0.1", + "@types/ws": "^8.5.4", + "devtools-protocol": "^0.0.1113120", "expo-module-scripts": "^3.0.0", "klaw-sync": "^6.0.0", "nock": "~13.2.2", diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/device.test.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/device.test.ts new file mode 100644 index 0000000000000..6e8007d95ea5a --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/device.test.ts @@ -0,0 +1,122 @@ +import { createInspectorDeviceClass } from '../device'; +import { InspectorHandler } from '../messages/types'; + +describe('ExpoInspectorDevice', () => { + it('initializes with default handlers', () => { + const { device } = createTestDevice(); + expect(device.handlers).toHaveLength(1); + }); + + describe('device', () => { + it('handles known device messages', () => { + const { device, MetroDevice } = createTestDevice(); + const handler: InspectorHandler = { onDeviceMessage: jest.fn().mockReturnValue(true) }; + device.handlers = [handler]; + + device._processMessageFromDevice( + { method: 'Network.responseReceived', params: { requestId: 420 } }, + jest.fn() // debugger info mock + ); + + expect(handler.onDeviceMessage).toBeCalled(); + // Expect the message is NOT propagated to original handlers + expect(MetroDevice.prototype._processMessageFromDevice).not.toBeCalled(); + }); + + it('does not handle unknown device messages', () => { + const { device, MetroDevice } = createTestDevice(); + const handler: InspectorHandler = { onDeviceMessage: jest.fn().mockReturnValue(false) }; + device.handlers = [handler]; + + device._processMessageFromDevice( + { method: 'Network.responseReceived', params: { requestId: 420 } }, + jest.fn() // debugger info mock + ); + + expect(handler.onDeviceMessage).toBeCalled(); + // Expect the message is propagated to original handlers + expect(MetroDevice.prototype._processMessageFromDevice).toBeCalled(); + }); + + it('does not handle without handlers', () => { + const { device, MetroDevice } = createTestDevice(); + device.handlers = []; + + device._processMessageFromDevice( + { method: 'Network.responseReceived', params: { requestId: 420 } }, + jest.fn() // debugger info mock + ); + + // Expect the message is propagated to original handlers + expect(MetroDevice.prototype._processMessageFromDevice).toBeCalled(); + }); + }); + + describe('debugger', () => { + it('intercepts known debugger messages', () => { + const { device, MetroDevice } = createTestDevice(); + const handler: InspectorHandler = { onDebuggerMessage: jest.fn().mockReturnValue(true) }; + device.handlers = [handler]; + + const handled = device._interceptMessageFromDebugger( + { id: 420, method: 'Network.getResponseBody', params: { requestId: 420 } }, + jest.fn(), // debugger info mock + jest.fn() as any // socket mock + ); + + expect(handled).toBe(true); + expect(handler.onDebuggerMessage).toBeCalled(); + // Expect the message is NOT propagated to original handlers + expect(MetroDevice.prototype._interceptMessageFromDebugger).not.toBeCalled(); + }); + + it('does not intercept unknown debugger messages', () => { + const { device, MetroDevice } = createTestDevice(); + const handler: InspectorHandler = { onDebuggerMessage: jest.fn().mockReturnValue(false) }; + device.handlers = [handler]; + + const handled = device._interceptMessageFromDebugger( + { id: 420, method: 'Network.getResponseBody', params: { requestId: 420 } }, + jest.fn(), // debugger info mock + jest.fn() as any // socket mock + ); + + expect(handled).not.toBe(true); + expect(handler.onDebuggerMessage).toBeCalled(); + // Expect the message is propagated to original handlers + expect(MetroDevice.prototype._interceptMessageFromDebugger).toBeCalled(); + }); + + it('does not intercept without handlers', () => { + const { device, MetroDevice } = createTestDevice(); + device.handlers = []; + + const handled = device._interceptMessageFromDebugger( + { id: 420, method: 'Network.getResponseBody', params: { requestId: 420 } }, + jest.fn(), // debugger info mock + jest.fn() as any // socket mock + ); + + expect(handled).not.toBe(true); + // Expect the message is propagated to original handlers + expect(MetroDevice.prototype._interceptMessageFromDebugger).toBeCalled(); + }); + }); +}); + +/** Create a test device instance without extending the Metro device */ +function createTestDevice() { + class MetroDevice { + _processMessageFromDevice() {} + _interceptMessageFromDebugger() {} + } + + // Dynamically replace these functions with mocks, doesn't work from class declaration + MetroDevice.prototype._processMessageFromDevice = jest.fn(); + MetroDevice.prototype._interceptMessageFromDebugger = jest.fn(); + + const ExpoDevice = createInspectorDeviceClass(MetroDevice); + const device = new ExpoDevice(); + + return { ExpoDevice, MetroDevice, device }; +} diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/proxy.test.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/proxy.test.ts new file mode 100644 index 0000000000000..c08b9d04afed5 --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/__tests__/proxy.test.ts @@ -0,0 +1,178 @@ +import connect from 'connect'; +import http from 'http'; +import { InspectorProxy as MetroProxy } from 'metro-inspector-proxy'; +import fetch from 'node-fetch'; +import { parse } from 'url'; +import WS from 'ws'; + +import { ExpoInspectorProxy as ExpoProxy } from '../proxy'; + +it('shares devices with metro proxy', () => { + const { expoProxy, metroProxy } = createTestProxy(); + expect(metroProxy._devices).toBe(expoProxy.devices); +}); + +it('responds to `/json` and `/json/list` endpoint', async () => { + const { expoProxy } = createTestProxy(); + const app = connect(); + const { server, serverUrl } = await createTestServer(app); + + app.use(expoProxy.processRequest); + + try { + const [jsonResponse, listResponse] = await Promise.all([ + fetch(`${serverUrl}/json`), + fetch(`${serverUrl}/json/list`), + ]); + + expect(jsonResponse.ok).toBe(true); + expect(listResponse.ok).toBe(true); + expect(await jsonResponse.json()).toBeDefined(); + expect(await listResponse.json()).toBeDefined(); + } finally { + server.close(); + } +}); + +it('creates websocket listeners for device and debugger', async () => { + const { expoProxy } = createTestProxy(); + const { server } = await createTestServer(); + + const listeners = expoProxy.createWebSocketListeners(server); + server.close(); + + expect(listeners['/inspector/device']).toBeInstanceOf(WS.Server); + expect(listeners['/inspector/debug']).toBeInstanceOf(WS.Server); +}); + +// The sourcemap references are relying on the server address +// Without proper failure, this could lead to unexpected sourcemap issues +it('fails when creating websocket listeners without server address', async () => { + const { expoProxy } = createTestProxy(); + const { server } = await createTestServer(); + + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + + expect(() => expoProxy.createWebSocketListeners(server)).toThrowError( + 'could not resolve the server address' + ); +}); + +it('creates a new device when a client connects', async () => { + const { expoProxy } = createTestProxy(); + const { server, deviceWebSocketUrl } = await createTestServer(); + useWebsockets(server, expoProxy.createWebSocketListeners(server)); + + const device = new WS(deviceWebSocketUrl); + + try { + await new Promise((resolve) => device.on('open', resolve)); + + expect(device.readyState).toBe(device.OPEN); + expect(expoProxy.devices.size).toBe(1); + } finally { + server.close(); + device.close(); + } +}); + +it('removes device when client disconnects', async () => { + const { expoProxy } = createTestProxy(); + const { server, deviceWebSocketUrl } = await createTestServer(); + useWebsockets(server, expoProxy.createWebSocketListeners(server)); + + const device = new WS(deviceWebSocketUrl); + + try { + await new Promise((resolve) => device.on('open', resolve)); + expect(expoProxy.devices.size).toBe(1); + + device.close(); + + await new Promise((resolve) => device.on('close', resolve)); + expect(expoProxy.devices.size).toBe(0); + } finally { + server.close(); + device.close(); + } +}); + +it('accepts debugger connections when device is connected', async () => { + const { expoProxy } = createTestProxy(); + const { server, deviceWebSocketUrl, debuggerWebSocketUrl } = await createTestServer(); + useWebsockets(server, expoProxy.createWebSocketListeners(server)); + + let deviceWs: WS | null = null; + let debuggerWs: WS | null = null; + + try { + deviceWs = new WS(deviceWebSocketUrl); + await new Promise((resolve) => deviceWs?.on('open', resolve)); + + const device = expoProxy.devices.values().next().value; + expect(device).toBeDefined(); + + const deviceDebugHandler = jest.spyOn(device, 'handleDebuggerConnection'); + + debuggerWs = new WS(`${debuggerWebSocketUrl}?device=${device.id}&page=1`); + await new Promise((resolve) => debuggerWs?.on('open', resolve)); + + expect(debuggerWs.readyState).toBe(debuggerWs.OPEN); + expect(deviceDebugHandler).toBeCalled(); + } finally { + server.close(); + deviceWs?.close(); + debuggerWs?.close(); + } +}); + +function createTestProxy() { + class ExpoDevice { + constructor(public readonly id: number) {} + handleDebuggerConnection() {} + } + + const metroProxy = new MetroProxy(); + const expoProxy = new ExpoProxy(metroProxy, ExpoDevice); + + return { ExpoDevice, metroProxy, expoProxy }; +} + +async function createTestServer(app?: http.RequestListener) { + const server = http.createServer(app); + + await new Promise((resolve, reject) => { + server.listen((error) => (error ? reject(error) : resolve())); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Test server has no proper address'); + } + + const serverLocation = + address.family === 'IPv6' + ? `[${address.address}]:${address.port}` + : `${address.address}:${address.port}`; + + return { + server, + serverUrl: `http://${serverLocation}`, + deviceWebSocketUrl: `ws://${serverLocation}/inspector/device`, + debuggerWebSocketUrl: `ws://${serverLocation}/inspector/debug`, + }; +} + +function useWebsockets(server: http.Server, websockets: Record) { + server.on('upgrade', (request, socket, head) => { + const { pathname } = parse(request.url!); + + if (pathname !== null && websockets[pathname]) { + websockets[pathname].handleUpgrade(request, socket, head, (ws) => { + websockets[pathname].emit('connection', ws, request); + }); + } + }); +} diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/device.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/device.ts new file mode 100644 index 0000000000000..8f0bd888a1fc0 --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/device.ts @@ -0,0 +1,41 @@ +import type { DebuggerInfo, Device as MetroDevice } from 'metro-inspector-proxy'; +import type WS from 'ws'; + +import { NetworkResponseHandler } from './handlers/NetworkResponse'; +import { DeviceRequest, InspectorHandler, DebuggerRequest } from './handlers/types'; + +export function createInspectorDeviceClass(MetroDeviceClass: typeof MetroDevice) { + return class ExpoInspectorDevice extends MetroDeviceClass implements InspectorHandler { + /** All handlers that should be used to intercept or reply to CDP events */ + public handlers: InspectorHandler[] = [new NetworkResponseHandler()]; + + onDeviceMessage(message: any, info: DebuggerInfo): boolean { + return this.handlers.some((handler) => handler.onDeviceMessage?.(message, info) ?? false); + } + + onDebuggerMessage(message: any, info: DebuggerInfo): boolean { + return this.handlers.some((handler) => handler.onDebuggerMessage?.(message, info) ?? false); + } + + /** Hook into the message life cycle to answer more complex CDP messages */ + async _processMessageFromDevice(message: DeviceRequest, info: DebuggerInfo) { + if (!this.onDeviceMessage(message, info)) { + await super._processMessageFromDevice(message, info); + } + } + + /** Hook into the message life cycle to answer more complex CDP messages */ + _interceptMessageFromDebugger( + request: DebuggerRequest, + info: DebuggerInfo, + socket: WS + ): boolean { + // Note, `socket` is the exact same as `info.socket` + if (this.onDebuggerMessage(request, info)) { + return true; + } + + return super._interceptMessageFromDebugger(request, info, socket); + } + }; +} diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/NetworkResponse.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/NetworkResponse.ts new file mode 100644 index 0000000000000..72e3da379caa5 --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/NetworkResponse.ts @@ -0,0 +1,60 @@ +import type { Protocol } from 'devtools-protocol'; +import type { DebuggerInfo } from 'metro-inspector-proxy'; + +import { + CdpMessage, + InspectorHandler, + DeviceRequest, + DebuggerRequest, + DebuggerResponse, + DeviceResponse, +} from './types'; + +export class NetworkResponseHandler implements InspectorHandler { + /** All known responses, mapped by request id */ + storage = new Map['result']>(); + + onDeviceMessage(message: DeviceRequest) { + if (message.method === 'Expo(Network.receivedResponseBody)') { + const { requestId, ...requestInfo } = message.params; + this.storage.set(requestId, requestInfo); + return true; + } + + return false; + } + + onDebuggerMessage( + message: DebuggerRequest, + { socket }: Pick + ) { + if ( + message.method === 'Network.getResponseBody' && + this.storage.has(message.params.requestId) + ) { + const response: DeviceResponse = { + id: message.id, + result: this.storage.get(message.params.requestId)!, + }; + + socket.send(JSON.stringify(response)); + return true; + } + + return false; + } +} + +/** Custom message to transfer the response body data to the proxy */ +export type NetworkReceivedResponseBody = CdpMessage< + 'Expo(Network.receivedResponseBody)', + Protocol.Network.GetResponseBodyRequest & Protocol.Network.GetResponseBodyResponse, + never +>; + +/** @see https://chromedevtools.github.io/devtools-protocol/1-2/Network/#method-getResponseBody */ +export type NetworkGetResponseBody = CdpMessage< + 'Network.getResponseBody', + Protocol.Network.GetResponseBodyRequest, + Protocol.Network.GetResponseBodyResponse +>; diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/__tests__/NetworkResponse.test.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/__tests__/NetworkResponse.test.ts new file mode 100644 index 0000000000000..26d24a896ad96 --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/__tests__/NetworkResponse.test.ts @@ -0,0 +1,60 @@ +import { NetworkResponseHandler } from '../NetworkResponse'; + +it('responds to response body from device and debugger', () => { + const handler = new NetworkResponseHandler(); + const debuggerSocket = { send: jest.fn() }; + + // Expect the device message to be handled + expect( + handler.onDeviceMessage({ + method: 'Expo(Network.receivedResponseBody)', + params: { + requestId: '1337', + body: 'hello', + base64Encoded: false, + }, + }) + ).toBe(true); + + // Expect the debugger message to be handled + expect( + handler.onDebuggerMessage( + { + id: 420, + method: 'Network.getResponseBody', + params: { requestId: '1337' }, + }, + { socket: debuggerSocket } + ) + ).toBe(true); + + // Expect the proper response was sent + expect(debuggerSocket.send).toBeCalledWith( + JSON.stringify({ + id: 420, + result: { + body: 'hello', + base64Encoded: false, + }, + }) + ); +}); + +it('does not respond to non-existing response', () => { + const handler = new NetworkResponseHandler(); + const debuggerSocket = { send: jest.fn() }; + + // Expect the debugger message to not be handled + expect( + handler.onDebuggerMessage( + { + id: 420, + method: 'Network.getResponseBody', + params: { requestId: '1337' }, + }, + { socket: debuggerSocket } + ) + ).toBe(false); + + expect(debuggerSocket.send).not.toBeCalled(); +}); diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/types.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/types.ts new file mode 100644 index 0000000000000..36e8924058b53 --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/handlers/types.ts @@ -0,0 +1,41 @@ +import { DebuggerInfo } from 'metro-inspector-proxy'; + +export interface InspectorHandler { + /** + * Intercept a message coming from the device, modify or respond to it through `this._sendMessageToDevice`. + * Return `true` if the message was handled, this will stop the message propagation. + */ + onDeviceMessage?(message: DeviceRequest | DeviceResponse, info: DebuggerInfo): boolean; + + /** + * Intercept a message coming from the debugger, modify or respond to it through `socket.send`. + * Return `true` if the message was handled, this will stop the message propagation. + */ + onDebuggerMessage?(message: DebuggerRequest, info: DebuggerInfo): boolean; +} + +/** + * The outline of a basic Chrome DevTools Protocol request, either from device or debugger. + * Both the request and response parameters could be optional, use `never` to enforce these fields. + */ +export type CdpMessage< + Method extends string = string, + Request extends object = object, + Response extends object = object +> = { + method: Method; + params: Request; + result: Response; +}; + +export type DeviceRequest = Pick; +export type DeviceResponse = Pick & { + /** The request identifier, used to link requests and responses */ + id: number; +}; + +export type DebuggerRequest = Pick & { + /** The request identifier, used to link requests and responses */ + id: number; +}; +export type DebuggerResponse = Pick; diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/index.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/index.ts new file mode 100644 index 0000000000000..d39495699fba4 --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/index.ts @@ -0,0 +1,29 @@ +import { + importMetroInspectorDeviceFromProject, + importMetroInspectorProxyFromProject, +} from '../resolveFromProject'; +import { createInspectorDeviceClass } from './device'; +import { ExpoInspectorProxy } from './proxy'; + +export { ExpoInspectorProxy } from './proxy'; + +const debug = require('debug')('expo:metro:inspector-proxy') as typeof console.log; + +export function createInspectorProxy(projectRoot: string) { + debug('Experimental inspector proxy enabled'); + + // Import the installed `metro-inspector-proxy` from the project + // We use these base classes to extend functionality + const { InspectorProxy: MetroInspectorProxy } = importMetroInspectorProxyFromProject(projectRoot); + // The device is slightly more complicated, we need to extend that class + const ExpoInspectorDevice = createInspectorDeviceClass( + importMetroInspectorDeviceFromProject(projectRoot) + ); + + const inspectorProxy = new ExpoInspectorProxy( + new MetroInspectorProxy(projectRoot), + ExpoInspectorDevice + ); + + return inspectorProxy; +} diff --git a/packages/@expo/cli/src/start/server/metro/inspector-proxy/proxy.ts b/packages/@expo/cli/src/start/server/metro/inspector-proxy/proxy.ts new file mode 100644 index 0000000000000..70b312e912c23 --- /dev/null +++ b/packages/@expo/cli/src/start/server/metro/inspector-proxy/proxy.ts @@ -0,0 +1,179 @@ +import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; +import type { Server as HttpsServer } from 'https'; +import type { InspectorProxy as MetroProxy, Device as MetroDevice } from 'metro-inspector-proxy'; +import { parse } from 'url'; +import WS, { Server as WSServer } from 'ws'; + +import { Log } from '../../../../log'; + +const WS_DEVICE_URL = '/inspector/device'; +const WS_DEBUGGER_URL = '/inspector/debug'; +const WS_GENERIC_ERROR_STATUS = 1011; + +const debug = require('debug')('expo:metro:inspector-proxy:proxy') as typeof console.log; + +// This is a workaround for `ConstructorType` not working on dynamically generated classes +type Instantiatable = new (...args: any) => Instance; + +export class ExpoInspectorProxy { + constructor( + public readonly metroProxy: MetroProxy, + private DeviceClass: Instantiatable, + public readonly devices: Map = new Map() + ) { + // monkey-patch the device list to expose it within the metro inspector + this.metroProxy._devices = this.devices; + + // force httpEndpointMiddleware to be bound to this proxy instance + this.processRequest = this.processRequest.bind(this); + } + + /** + * Initialize the server address from the metro server. + * This is required to properly reference sourcemaps for the debugger. + */ + private setServerAddress(server: HttpServer | HttpsServer) { + const addressInfo = server.address(); + + if (typeof addressInfo === 'string') { + throw new Error(`Inspector proxy could not resolve the server address, got "${addressInfo}"`); + } else if (addressInfo === null) { + throw new Error(`Inspector proxy could not resolve the server address, got "null"`); + } + + const { address, port, family } = addressInfo; + + if (family === 'IPv6') { + this.metroProxy._serverAddressWithPort = `[${address ?? '::1'}]:${port}`; + } else { + this.metroProxy._serverAddressWithPort = `${address ?? 'localhost'}:${port}`; + } + } + + /** @see https://chromedevtools.github.io/devtools-protocol/#endpoints */ + public processRequest(req: IncomingMessage, res: ServerResponse, next: (error?: Error) => any) { + this.metroProxy.processRequest(req, res, next); + } + + public createWebSocketListeners(server: HttpServer | HttpsServer): Record { + this.setServerAddress(server); + + return { + [WS_DEVICE_URL]: this.createDeviceWebSocketServer(), + [WS_DEBUGGER_URL]: this.createDebuggerWebSocketServer(), + }; + } + + private createDeviceWebSocketServer() { + const wss = new WS.Server({ + noServer: true, + perMessageDeflate: false, + }); + + // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L157 + wss.on('connection', (socket, request) => { + try { + const deviceId = this.metroProxy._deviceCounter++; + const { deviceName, appName } = getNewDeviceInfo(request.url); + + this.devices.set( + deviceId, + new this.DeviceClass(deviceId, deviceName, appName, socket, this.metroProxy._projectRoot) + ); + + debug('New device connected: device=%s, app=%s', deviceName, appName); + + socket.on('close', () => { + this.devices.delete(deviceId); + debug('Device disconnected: device=%s, app=%s', deviceName, appName); + }); + } catch (error: unknown) { + let message = ''; + + debug('Could not establish a connection to on-device debugger:', error); + + if (error instanceof Error) { + message = error.toString(); + Log.error('Failed to create a socket connection to on-device debugger (Hermes engine).'); + Log.exception(error); + } else { + Log.error( + 'Failed to create a socket connection to on-device debugger (Hermes engine), unknown error.' + ); + } + + socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); + } + }); + + return wss; + } + + private createDebuggerWebSocketServer() { + const wss = new WS.Server({ + noServer: true, + perMessageDeflate: false, + }); + + // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L193 + wss.on('connection', (socket, request) => { + try { + const { deviceId, pageId } = getExistingDeviceInfo(request.url); + if (!deviceId || !pageId) { + // TODO(cedric): change these errors to proper error types + throw new Error(`Missing "device" and/or "page" IDs in query parameters`); + } + + const device = this.devices.get(parseInt(deviceId, 10)); + if (!device) { + // TODO(cedric): change these errors to proper error types + throw new Error(`Device with ID "${deviceId}" not found.`); + } + + debug('New debugger connected: device=%s, app=%s', device._name, device._app); + + device.handleDebuggerConnection(socket, pageId); + + socket.on('close', () => { + debug('Debugger disconnected: device=%s, app=%s', device._name, device._app); + }); + } catch (error: unknown) { + let message = ''; + + debug('Could not establish a connection to debugger:', error); + + if (error instanceof Error) { + message = error.toString(); + Log.error('Failed to create a socket connection to the debugger.'); + Log.exception(error); + } else { + Log.error('Failed to create a socket connection to the debugger, unkown error.'); + } + + socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); + } + }); + + return wss; + } +} + +function asString(value: string | string[] = ''): string { + return Array.isArray(value) ? value.join() : value; +} + +function getNewDeviceInfo(url: IncomingMessage['url']) { + const { query } = parse(url ?? '', true); + return { + deviceName: asString(query.name) || 'Unknown device name', + appName: asString(query.app) || 'Unknown app name', + }; +} + +function getExistingDeviceInfo(url: IncomingMessage['url']) { + const { query } = parse(url ?? '', true); + return { + deviceId: asString(query.device), + pageId: asString(query.page), + }; +} diff --git a/packages/@expo/cli/src/start/server/metro/resolveFromProject.ts b/packages/@expo/cli/src/start/server/metro/resolveFromProject.ts index 68dd96ba14e5a..342963c7b503f 100644 --- a/packages/@expo/cli/src/start/server/metro/resolveFromProject.ts +++ b/packages/@expo/cli/src/start/server/metro/resolveFromProject.ts @@ -30,11 +30,6 @@ function importFromProject(projectRoot: string, moduleId: string) { export function importMetroFromProject(projectRoot: string): typeof import('metro') { return importFromProject(projectRoot, 'metro'); } -export function importMetroInspectorProxyFromProject( - projectRoot: string -): typeof import('metro-inspector-proxy') { - return importFromProject(projectRoot, 'metro-inspector-proxy'); -} export function importMetroCreateWebsocketServerFromProject( projectRoot: string ): typeof import('metro/src/lib/createWebsocketServer').createWebsocketServer { @@ -60,6 +55,20 @@ export function importMetroResolverFromProject( return importFromProject(projectRoot, 'metro-resolver'); } +/** Import `metro-inspector-proxy` from the project. */ +export function importMetroInspectorProxyFromProject( + projectRoot: string +): typeof import('metro-inspector-proxy') { + return importFromProject(projectRoot, 'metro-inspector-proxy'); +} + +/** Import `metro-inspector-proxy/src/Device` from the project. */ +export function importMetroInspectorDeviceFromProject( + projectRoot: string +): typeof import('metro-inspector-proxy/src/Device') { + return importFromProject(projectRoot, 'metro-inspector-proxy/src/Device'); +} + /** * Import the internal `saveAssets()` function from `react-native` for the purpose * of saving production assets as-is instead of converting them to a hash. diff --git a/packages/@expo/cli/src/start/server/metro/runServer-fork.ts b/packages/@expo/cli/src/start/server/metro/runServer-fork.ts index f52203ce6764b..9d7b943e17235 100644 --- a/packages/@expo/cli/src/start/server/metro/runServer-fork.ts +++ b/packages/@expo/cli/src/start/server/metro/runServer-fork.ts @@ -10,6 +10,8 @@ import { ConfigT } from 'metro-config'; import { InspectorProxy } from 'metro-inspector-proxy'; import { parse } from 'url'; +import { env } from '../../../utils/env'; +import { createInspectorProxy, ExpoInspectorProxy } from './inspector-proxy'; import { importMetroCreateWebsocketServerFromProject, importMetroFromProject, @@ -64,8 +66,10 @@ export const runServer = async ( serverApp.use(middleware); - let inspectorProxy: InspectorProxy | null = null; - if (config.server.runInspectorProxy) { + let inspectorProxy: InspectorProxy | ExpoInspectorProxy | null = null; + if (config.server.runInspectorProxy && env.EXPO_USE_CUSTOM_INSPECTOR_PROXY) { + inspectorProxy = createInspectorProxy(config.projectRoot); + } else if (config.server.runInspectorProxy) { inspectorProxy = new InspectorProxy(config.projectRoot); } diff --git a/packages/@expo/cli/src/utils/env.ts b/packages/@expo/cli/src/utils/env.ts index 2769bd9e3ef4d..f0b9bcf891cd6 100644 --- a/packages/@expo/cli/src/utils/env.ts +++ b/packages/@expo/cli/src/utils/env.ts @@ -141,6 +141,11 @@ class Env { get EXPO_USE_PATH_ALIASES(): boolean { return boolish('EXPO_USE_PATH_ALIASES', false); } + + /** **Experimental:** Use the network inspector by overriding the metro inspector proxy with a custom version */ + get EXPO_USE_CUSTOM_INSPECTOR_PROXY(): boolean { + return boolish('EXPO_USE_CUSTOM_INSPECTOR_PROXY', false); + } } export const env = new Env(); diff --git a/packages/@expo/cli/ts-declarations/metro-inspector-proxy/index.d.ts b/packages/@expo/cli/ts-declarations/metro-inspector-proxy/index.d.ts index 026c50a53775d..d3bbfb9989e78 100644 --- a/packages/@expo/cli/ts-declarations/metro-inspector-proxy/index.d.ts +++ b/packages/@expo/cli/ts-declarations/metro-inspector-proxy/index.d.ts @@ -1,10 +1,181 @@ +declare module 'metro-inspector-proxy/src/Device' { + import { Device } from 'metro-inspector-proxy'; + export = Device; +} + declare module 'metro-inspector-proxy' { - import { Server } from 'metro'; - import http from 'http'; - import https from 'https'; - export class InspectorProxy { + import WS from 'ws'; + import type { Server as HttpsServer } from 'https'; + import type { + IncomingMessage as HttpRequest, + ServerResponse as HttpResponse, + Server as HttpServer, + } from 'http'; + + type Middleware = (error?: Error) => any; + + /** + * 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. + */ + type Page = { + id: string; + title: string; + vm: string; + app: string; + + // Allow objects too + [key: string]: string; + }; + + 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; + + // Allow objects too + [key: string]: string; + }; + + type PageDescription = any; + + function runInspectorProxy(port: number, projectRoot: string): void; + + 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. + */ + _serverAddressWithPort: string = ''; + constructor(projectRoot: string); - processRequest(req: http.IncomingMessage, res: http.ServerResponse, next: () => void): void; - createWebSocketListeners(server: T): T; + + /** + * 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(req: HttpRequest, res: HttpResponse, next: Middleware): void; + + /** Adds websocket listeners to the provided HTTP/HTTPS server. */ + createWebSocketListeners(server: HttpServer | HttpsServer): Record; + + /** Converts page information received from device into PageDescription object that is sent to debugger. */ + _buildPageDescription(deviceId: number, device: Device, page: Page): PageDescription; + + /** + * Sends object as response to HTTP request. + * Just serializes object using JSON and sets required headers. + */ + _sendJsonResponse(response: ServerResponse, object: any): void; + + /** + * 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.Server; + + /** + * 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.Server; + } + + class Device { + /** ID of the device. */ + _id: number; + /** 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: Page[]; + /** Stores information about currently connected debugger (if any). */ + _debuggerConnection: DebuggerInfo | null = 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; + /** Root of the project used for relative to absolute source path conversion. */ + _projectRoot: string; + + /** + * 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 = null; + + constructor(id: number, name: string, app: string, socket: WS, projectRoot: string); + + getName(): string; + getPagesList(): Page[]; + + /** + * 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): void; + + /** + * 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): void; + + /** Sends single message to device. */ + _sendMessageToDevice(message: MessageToDevice): void; + + /** Sends 'getPages' request to device every PAGES_POLLING_INTERVAL milliseconds.*/ + _setPagesPolling(): void; + + /** Allows to make changes in incoming message from device. */ + _processMessageFromDevice( + payload: { method: string; params: { sourceMapURL: string; url: string } }, + debuggerInfo: DebuggerInfo + ): void; + + /** Allows to make changes in incoming messages from debugger. */ + _interceptMessageFromDebugger( + request: DebuggerRequest, + debuggerInfo: DebuggerInfo, + socket: WS + ): DebuggerResponse | null; + + // _newReactNativePage + // _processDebuggerSetBreakpointByUrl + // _processDebuggerGetScriptSource + // _mapToDevicePageId } }