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