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