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 // See https://github.com/facebook/metro/pull/991 // @ts-expect-error - Device ID is changing from `number` to `string` this.metroProxy._devices = this.devices; // force httpEndpointMiddleware to be bound to this proxy instance this.processRequest = this.processRequest.bind(this); } /** * Normalize the server address for clients to connect to. * @param addressInfo the server address returned by `HttpServer.address()` or `HttpsServer.address()`. * @returns "address:port" */ public static normalizeServerAddress(addressInfo: ReturnType): string { 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"`); } let address = addressInfo.address; if (addressInfo.family === 'IPv6') { address = address === '::' ? `[::1]` : `[${address}]`; } else { address = address === '0.0.0.0' ? 'localhost' : address; } return `${address}:${addressInfo.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 { // Initialize the server address from the metro server. // This is required to properly reference sourcemaps for the debugger. this.metroProxy._serverAddressWithPort = ExpoInspectorProxy.normalizeServerAddress( server.address() ); 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 fallbackDeviceId = String(this.metroProxy._deviceCounter++); const { deviceId: newDeviceId, deviceName, appName } = getDeviceInfo(request.url); const deviceId = newDeviceId ?? fallbackDeviceId; const oldDevice = this.devices.get(deviceId); const newDevice = new this.DeviceClass( deviceId, deviceName, appName, socket, this.metroProxy._projectRoot ); if (oldDevice) { debug('Device reconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); // See: https://github.com/facebook/metro/pull/991 // @ts-expect-error - Newly introduced method coming to metro-inspector-proxy soon oldDevice.handleDuplicateDeviceConnection(newDevice); } else { debug('New device connected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); } this.devices.set(deviceId, newDevice); socket.on('close', () => { if (this.devices.get(deviceId) === newDevice) { this.devices.delete(deviceId); debug('Device disconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); } }); } 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, debuggerType } = getDebuggerInfo(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(deviceId); 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); // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device if (debuggerType && typeof device.handleDebuggerConnectionWithType === 'function') { // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device device.handleDebuggerConnectionWithType(socket, pageId, debuggerType); } else { 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 getDeviceInfo(url: IncomingMessage['url']) { const { query } = parse(url ?? '', true); return { deviceId: asString(query.device) || undefined, deviceName: asString(query.name) || 'Unknown device name', appName: asString(query.app) || 'Unknown app name', }; } function getDebuggerInfo(url: IncomingMessage['url']) { const { query } = parse(url ?? '', true); return { deviceId: asString(query.device), pageId: asString(query.page), debuggerType: asString(query.type) ?? undefined, }; }