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