Files
react-native/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js
T
Alex Hunt fe0dc19131 Align devtoolsFrontendUrl within /json/list (#52289)
Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/52289

The old `devtools://devtools/...` URL is **unsupported** with our modern debugger backend.

This reference was something we'd been intentionally leaving in place to preserve the old experience in Flipper — we can now remove.

Changelog: [Internal]

Reviewed By: motiz88

Differential Revision: D77368319

fbshipit-source-id: 400183e9bc477a887d66d79b412277971cf425e5
2025-06-26 04:20:04 -07:00

634 lines
21 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
import type {DebuggerSessionIDs, EventReporter} from '../types/EventReporter';
import type {Experiments} from '../types/Experiments';
import type {Logger} from '../types/Logger';
import type {CreateCustomMessageHandlerFn} from './CustomMessageHandler';
import type {DeviceOptions} from './Device';
import type {
JsonPagesListResponse,
JsonVersionResponse,
Page,
PageDescription,
} from './types';
import type {IncomingMessage, ServerResponse} from 'http';
import getBaseUrlFromRequest from '../utils/getBaseUrlFromRequest';
import getDevToolsFrontendUrl from '../utils/getDevToolsFrontendUrl';
import Device from './Device';
import EventLoopPerfTracker from './EventLoopPerfTracker';
import InspectorProxyHeartbeat from './InspectorProxyHeartbeat';
import nullthrows from 'nullthrows';
import url from 'url';
import WS from 'ws';
const debug = require('debug')('Metro:InspectorProxy');
const WS_DEVICE_URL = '/inspector/device';
const WS_DEBUGGER_URL = '/inspector/debug';
const PAGES_LIST_JSON_URL = '/json';
const PAGES_LIST_JSON_URL_2 = '/json/list';
const PAGES_LIST_JSON_VERSION_URL = '/json/version';
const HEARTBEAT_TIME_BETWEEN_PINGS_MS = 5000;
const HEARTBEAT_TIMEOUT_MS = 60000;
const MIN_PING_TO_REPORT = 500;
const EVENT_LOOP_PERF_MEASUREMENT_MS = 5000;
const MIN_EVENT_LOOP_DELAY_PERCENT_TO_REPORT = 20;
const INTERNAL_ERROR_CODE = 1011;
// should be aligned with
// https://github.com/facebook/react-native-devtools-frontend/blob/3d17e0fd462dc698db34586697cce2371b25e0d3/front_end/ui/legacy/components/utils/TargetDetachedDialog.ts#L50-L64
const INTERNAL_ERROR_MESSAGES = {
UNREGISTERED_DEVICE:
'[UNREGISTERED_DEVICE] Debugger connection attempted for a device that was not registered',
INCORRECT_URL:
'[INCORRECT_URL] Incorrect URL - device and page IDs must be provided',
};
export type GetPageDescriptionsConfig = {
requestorRelativeBaseUrl: URL,
logNoPagesForConnectedDevice?: boolean,
};
export interface InspectorProxyQueries {
/**
* Returns list of page descriptions ordered by device connection order, then
* page addition order.
*/
getPageDescriptions(
config: GetPageDescriptionsConfig,
): Array<PageDescription>;
}
/**
* Main Inspector Proxy class that connects JavaScript VM inside Android/iOS apps and JS debugger.
*/
export default class InspectorProxy implements InspectorProxyQueries {
// Root of the project used for relative to absolute source path conversion.
#projectRoot: string;
// The base URL to the dev server from the dev-middleware host.
#serverBaseUrl: URL;
// Maps device ID to Device instance.
#devices: Map<string, Device>;
// Internal counter for device IDs -- just gets incremented for each new device.
#deviceCounter: number = 0;
#eventReporter: ?EventReporter;
#experiments: Experiments;
// custom message handler factory allowing implementers to handle unsupported CDP messages.
#customMessageHandler: ?CreateCustomMessageHandlerFn;
#logger: ?Logger;
#lastMessageTimestamp: number | null = null;
#eventLoopPerfTracker: EventLoopPerfTracker;
constructor(
projectRoot: string,
serverBaseUrl: string,
eventReporter: ?EventReporter,
experiments: Experiments,
logger?: Logger,
customMessageHandler: ?CreateCustomMessageHandlerFn,
trackEventLoopPerf?: boolean = false,
) {
this.#projectRoot = projectRoot;
this.#serverBaseUrl = new URL(serverBaseUrl);
this.#devices = new Map();
this.#eventReporter = eventReporter;
this.#experiments = experiments;
this.#logger = logger;
this.#customMessageHandler = customMessageHandler;
if (trackEventLoopPerf) {
this.#eventLoopPerfTracker = new EventLoopPerfTracker({
perfMeasurementDuration: EVENT_LOOP_PERF_MEASUREMENT_MS,
minDelayPercentToReport: MIN_EVENT_LOOP_DELAY_PERCENT_TO_REPORT,
onHighDelay: ({
eventLoopUtilization,
maxEventLoopDelayPercent,
duration,
debuggerSessionIDs,
connectionUptime,
}) => {
debug(
"[perf] high event loop delay in the last %ds- event loop utilization='%d%' max event loop delay percent='%d%'",
duration / 1000,
eventLoopUtilization,
maxEventLoopDelayPercent,
);
this.#eventReporter?.logEvent({
type: 'high_event_loop_delay',
eventLoopUtilization,
maxEventLoopDelayPercent,
duration,
connectionUptime,
...debuggerSessionIDs,
});
},
});
}
}
getPageDescriptions({
requestorRelativeBaseUrl,
logNoPagesForConnectedDevice = false,
}: GetPageDescriptionsConfig): Array<PageDescription> {
// Build list of pages from all devices.
let result: Array<PageDescription> = [];
Array.from(this.#devices.entries()).forEach(([deviceId, device]) => {
const devicePages = device
.getPagesList()
.map((page: Page) =>
this.#buildPageDescription(
deviceId,
device,
page,
requestorRelativeBaseUrl,
),
);
if (
logNoPagesForConnectedDevice &&
devicePages.length === 0 &&
device.dangerouslyGetSocket()?.readyState === WS.OPEN
) {
this.#logger?.warn(
`Waiting for a DevTools connection to app='%s' on device='%s'.
Try again when the main bundle for the app is built and connection is established.
If no connection occurs, try to:
- Restart the app. For Android, force stopping the app first might be required.
- Ensure a stable connection to the device.
- Ensure that the app is built in a mode that supports debugging.
- Take the app out of running in the background.`,
device.getApp(),
device.getName(),
);
this.#eventReporter?.logEvent({
type: 'no_debug_pages_for_device',
appId: device.getApp(),
deviceName: device.getName(),
deviceId: deviceId,
pageId: null,
});
}
result = result.concat(devicePages);
});
return result;
}
// Process HTTP request sent to server. We only respond to 2 HTTP requests:
// 1. /json/version returns Chrome debugger protocol version that we use
// 2. /json and /json/list returns list of page descriptions (list of inspectable apps).
// This list is combined from all the connected devices.
processRequest(
request: IncomingMessage,
response: ServerResponse,
next: (?Error) => mixed,
) {
const pathname = url.parse(request.url).pathname;
if (
pathname === PAGES_LIST_JSON_URL ||
pathname === PAGES_LIST_JSON_URL_2
) {
this.#sendJsonResponse(
response,
this.getPageDescriptions({
requestorRelativeBaseUrl:
getBaseUrlFromRequest(request) ?? this.#serverBaseUrl,
logNoPagesForConnectedDevice: true,
}),
);
} else if (pathname === PAGES_LIST_JSON_VERSION_URL) {
this.#sendJsonResponse(response, {
Browser: 'Mobile JavaScript',
'Protocol-Version': '1.1',
});
} else {
next();
}
}
createWebSocketListeners(): {
[path: string]: WS.Server,
} {
return {
[WS_DEVICE_URL]: this.#createDeviceConnectionWSServer(),
[WS_DEBUGGER_URL]: this.#createDebuggerConnectionWSServer(),
};
}
// Converts page information received from device into PageDescription object
// that is sent to debugger.
#buildPageDescription(
deviceId: string,
device: Device,
page: Page,
requestorRelativeBaseUrl: URL,
): PageDescription {
const {host, protocol} = requestorRelativeBaseUrl;
const webSocketScheme = protocol === 'https:' ? 'wss' : 'ws';
const webSocketUrlWithoutProtocol = `${host}${WS_DEBUGGER_URL}?device=${deviceId}&page=${page.id}`;
const webSocketDebuggerUrl = `${webSocketScheme}://${webSocketUrlWithoutProtocol}`;
const devtoolsFrontendUrl = getDevToolsFrontendUrl(
this.#experiments,
webSocketDebuggerUrl,
this.#serverBaseUrl.origin,
{
relative: true,
useFuseboxEntryPoint: page.capabilities.prefersFuseboxFrontend,
},
);
return {
id: `${deviceId}-${page.id}`,
title: page.title,
description: page.description ?? page.app,
appId: page.app,
type: 'node',
devtoolsFrontendUrl,
webSocketDebuggerUrl,
...(page.vm != null ? {vm: page.vm} : null),
deviceName: device.getName(),
reactNative: {
logicalDeviceId: deviceId,
capabilities: nullthrows(page.capabilities),
},
};
}
// Sends object as response to HTTP request.
// Just serializes object using JSON and sets required headers.
#sendJsonResponse(
response: ServerResponse,
object: JsonPagesListResponse | JsonVersionResponse,
) {
const data = JSON.stringify(object, null, 2);
response.writeHead(200, {
'Content-Type': 'application/json; charset=UTF-8',
'Cache-Control': 'no-cache',
'Content-Length': Buffer.byteLength(data).toString(),
Connection: 'close',
});
response.end(data);
}
#getTimeSinceLastCommunication(): number | null {
const timestamp = this.#lastMessageTimestamp;
return timestamp == null ? null : Date.now() - timestamp;
}
#onMessageFromDeviceOrDebugger(
message: string,
debuggerSessionIDs: DebuggerSessionIDs,
connectionUptime: number,
): void {
// TODO: instead remove this and any other messages in idle state we find
// Not using JSON.parse for performance reasons. Worst case, we'll get
// less accurate idle state reporting, which we would easily see in data.
if (message.includes('"event":"getPages"')) {
return;
}
this.#lastMessageTimestamp = Date.now();
this.#eventLoopPerfTracker?.trackPerfThrottled(
debuggerSessionIDs,
connectionUptime,
);
}
// Adds websocket handler for device connections.
// Device connects to /inspector/device and passes device and app names as
// HTTP GET params.
// For each new websocket connection we parse device and app names and create
// new instance of Device class.
#createDeviceConnectionWSServer(): ws$WebSocketServer {
const wss = new WS.Server({
noServer: true,
perMessageDeflate: true,
// Don't crash on exceptionally large messages - assume the device is
// well-behaved and the debugger is prepared to handle large messages.
maxPayload: 0,
});
// $FlowFixMe[value-as-type]
wss.on('connection', async (socket: WS, req) => {
const wssTimestamp = Date.now();
const fallbackDeviceId = String(this.#deviceCounter++);
const query = url.parse(req.url || '', true).query || {};
const deviceId = query.device || fallbackDeviceId;
const deviceName = query.name || 'Unknown';
const appName = query.app || 'Unknown';
const isProfilingBuild = query.profiling === 'true';
try {
const deviceRelativeBaseUrl =
getBaseUrlFromRequest(req) ?? this.#serverBaseUrl;
const oldDevice = this.#devices.get(deviceId);
let newDevice;
const deviceOptions: DeviceOptions = {
id: deviceId,
name: deviceName,
app: appName,
socket,
projectRoot: this.#projectRoot,
eventReporter: this.#eventReporter,
createMessageMiddleware: this.#customMessageHandler,
deviceRelativeBaseUrl,
serverRelativeBaseUrl: this.#serverBaseUrl,
isProfilingBuild,
};
if (oldDevice) {
oldDevice.dangerouslyRecreateDevice(deviceOptions);
newDevice = oldDevice;
} else {
newDevice = new Device(deviceOptions);
}
this.#devices.set(deviceId, newDevice);
this.#logger?.info(
"Connection established to app='%s' on device='%s'.",
appName,
deviceName,
);
debug(
"Got new device connection: name='%s', app=%s, device=%s, via=%s",
deviceName,
appName,
deviceId,
deviceRelativeBaseUrl.origin,
);
const debuggerSessionIDs: DebuggerSessionIDs = {
appId: newDevice?.getApp() || null,
deviceId,
deviceName: newDevice?.getName() || null,
pageId: null,
};
const heartbeat = new InspectorProxyHeartbeat({
socket,
timeBetweenPings: HEARTBEAT_TIME_BETWEEN_PINGS_MS,
minHighPingToReport: MIN_PING_TO_REPORT,
timeoutMs: HEARTBEAT_TIMEOUT_MS,
onHighPing: roundtripDuration => {
debug(
"[high ping] [ Device ] %sms for app='%s' on device='%s'",
String(roundtripDuration).padStart(5),
debuggerSessionIDs.appId,
debuggerSessionIDs.deviceName,
);
this.#eventReporter?.logEvent({
type: 'device_high_ping',
duration: roundtripDuration,
timeSinceLastCommunication: this.#getTimeSinceLastCommunication(),
connectionUptime: Date.now() - wssTimestamp,
...debuggerSessionIDs,
});
},
onTimeout: roundtripDuration => {
// We don't use close() here because that initiates a closing handshake,
// which will not complete if the other end has gone away - 'close'
// would not be emitted.
// terminate() emits 'close' immediately, allowing us to handle it and
// inform any clients.
socket.terminate();
this.#logger?.error(
"[timeout] connection terminated with Device for app='%s' on device='%s' after not responding for %s seconds.",
debuggerSessionIDs.appId ?? 'unknown',
debuggerSessionIDs.deviceName ?? 'unknown',
String(roundtripDuration / 1000),
);
this.#eventReporter?.logEvent({
type: 'device_timeout',
duration: roundtripDuration,
timeSinceLastCommunication: this.#getTimeSinceLastCommunication(),
connectionUptime: Date.now() - wssTimestamp,
...debuggerSessionIDs,
});
},
});
heartbeat.start();
socket.on('message', message =>
this.#onMessageFromDeviceOrDebugger(
message.toString(),
debuggerSessionIDs,
Date.now() - wssTimestamp,
),
);
socket.on('close', (code: number, reason: string) => {
this.#logger?.info(
"Connection closed to device='%s' for app='%s' with code='%s' and reason='%s'.",
deviceName,
appName,
String(code),
reason,
);
this.#eventReporter?.logEvent({
type: 'device_connection_closed',
code,
reason,
timeSinceLastCommunication: this.#getTimeSinceLastCommunication(),
connectionUptime: Date.now() - wssTimestamp,
...debuggerSessionIDs,
});
if (this.#devices.get(deviceId)?.dangerouslyGetSocket() === socket) {
this.#devices.delete(deviceId);
}
});
} catch (error) {
this.#logger?.error(
"Connection failed to be established with app='%s' on device='%s' with error:",
appName,
deviceName,
error,
);
socket.close(INTERNAL_ERROR_CODE, error?.toString() ?? 'Unknown error');
}
});
return wss;
}
// Returns websocket handler for debugger connections.
// Debugger connects to webSocketDebuggerUrl that we return as part of page description
// in /json response.
// When debugger connects we try to parse device and page IDs from the query and pass
// websocket object to corresponding Device instance.
#createDebuggerConnectionWSServer(): ws$WebSocketServer {
const wss = new WS.Server({
noServer: true,
perMessageDeflate: false,
// Don't crash on exceptionally large messages - assume the debugger is
// well-behaved and the device is prepared to handle large messages.
maxPayload: 0,
});
// $FlowFixMe[value-as-type]
wss.on('connection', async (socket: WS, req) => {
const wssTimestamp = Date.now();
const query = url.parse(req.url || '', true).query || {};
const deviceId = query.device;
const pageId = query.page;
const debuggerRelativeBaseUrl =
getBaseUrlFromRequest(req) ?? this.#serverBaseUrl;
const device: Device | void = deviceId
? this.#devices.get(deviceId)
: undefined;
const debuggerSessionIDs: DebuggerSessionIDs = {
appId: device?.getApp() || null,
deviceId,
deviceName: device?.getName() || null,
pageId,
};
try {
if (deviceId == null || pageId == null) {
throw new Error(INTERNAL_ERROR_MESSAGES.INCORRECT_URL);
}
if (device == null) {
throw new Error(INTERNAL_ERROR_MESSAGES.UNREGISTERED_DEVICE);
}
this.#logger?.info(
"Connection established to DevTools for app='%s' on device='%s'.",
device.getApp() || 'unknown',
device.getName() || 'unknown',
);
const heartbeat = new InspectorProxyHeartbeat({
socket,
timeBetweenPings: HEARTBEAT_TIME_BETWEEN_PINGS_MS,
minHighPingToReport: MIN_PING_TO_REPORT,
timeoutMs: HEARTBEAT_TIMEOUT_MS,
onHighPing: roundtripDuration => {
debug(
"[high ping] [DevTools] %sms for app='%s' on device='%s'",
String(roundtripDuration).padStart(5),
debuggerSessionIDs.appId,
debuggerSessionIDs.deviceName,
);
this.#eventReporter?.logEvent({
type: 'debugger_high_ping',
duration: roundtripDuration,
timeSinceLastCommunication: this.#getTimeSinceLastCommunication(),
connectionUptime: Date.now() - wssTimestamp,
...debuggerSessionIDs,
});
},
onTimeout: roundtripDuration => {
// We don't use close() here because that initiates a closing handshake,
// which will not complete if the other end has gone away - 'close'
// would not be emitted.
// terminate() emits 'close' immediately, allowing us to handle it and
// inform any clients.
socket.terminate();
this.#logger?.error(
"[timeout] connection terminated with DevTools for app='%s' on device='%s' after not responding for %s seconds.",
debuggerSessionIDs.appId ?? 'unknown',
debuggerSessionIDs.deviceName ?? 'unknown',
String(roundtripDuration / 1000),
);
this.#eventReporter?.logEvent({
type: 'debugger_timeout',
duration: roundtripDuration,
timeSinceLastCommunication: this.#getTimeSinceLastCommunication(),
connectionUptime: Date.now() - wssTimestamp,
...debuggerSessionIDs,
});
},
});
heartbeat.start();
socket.on('message', message =>
this.#onMessageFromDeviceOrDebugger(
message.toString(),
debuggerSessionIDs,
Date.now() - wssTimestamp,
),
);
device.handleDebuggerConnection(socket, pageId, {
debuggerRelativeBaseUrl,
userAgent: req.headers['user-agent'] ?? query.userAgent ?? null,
});
socket.on('close', (code: number, reason: string) => {
this.#logger?.info(
"Connection closed to DevTools for app='%s' on device='%s' with code='%s' and reason='%s'.",
device.getApp() || 'unknown',
device.getName() || 'unknown',
String(code),
reason,
);
this.#eventReporter?.logEvent({
type: 'debugger_connection_closed',
code,
reason,
timeSinceLastCommunication: this.#getTimeSinceLastCommunication(),
connectionUptime: Date.now() - wssTimestamp,
...debuggerSessionIDs,
});
});
} catch (error) {
this.#logger?.error(
"Connection failed to be established with DevTools for app='%s' on device='%s' and device id='%s' with error:",
device?.getApp() || 'unknown',
device?.getName() || 'unknown',
deviceId,
error,
);
socket.close(INTERNAL_ERROR_CODE, error?.toString() ?? 'Unknown error');
this.#eventReporter?.logEvent({
type: 'connect_debugger_frontend',
status: 'error',
error,
...debuggerSessionIDs,
});
}
});
return wss;
}
}