15234fe38SCedric van Puttenimport type { Server as HttpServer, IncomingMessage, ServerResponse } from 'http'; 25234fe38SCedric van Puttenimport type { Server as HttpsServer } from 'https'; 35234fe38SCedric van Puttenimport type { InspectorProxy as MetroProxy, Device as MetroDevice } from 'metro-inspector-proxy'; 45234fe38SCedric van Puttenimport { parse } from 'url'; 55234fe38SCedric van Puttenimport WS, { Server as WSServer } from 'ws'; 65234fe38SCedric van Putten 75234fe38SCedric van Puttenimport { Log } from '../../../../log'; 85234fe38SCedric van Putten 95234fe38SCedric van Puttenconst WS_DEVICE_URL = '/inspector/device'; 105234fe38SCedric van Puttenconst WS_DEBUGGER_URL = '/inspector/debug'; 115234fe38SCedric van Puttenconst WS_GENERIC_ERROR_STATUS = 1011; 125234fe38SCedric van Putten 135234fe38SCedric van Puttenconst debug = require('debug')('expo:metro:inspector-proxy:proxy') as typeof console.log; 145234fe38SCedric van Putten 155234fe38SCedric van Putten// This is a workaround for `ConstructorType` not working on dynamically generated classes 165234fe38SCedric van Puttentype Instantiatable<Instance> = new (...args: any) => Instance; 175234fe38SCedric van Putten 185234fe38SCedric van Puttenexport class ExpoInspectorProxy<D extends MetroDevice = MetroDevice> { 195234fe38SCedric van Putten constructor( 205234fe38SCedric van Putten public readonly metroProxy: MetroProxy, 215234fe38SCedric van Putten private DeviceClass: Instantiatable<D>, 22f5fa30f6SCedric van Putten public readonly devices: Map<string, D> = new Map() 235234fe38SCedric van Putten ) { 245234fe38SCedric van Putten // monkey-patch the device list to expose it within the metro inspector 25f5fa30f6SCedric van Putten // See https://github.com/facebook/metro/pull/991 26f5fa30f6SCedric van Putten // @ts-expect-error - Device ID is changing from `number` to `string` 275234fe38SCedric van Putten this.metroProxy._devices = this.devices; 285234fe38SCedric van Putten 295234fe38SCedric van Putten // force httpEndpointMiddleware to be bound to this proxy instance 305234fe38SCedric van Putten this.processRequest = this.processRequest.bind(this); 315234fe38SCedric van Putten } 325234fe38SCedric van Putten 335234fe38SCedric van Putten /** 34*70e5585aSKudo Chien * Normalize the server address for clients to connect to. 35*70e5585aSKudo Chien * @param addressInfo the server address returned by `HttpServer.address()` or `HttpsServer.address()`. 36*70e5585aSKudo Chien * @returns "address:port" 375234fe38SCedric van Putten */ 38*70e5585aSKudo Chien public static normalizeServerAddress(addressInfo: ReturnType<HttpServer['address']>): string { 395234fe38SCedric van Putten if (typeof addressInfo === 'string') { 405234fe38SCedric van Putten throw new Error(`Inspector proxy could not resolve the server address, got "${addressInfo}"`); 415234fe38SCedric van Putten } else if (addressInfo === null) { 425234fe38SCedric van Putten throw new Error(`Inspector proxy could not resolve the server address, got "null"`); 435234fe38SCedric van Putten } 445234fe38SCedric van Putten 45*70e5585aSKudo Chien let address = addressInfo.address; 46*70e5585aSKudo Chien if (addressInfo.family === 'IPv6') { 47*70e5585aSKudo Chien address = address === '::' ? `[::1]` : `[${address}]`; 485234fe38SCedric van Putten } else { 49*70e5585aSKudo Chien address = address === '0.0.0.0' ? 'localhost' : address; 505234fe38SCedric van Putten } 51*70e5585aSKudo Chien return `${address}:${addressInfo.port}`; 525234fe38SCedric van Putten } 535234fe38SCedric van Putten 545234fe38SCedric van Putten /** @see https://chromedevtools.github.io/devtools-protocol/#endpoints */ 555234fe38SCedric van Putten public processRequest(req: IncomingMessage, res: ServerResponse, next: (error?: Error) => any) { 565234fe38SCedric van Putten this.metroProxy.processRequest(req, res, next); 575234fe38SCedric van Putten } 585234fe38SCedric van Putten 595234fe38SCedric van Putten public createWebSocketListeners(server: HttpServer | HttpsServer): Record<string, WSServer> { 60*70e5585aSKudo Chien // Initialize the server address from the metro server. 61*70e5585aSKudo Chien // This is required to properly reference sourcemaps for the debugger. 62*70e5585aSKudo Chien this.metroProxy._serverAddressWithPort = ExpoInspectorProxy.normalizeServerAddress( 63*70e5585aSKudo Chien server.address() 64*70e5585aSKudo Chien ); 655234fe38SCedric van Putten 665234fe38SCedric van Putten return { 675234fe38SCedric van Putten [WS_DEVICE_URL]: this.createDeviceWebSocketServer(), 685234fe38SCedric van Putten [WS_DEBUGGER_URL]: this.createDebuggerWebSocketServer(), 695234fe38SCedric van Putten }; 705234fe38SCedric van Putten } 715234fe38SCedric van Putten 725234fe38SCedric van Putten private createDeviceWebSocketServer() { 735234fe38SCedric van Putten const wss = new WS.Server({ 745234fe38SCedric van Putten noServer: true, 755234fe38SCedric van Putten perMessageDeflate: false, 765234fe38SCedric van Putten }); 775234fe38SCedric van Putten 785234fe38SCedric van Putten // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L157 795234fe38SCedric van Putten wss.on('connection', (socket, request) => { 805234fe38SCedric van Putten try { 81f5fa30f6SCedric van Putten const fallbackDeviceId = String(this.metroProxy._deviceCounter++); 82033ea1fcSCedric van Putten const { deviceId: newDeviceId, deviceName, appName } = getDeviceInfo(request.url); 835234fe38SCedric van Putten 84f5fa30f6SCedric van Putten const deviceId = newDeviceId ?? fallbackDeviceId; 85f5fa30f6SCedric van Putten 86f5fa30f6SCedric van Putten const oldDevice = this.devices.get(deviceId); 87f5fa30f6SCedric van Putten const newDevice = new this.DeviceClass( 885234fe38SCedric van Putten deviceId, 89f5fa30f6SCedric van Putten deviceName, 90f5fa30f6SCedric van Putten appName, 91f5fa30f6SCedric van Putten socket, 92f5fa30f6SCedric van Putten this.metroProxy._projectRoot 935234fe38SCedric van Putten ); 945234fe38SCedric van Putten 95f5fa30f6SCedric van Putten if (oldDevice) { 96f5fa30f6SCedric van Putten debug('Device reconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 97f5fa30f6SCedric van Putten // See: https://github.com/facebook/metro/pull/991 98f5fa30f6SCedric van Putten // @ts-expect-error - Newly introduced method coming to metro-inspector-proxy soon 99f5fa30f6SCedric van Putten oldDevice.handleDuplicateDeviceConnection(newDevice); 100f5fa30f6SCedric van Putten } else { 101f5fa30f6SCedric van Putten debug('New device connected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 102f5fa30f6SCedric van Putten } 103f5fa30f6SCedric van Putten 104f5fa30f6SCedric van Putten this.devices.set(deviceId, newDevice); 1055234fe38SCedric van Putten 1065234fe38SCedric van Putten socket.on('close', () => { 107f5fa30f6SCedric van Putten if (this.devices.get(deviceId) === newDevice) { 1085234fe38SCedric van Putten this.devices.delete(deviceId); 109f5fa30f6SCedric van Putten debug('Device disconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId); 110f5fa30f6SCedric van Putten } 1115234fe38SCedric van Putten }); 1125234fe38SCedric van Putten } catch (error: unknown) { 1135234fe38SCedric van Putten let message = ''; 1145234fe38SCedric van Putten 1155234fe38SCedric van Putten debug('Could not establish a connection to on-device debugger:', error); 1165234fe38SCedric van Putten 1175234fe38SCedric van Putten if (error instanceof Error) { 1185234fe38SCedric van Putten message = error.toString(); 1195234fe38SCedric van Putten Log.error('Failed to create a socket connection to on-device debugger (Hermes engine).'); 1205234fe38SCedric van Putten Log.exception(error); 1215234fe38SCedric van Putten } else { 1225234fe38SCedric van Putten Log.error( 1235234fe38SCedric van Putten 'Failed to create a socket connection to on-device debugger (Hermes engine), unknown error.' 1245234fe38SCedric van Putten ); 1255234fe38SCedric van Putten } 1265234fe38SCedric van Putten 1275234fe38SCedric van Putten socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); 1285234fe38SCedric van Putten } 1295234fe38SCedric van Putten }); 1305234fe38SCedric van Putten 1315234fe38SCedric van Putten return wss; 1325234fe38SCedric van Putten } 1335234fe38SCedric van Putten 1345234fe38SCedric van Putten private createDebuggerWebSocketServer() { 1355234fe38SCedric van Putten const wss = new WS.Server({ 1365234fe38SCedric van Putten noServer: true, 1375234fe38SCedric van Putten perMessageDeflate: false, 1385234fe38SCedric van Putten }); 1395234fe38SCedric van Putten 1405234fe38SCedric van Putten // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L193 1415234fe38SCedric van Putten wss.on('connection', (socket, request) => { 1425234fe38SCedric van Putten try { 143033ea1fcSCedric van Putten const { deviceId, pageId, debuggerType } = getDebuggerInfo(request.url); 1445234fe38SCedric van Putten if (!deviceId || !pageId) { 1455234fe38SCedric van Putten // TODO(cedric): change these errors to proper error types 1465234fe38SCedric van Putten throw new Error(`Missing "device" and/or "page" IDs in query parameters`); 1475234fe38SCedric van Putten } 1485234fe38SCedric van Putten 149f5fa30f6SCedric van Putten const device = this.devices.get(deviceId); 1505234fe38SCedric van Putten if (!device) { 1515234fe38SCedric van Putten // TODO(cedric): change these errors to proper error types 1525234fe38SCedric van Putten throw new Error(`Device with ID "${deviceId}" not found.`); 1535234fe38SCedric van Putten } 1545234fe38SCedric van Putten 1555234fe38SCedric van Putten debug('New debugger connected: device=%s, app=%s', device._name, device._app); 1565234fe38SCedric van Putten 157033ea1fcSCedric van Putten // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device 158033ea1fcSCedric van Putten if (debuggerType && typeof device.handleDebuggerConnectionWithType === 'function') { 159033ea1fcSCedric van Putten // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device 160033ea1fcSCedric van Putten device.handleDebuggerConnectionWithType(socket, pageId, debuggerType); 161033ea1fcSCedric van Putten } else { 1625234fe38SCedric van Putten device.handleDebuggerConnection(socket, pageId); 163033ea1fcSCedric van Putten } 1645234fe38SCedric van Putten 1655234fe38SCedric van Putten socket.on('close', () => { 1665234fe38SCedric van Putten debug('Debugger disconnected: device=%s, app=%s', device._name, device._app); 1675234fe38SCedric van Putten }); 1685234fe38SCedric van Putten } catch (error: unknown) { 1695234fe38SCedric van Putten let message = ''; 1705234fe38SCedric van Putten 1715234fe38SCedric van Putten debug('Could not establish a connection to debugger:', error); 1725234fe38SCedric van Putten 1735234fe38SCedric van Putten if (error instanceof Error) { 1745234fe38SCedric van Putten message = error.toString(); 1755234fe38SCedric van Putten Log.error('Failed to create a socket connection to the debugger.'); 1765234fe38SCedric van Putten Log.exception(error); 1775234fe38SCedric van Putten } else { 1785234fe38SCedric van Putten Log.error('Failed to create a socket connection to the debugger, unkown error.'); 1795234fe38SCedric van Putten } 1805234fe38SCedric van Putten 1815234fe38SCedric van Putten socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error'); 1825234fe38SCedric van Putten } 1835234fe38SCedric van Putten }); 1845234fe38SCedric van Putten 1855234fe38SCedric van Putten return wss; 1865234fe38SCedric van Putten } 1875234fe38SCedric van Putten} 1885234fe38SCedric van Putten 1895234fe38SCedric van Puttenfunction asString(value: string | string[] = ''): string { 1905234fe38SCedric van Putten return Array.isArray(value) ? value.join() : value; 1915234fe38SCedric van Putten} 1925234fe38SCedric van Putten 193033ea1fcSCedric van Puttenfunction getDeviceInfo(url: IncomingMessage['url']) { 1945234fe38SCedric van Putten const { query } = parse(url ?? '', true); 1955234fe38SCedric van Putten return { 196f5fa30f6SCedric van Putten deviceId: asString(query.device) || undefined, 1975234fe38SCedric van Putten deviceName: asString(query.name) || 'Unknown device name', 1985234fe38SCedric van Putten appName: asString(query.app) || 'Unknown app name', 1995234fe38SCedric van Putten }; 2005234fe38SCedric van Putten} 2015234fe38SCedric van Putten 202033ea1fcSCedric van Puttenfunction getDebuggerInfo(url: IncomingMessage['url']) { 2035234fe38SCedric van Putten const { query } = parse(url ?? '', true); 2045234fe38SCedric van Putten return { 2055234fe38SCedric van Putten deviceId: asString(query.device), 2065234fe38SCedric van Putten pageId: asString(query.page), 207033ea1fcSCedric van Putten debuggerType: asString(query.type) ?? undefined, 2085234fe38SCedric van Putten }; 2095234fe38SCedric van Putten} 210