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