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 * Initialize the server address from the metro server. 35 * This is required to properly reference sourcemaps for the debugger. 36 */ 37 private setServerAddress(server: HttpServer | HttpsServer) { 38 const addressInfo = server.address(); 39 40 if (typeof addressInfo === 'string') { 41 throw new Error(`Inspector proxy could not resolve the server address, got "${addressInfo}"`); 42 } else if (addressInfo === null) { 43 throw new Error(`Inspector proxy could not resolve the server address, got "null"`); 44 } 45 46 const { address, port, family } = addressInfo; 47 48 if (family === 'IPv6') { 49 this.metroProxy._serverAddressWithPort = `[${address ?? '::1'}]:${port}`; 50 } else { 51 this.metroProxy._serverAddressWithPort = `${address ?? 'localhost'}:${port}`; 52 } 53 } 54 55 /** @see https://chromedevtools.github.io/devtools-protocol/#endpoints */ 56 public processRequest(req: IncomingMessage, res: ServerResponse, next: (error?: Error) => any) { 57 this.metroProxy.processRequest(req, res, next); 58 } 59 60 public createWebSocketListeners(server: HttpServer | HttpsServer): Record<string, WSServer> { 61 this.setServerAddress(server); 62 63 return { 64 [WS_DEVICE_URL]: this.createDeviceWebSocketServer(), 65 [WS_DEBUGGER_URL]: this.createDebuggerWebSocketServer(), 66 }; 67 } 68 69 private createDeviceWebSocketServer() { 70 const wss = new WS.Server({ 71 noServer: true, 72 perMessageDeflate: false, 73 }); 74 75 // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L157 76 wss.on('connection', (socket, request) => { 77 try { 78 const fallbackDeviceId = String(this.metroProxy._deviceCounter++); 79 const { deviceId: newDeviceId, deviceName, appName } = getNewDeviceInfo(request.url); 80 81 const deviceId = newDeviceId ?? fallbackDeviceId; 82 83 const oldDevice = this.devices.get(deviceId); 84 const newDevice = new this.DeviceClass( 85 deviceId, 86 deviceName, 87 appName, 88 socket, 89 this.metroProxy._projectRoot 90 ); 91 92 if (oldDevice) { 93 debug('Device reconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 94 // See: https://github.com/facebook/metro/pull/991 95 // @ts-expect-error - Newly introduced method coming to metro-inspector-proxy soon 96 oldDevice.handleDuplicateDeviceConnection(newDevice); 97 } else { 98 debug('New device connected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 99 } 100 101 this.devices.set(deviceId, newDevice); 102 103 socket.on('close', () => { 104 if (this.devices.get(deviceId) === newDevice) { 105 this.devices.delete(deviceId); 106 debug('Device disconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 107 } 108 }); 109 } catch (error: unknown) { 110 let message = ''; 111 112 debug('Could not establish a connection to on-device debugger:', error); 113 114 if (error instanceof Error) { 115 message = error.toString(); 116 Log.error('Failed to create a socket connection to on-device debugger (Hermes engine).'); 117 Log.exception(error); 118 } else { 119 Log.error( 120 'Failed to create a socket connection to on-device debugger (Hermes engine), unknown error.' 121 ); 122 } 123 124 socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); 125 } 126 }); 127 128 return wss; 129 } 130 131 private createDebuggerWebSocketServer() { 132 const wss = new WS.Server({ 133 noServer: true, 134 perMessageDeflate: false, 135 }); 136 137 // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L193 138 wss.on('connection', (socket, request) => { 139 try { 140 const { deviceId, pageId } = getExistingDeviceInfo(request.url); 141 if (!deviceId || !pageId) { 142 // TODO(cedric): change these errors to proper error types 143 throw new Error(`Missing "device" and/or "page" IDs in query parameters`); 144 } 145 146 const device = this.devices.get(deviceId); 147 if (!device) { 148 // TODO(cedric): change these errors to proper error types 149 throw new Error(`Device with ID "${deviceId}" not found.`); 150 } 151 152 debug('New debugger connected: device=%s, app=%s', device._name, device._app); 153 154 device.handleDebuggerConnection(socket, pageId); 155 156 socket.on('close', () => { 157 debug('Debugger disconnected: device=%s, app=%s', device._name, device._app); 158 }); 159 } catch (error: unknown) { 160 let message = ''; 161 162 debug('Could not establish a connection to debugger:', error); 163 164 if (error instanceof Error) { 165 message = error.toString(); 166 Log.error('Failed to create a socket connection to the debugger.'); 167 Log.exception(error); 168 } else { 169 Log.error('Failed to create a socket connection to the debugger, unkown error.'); 170 } 171 172 socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); 173 } 174 }); 175 176 return wss; 177 } 178} 179 180function asString(value: string | string[] = ''): string { 181 return Array.isArray(value) ? value.join() : value; 182} 183 184function getNewDeviceInfo(url: IncomingMessage['url']) { 185 const { query } = parse(url ?? '', true); 186 return { 187 deviceId: asString(query.device) || undefined, 188 deviceName: asString(query.name) || 'Unknown device name', 189 appName: asString(query.app) || 'Unknown app name', 190 }; 191} 192 193function getExistingDeviceInfo(url: IncomingMessage['url']) { 194 const { query } = parse(url ?? '', true); 195 return { 196 deviceId: asString(query.device), 197 pageId: asString(query.page), 198 }; 199} 200