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 } = getDeviceInfo(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, debuggerType } = getDebuggerInfo(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 // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device 155 if (debuggerType && typeof device.handleDebuggerConnectionWithType === 'function') { 156 // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device 157 device.handleDebuggerConnectionWithType(socket, pageId, debuggerType); 158 } else { 159 device.handleDebuggerConnection(socket, pageId); 160 } 161 162 socket.on('close', () => { 163 debug('Debugger disconnected: device=%s, app=%s', device._name, device._app); 164 }); 165 } catch (error: unknown) { 166 let message = ''; 167 168 debug('Could not establish a connection to debugger:', error); 169 170 if (error instanceof Error) { 171 message = error.toString(); 172 Log.error('Failed to create a socket connection to the debugger.'); 173 Log.exception(error); 174 } else { 175 Log.error('Failed to create a socket connection to the debugger, unkown error.'); 176 } 177 178 socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); 179 } 180 }); 181 182 return wss; 183 } 184} 185 186function asString(value: string | string[] = ''): string { 187 return Array.isArray(value) ? value.join() : value; 188} 189 190function getDeviceInfo(url: IncomingMessage['url']) { 191 const { query } = parse(url ?? '', true); 192 return { 193 deviceId: asString(query.device) || undefined, 194 deviceName: asString(query.name) || 'Unknown device name', 195 appName: asString(query.app) || 'Unknown app name', 196 }; 197} 198 199function getDebuggerInfo(url: IncomingMessage['url']) { 200 const { query } = parse(url ?? '', true); 201 return { 202 deviceId: asString(query.device), 203 pageId: asString(query.page), 204 debuggerType: asString(query.type) ?? undefined, 205 }; 206} 207