1import type { DebuggerInfo, Device as MetroDevice } from 'metro-inspector-proxy';
2import fetch from 'node-fetch';
3import type WS from 'ws';
4
5import { NetworkResponseHandler } from './handlers/NetworkResponse';
6import { PageReloadHandler } from './handlers/PageReload';
7import { VscodeDebuggerGetPossibleBreakpointsHandler } from './handlers/VscodeDebuggerGetPossibleBreakpoints';
8import { VscodeDebuggerScriptParsedHandler } from './handlers/VscodeDebuggerScriptParsed';
9import { VscodeDebuggerSetBreakpointByUrlHandler } from './handlers/VscodeDebuggerSetBreakpointByUrl';
10import { VscodeRuntimeGetPropertiesHandler } from './handlers/VscodeRuntimeGetProperties';
11import { DeviceRequest, InspectorHandler, DebuggerRequest } from './handlers/types';
12import { MetroBundlerDevServer } from '../MetroBundlerDevServer';
13
14/** Export the supported debugger types this inspector proxy can handle */
15export type DebuggerType = 'vscode' | 'generic';
16
17/** The debugger information being tracked by this device class */
18export type ExpoDebuggerInfo = DebuggerInfo & { debuggerType?: DebuggerType };
19
20export function createInspectorDeviceClass(
21  metroBundler: MetroBundlerDevServer,
22  MetroDeviceClass: typeof MetroDevice
23) {
24  return class ExpoInspectorDevice extends MetroDeviceClass implements InspectorHandler {
25    /** Stores information about currently connected debugger (if any). */
26    _debuggerConnection: ExpoDebuggerInfo | null = null;
27
28    /** All handlers that should be used to intercept or reply to CDP events */
29    public handlers: InspectorHandler[] = [
30      // Generic handlers
31      new NetworkResponseHandler(),
32      new PageReloadHandler(metroBundler),
33      // Vscode-specific handlers
34      new VscodeDebuggerGetPossibleBreakpointsHandler(),
35      new VscodeDebuggerScriptParsedHandler(this),
36      new VscodeDebuggerSetBreakpointByUrlHandler(),
37      new VscodeRuntimeGetPropertiesHandler(),
38    ];
39
40    onDeviceMessage(message: any, info: DebuggerInfo): boolean {
41      return this.handlers.some((handler) => handler.onDeviceMessage?.(message, info) ?? false);
42    }
43
44    onDebuggerMessage(message: any, info: DebuggerInfo): boolean {
45      return this.handlers.some((handler) => handler.onDebuggerMessage?.(message, info) ?? false);
46    }
47
48    /**
49     * Handle a new device connection with the same device identifier.
50     * When the app and device name matches, we can reuse the debugger connection.
51     * Else, we have to shut the debugger connection down.
52     */
53    handleDuplicateDeviceConnection(newDevice: InstanceType<typeof MetroDeviceClass>) {
54      if (this._app !== newDevice._app || this._name !== newDevice._name) {
55        this._deviceSocket.close();
56        this._debuggerConnection?.socket.close();
57        return;
58      }
59
60      const oldDebugger = this._debuggerConnection;
61      this._debuggerConnection = null;
62
63      if (oldDebugger) {
64        oldDebugger.socket.removeAllListeners();
65        this._deviceSocket.close();
66        newDevice.handleDebuggerConnection(oldDebugger.socket, oldDebugger.pageId);
67      }
68    }
69
70    /**
71     * Handle a new debugger connection to this device.
72     * This adds the `debuggerType` property to the `DebuggerInfo` object.
73     * With that information, we can enable or disable debugger-specific handlers.
74     */
75    handleDebuggerConnectionWithType(socket: WS, pageId: string, debuggerType: DebuggerType): void {
76      this.handleDebuggerConnection(socket, pageId);
77
78      if (this._debuggerConnection) {
79        this._debuggerConnection.debuggerType = debuggerType;
80      }
81    }
82
83    /** Hook into the message life cycle to answer more complex CDP messages */
84    async _processMessageFromDevice(message: DeviceRequest<any>, info: DebuggerInfo) {
85      if (!this.onDeviceMessage(message, info)) {
86        await super._processMessageFromDevice(message, info);
87      }
88    }
89
90    /** Hook into the message life cycle to answer more complex CDP messages */
91    _interceptMessageFromDebugger(
92      request: DebuggerRequest,
93      info: DebuggerInfo,
94      socket: WS
95    ): boolean {
96      // Note, `socket` is the exact same as `info.socket`
97      if (this.onDebuggerMessage(request, info)) {
98        return true;
99      }
100
101      return super._interceptMessageFromDebugger(request, info, socket);
102    }
103
104    /**
105     * Overwrite the default text fetcher, to load sourcemaps from sources other than `localhost`.
106     * @todo Cedric: remove the custom `DebuggerScriptSource` handler when switching over to `metro@>=0.75.1`
107     * @see https://github.com/facebook/metro/blob/77f445f1bcd2264ad06174dbf8d542bc75834d29/packages/metro-inspector-proxy/src/Device.js#L573-L588
108     * @since [email protected]
109     */
110    async _fetchText(url: URL): Promise<string> {
111      const LENGTH_LIMIT_BYTES = 350_000_000; // 350mb
112
113      const response = await fetch(url);
114      if (!response.ok) {
115        throw new Error(`Received status ${response.status} while fetching: ${url}`);
116      }
117
118      const contentLength = response.headers.get('Content-Length');
119      if (contentLength && Number(contentLength) > LENGTH_LIMIT_BYTES) {
120        throw new Error('Expected file size is too large (more than 350mb)');
121      }
122
123      const text = await response.text();
124      if (Buffer.byteLength(text, 'utf8') > LENGTH_LIMIT_BYTES) {
125        throw new Error('File size is too large (more than 350mb)');
126      }
127
128      return text;
129    }
130  };
131}
132