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