1import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; 2import type { Server as HttpsServer } from 'https'; 3import type { InspectorProxy as MetroProxy, Device as MetroDevice } from 'metro-inspector-proxy'; 4import { parse } from 'url'; 5import WS, { Server as WSServer } from 'ws'; 6 7import { Log } from '../../../../log'; 8 9const WS_DEVICE_URL = '/inspector/device'; 10const WS_DEBUGGER_URL = '/inspector/debug'; 11const WS_GENERIC_ERROR_STATUS = 1011; 12 13const debug = require('debug')('expo:metro:inspector-proxy:proxy') as typeof console.log; 14 15// This is a workaround for `ConstructorType` not working on dynamically generated classes 16type Instantiatable<Instance> = new (...args: any) => Instance; 17 18export class ExpoInspectorProxy<D extends MetroDevice = MetroDevice> { 19 constructor( 20 public readonly metroProxy: MetroProxy, 21 private DeviceClass: Instantiatable<D>, 22 public readonly devices: Map<string, D> = new Map() 23 ) { 24 // monkey-patch the device list to expose it within the metro inspector 25 // See https://github.com/facebook/metro/pull/991 26 // @ts-expect-error - Device ID is changing from `number` to `string` 27 this.metroProxy._devices = this.devices; 28 29 // force httpEndpointMiddleware to be bound to this proxy instance 30 this.processRequest = this.processRequest.bind(this); 31 } 32 33 /** 34 * Normalize the server address for clients to connect to. 35 * @param addressInfo the server address returned by `HttpServer.address()` or `HttpsServer.address()`. 36 * @returns "address:port" 37 */ 38 public static normalizeServerAddress(addressInfo: ReturnType<HttpServer['address']>): string { 39 if (typeof addressInfo === 'string') { 40 throw new Error(`Inspector proxy could not resolve the server address, got "${addressInfo}"`); 41 } else if (addressInfo === null) { 42 throw new Error(`Inspector proxy could not resolve the server address, got "null"`); 43 } 44 45 let address = addressInfo.address; 46 if (addressInfo.family === 'IPv6') { 47 address = address === '::' ? `[::1]` : `[${address}]`; 48 } else { 49 address = address === '0.0.0.0' ? 'localhost' : address; 50 } 51 return `${address}:${addressInfo.port}`; 52 } 53 54 /** @see https://chromedevtools.github.io/devtools-protocol/#endpoints */ 55 public processRequest(req: IncomingMessage, res: ServerResponse, next: (error?: Error) => any) { 56 this.metroProxy.processRequest(req, res, next); 57 } 58 59 public createWebSocketListeners(server: HttpServer | HttpsServer): Record<string, WSServer> { 60 // Initialize the server address from the metro server. 61 // This is required to properly reference sourcemaps for the debugger. 62 this.metroProxy._serverAddressWithPort = ExpoInspectorProxy.normalizeServerAddress( 63 server.address() 64 ); 65 66 return { 67 [WS_DEVICE_URL]: this.createDeviceWebSocketServer(), 68 [WS_DEBUGGER_URL]: this.createDebuggerWebSocketServer(), 69 }; 70 } 71 72 private createDeviceWebSocketServer() { 73 const wss = new WS.Server({ 74 noServer: true, 75 perMessageDeflate: false, 76 }); 77 78 // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L157 79 wss.on('connection', (socket, request) => { 80 try { 81 const fallbackDeviceId = String(this.metroProxy._deviceCounter++); 82 const { deviceId: newDeviceId, deviceName, appName } = getDeviceInfo(request.url); 83 84 const deviceId = newDeviceId ?? fallbackDeviceId; 85 86 const oldDevice = this.devices.get(deviceId); 87 const newDevice = new this.DeviceClass( 88 deviceId, 89 deviceName, 90 appName, 91 socket, 92 this.metroProxy._projectRoot 93 ); 94 95 if (oldDevice) { 96 debug('Device reconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 97 // See: https://github.com/facebook/metro/pull/991 98 // @ts-expect-error - Newly introduced method coming to metro-inspector-proxy soon 99 oldDevice.handleDuplicateDeviceConnection(newDevice); 100 } else { 101 debug('New device connected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 102 } 103 104 this.devices.set(deviceId, newDevice); 105 106 socket.on('close', () => { 107 if (this.devices.get(deviceId) === newDevice) { 108 this.devices.delete(deviceId); 109 debug('Device disconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 110 } 111 }); 112 } catch (error: unknown) { 113 let message = ''; 114 115 debug('Could not establish a connection to on-device debugger:', error); 116 117 if (error instanceof Error) { 118 message = error.toString(); 119 Log.error('Failed to create a socket connection to on-device debugger (Hermes engine).'); 120 Log.exception(error); 121 } else { 122 Log.error( 123 'Failed to create a socket connection to on-device debugger (Hermes engine), unknown error.' 124 ); 125 } 126 127 socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); 128 } 129 }); 130 131 return wss; 132 } 133 134 private createDebuggerWebSocketServer() { 135 const wss = new WS.Server({ 136 noServer: true, 137 perMessageDeflate: false, 138 }); 139 140 // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L193 141 wss.on('connection', (socket, request) => { 142 try { 143 const { deviceId, pageId, debuggerType } = getDebuggerInfo(request.url); 144 if (!deviceId || !pageId) { 145 // TODO(cedric): change these errors to proper error types 146 throw new Error(`Missing "device" and/or "page" IDs in query parameters`); 147 } 148 149 const device = this.devices.get(deviceId); 150 if (!device) { 151 // TODO(cedric): change these errors to proper error types 152 throw new Error(`Device with ID "${deviceId}" not found.`); 153 } 154 155 debug('New debugger connected: device=%s, app=%s', device._name, device._app); 156 157 // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device 158 if (debuggerType && typeof device.handleDebuggerConnectionWithType === 'function') { 159 // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device 160 device.handleDebuggerConnectionWithType(socket, pageId, debuggerType); 161 } else { 162 device.handleDebuggerConnection(socket, pageId); 163 } 164 165 socket.on('close', () => { 166 debug('Debugger disconnected: device=%s, app=%s', device._name, device._app); 167 }); 168 } catch (error: unknown) { 169 let message = ''; 170 171 debug('Could not establish a connection to debugger:', error); 172 173 if (error instanceof Error) { 174 message = error.toString(); 175 Log.error('Failed to create a socket connection to the debugger.'); 176 Log.exception(error); 177 } else { 178 Log.error('Failed to create a socket connection to the debugger, unkown error.'); 179 } 180 181 socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); 182 } 183 }); 184 185 return wss; 186 } 187} 188 189function asString(value: string | string[] = ''): string { 190 return Array.isArray(value) ? value.join() : value; 191} 192 193function getDeviceInfo(url: IncomingMessage['url']) { 194 const { query } = parse(url ?? '', true); 195 return { 196 deviceId: asString(query.device) || undefined, 197 deviceName: asString(query.name) || 'Unknown device name', 198 appName: asString(query.app) || 'Unknown app name', 199 }; 200} 201 202function getDebuggerInfo(url: IncomingMessage['url']) { 203 const { query } = parse(url ?? '', true); 204 return { 205 deviceId: asString(query.device), 206 pageId: asString(query.page), 207 debuggerType: asString(query.type) ?? undefined, 208 }; 209} 210