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   * Normalize the server address for clients to connect to.
35   * @param addressInfo the server address returned by `HttpServer.address()` or `HttpsServer.address()`.
36   * @returns "address:port"
37   */
38  public static normalizeServerAddress(addressInfo: ReturnType<HttpServer['address']>): string {
39    if (typeof addressInfo === 'string') {
40      throw new Error(`Inspector proxy could not resolve the server address, got "${addressInfo}"`);
41    } else if (addressInfo === null) {
42      throw new Error(`Inspector proxy could not resolve the server address, got "null"`);
43    }
44
45    let address = addressInfo.address;
46    if (addressInfo.family === 'IPv6') {
47      address = address === '::' ? `[::1]` : `[${address}]`;
48    } else {
49      address = address === '0.0.0.0' ? 'localhost' : address;
50    }
51    return `${address}:${addressInfo.port}`;
52  }
53
54  /** @see https://chromedevtools.github.io/devtools-protocol/#endpoints */
55  public processRequest(req: IncomingMessage, res: ServerResponse, next: (error?: Error) => any) {
56    this.metroProxy.processRequest(req, res, next);
57  }
58
59  public createWebSocketListeners(server: HttpServer | HttpsServer): Record<string, WSServer> {
60    // Initialize the server address from the metro server.
61    // This is required to properly reference sourcemaps for the debugger.
62    this.metroProxy._serverAddressWithPort = ExpoInspectorProxy.normalizeServerAddress(
63      server.address()
64    );
65
66    return {
67      [WS_DEVICE_URL]: this.createDeviceWebSocketServer(),
68      [WS_DEBUGGER_URL]: this.createDebuggerWebSocketServer(),
69    };
70  }
71
72  private createDeviceWebSocketServer() {
73    const wss = new WS.Server({
74      noServer: true,
75      perMessageDeflate: false,
76    });
77
78    // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L157
79    wss.on('connection', (socket, request) => {
80      try {
81        const fallbackDeviceId = String(this.metroProxy._deviceCounter++);
82        const { deviceId: newDeviceId, deviceName, appName } = getDeviceInfo(request.url);
83
84        const deviceId = newDeviceId ?? fallbackDeviceId;
85
86        const oldDevice = this.devices.get(deviceId);
87        const newDevice = new this.DeviceClass(
88          deviceId,
89          deviceName,
90          appName,
91          socket,
92          this.metroProxy._projectRoot
93        );
94
95        if (oldDevice) {
96          debug('Device reconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId);
97          // See: https://github.com/facebook/metro/pull/991
98          // @ts-expect-error - Newly introduced method coming to metro-inspector-proxy soon
99          oldDevice.handleDuplicateDeviceConnection(newDevice);
100        } else {
101          debug('New device connected: device=%s, app=%s, id=%s', deviceName, appName, deviceId);
102        }
103
104        this.devices.set(deviceId, newDevice);
105
106        socket.on('close', () => {
107          if (this.devices.get(deviceId) === newDevice) {
108            this.devices.delete(deviceId);
109            debug('Device disconnected: device=%s, app=%s, id=%s', deviceName, appName, deviceId);
110          }
111        });
112      } catch (error: unknown) {
113        let message = '';
114
115        debug('Could not establish a connection to on-device debugger:', error);
116
117        if (error instanceof Error) {
118          message = error.toString();
119          Log.error('Failed to create a socket connection to on-device debugger (Hermes engine).');
120          Log.exception(error);
121        } else {
122          Log.error(
123            'Failed to create a socket connection to on-device debugger (Hermes engine), unknown error.'
124          );
125        }
126
127        socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error');
128      }
129    });
130
131    return wss;
132  }
133
134  private createDebuggerWebSocketServer() {
135    const wss = new WS.Server({
136      noServer: true,
137      perMessageDeflate: false,
138    });
139
140    // See: https://github.com/facebook/metro/blob/eeb211fdcfdcb9e7f8a51721bd0f48bc7d0d211f/packages/metro-inspector-proxy/src/InspectorProxy.js#L193
141    wss.on('connection', (socket, request) => {
142      try {
143        const { deviceId, pageId, debuggerType } = getDebuggerInfo(request.url);
144        if (!deviceId || !pageId) {
145          // TODO(cedric): change these errors to proper error types
146          throw new Error(`Missing "device" and/or "page" IDs in query parameters`);
147        }
148
149        const device = this.devices.get(deviceId);
150        if (!device) {
151          // TODO(cedric): change these errors to proper error types
152          throw new Error(`Device with ID "${deviceId}" not found.`);
153        }
154
155        debug('New debugger connected: device=%s, app=%s', device._name, device._app);
156
157        // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device
158        if (debuggerType && typeof device.handleDebuggerConnectionWithType === 'function') {
159          // @ts-expect-error The `handleDebuggerConnectionWithType` is part of our device implementation, not Metro's device
160          device.handleDebuggerConnectionWithType(socket, pageId, debuggerType);
161        } else {
162          device.handleDebuggerConnection(socket, pageId);
163        }
164
165        socket.on('close', () => {
166          debug('Debugger disconnected: device=%s, app=%s', device._name, device._app);
167        });
168      } catch (error: unknown) {
169        let message = '';
170
171        debug('Could not establish a connection to debugger:', error);
172
173        if (error instanceof Error) {
174          message = error.toString();
175          Log.error('Failed to create a socket connection to the debugger.');
176          Log.exception(error);
177        } else {
178          Log.error('Failed to create a socket connection to the debugger, unkown error.');
179        }
180
181        socket.close(WS_GENERIC_ERROR_STATUS, message || 'Unknown error');
182      }
183    });
184
185    return wss;
186  }
187}
188
189function asString(value: string | string[] = ''): string {
190  return Array.isArray(value) ? value.join() : value;
191}
192
193function getDeviceInfo(url: IncomingMessage['url']) {
194  const { query } = parse(url ?? '', true);
195  return {
196    deviceId: asString(query.device) || undefined,
197    deviceName: asString(query.name) || 'Unknown device name',
198    appName: asString(query.app) || 'Unknown app name',
199  };
200}
201
202function getDebuggerInfo(url: IncomingMessage['url']) {
203  const { query } = parse(url ?? '', true);
204  return {
205    deviceId: asString(query.device),
206    pageId: asString(query.page),
207    debuggerType: asString(query.type) ?? undefined,
208  };
209}
210