@react-native/dev-middleware
Version:
Dev server middleware for React Native
487 lines (485 loc) • 16.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _getBaseUrlFromRequest = _interopRequireDefault(
require("../utils/getBaseUrlFromRequest")
);
var _getDevToolsFrontendUrl = _interopRequireDefault(
require("../utils/getDevToolsFrontendUrl")
);
var _Device = _interopRequireDefault(require("./Device"));
var _EventLoopPerfTracker = _interopRequireDefault(
require("./EventLoopPerfTracker")
);
var _InspectorProxyHeartbeat = _interopRequireDefault(
require("./InspectorProxyHeartbeat")
);
var _nullthrows = _interopRequireDefault(require("nullthrows"));
var _url = _interopRequireDefault(require("url"));
var _ws = _interopRequireDefault(require("ws"));
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
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;
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",
};
class InspectorProxy {
#projectRoot;
#serverBaseUrl;
#devices;
#deviceCounter = 0;
#eventReporter;
#experiments;
#customMessageHandler;
#logger;
#lastMessageTimestamp = null;
#eventLoopPerfTracker;
constructor(
projectRoot,
serverBaseUrl,
eventReporter,
experiments,
logger,
customMessageHandler,
trackEventLoopPerf = 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.default({
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,
}) {
let result = [];
Array.from(this.#devices.entries()).forEach(([deviceId, device]) => {
const devicePages = device
.getPagesList()
.map((page) =>
this.#buildPageDescription(
deviceId,
device,
page,
requestorRelativeBaseUrl
)
);
if (
logNoPagesForConnectedDevice &&
devicePages.length === 0 &&
device.dangerouslyGetSocket()?.readyState === _ws.default.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;
}
processRequest(request, response, next) {
const pathname = _url.default.parse(request.url).pathname;
if (
pathname === PAGES_LIST_JSON_URL ||
pathname === PAGES_LIST_JSON_URL_2
) {
this.#sendJsonResponse(
response,
this.getPageDescriptions({
requestorRelativeBaseUrl:
(0, _getBaseUrlFromRequest.default)(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() {
return {
[WS_DEVICE_URL]: this.#createDeviceConnectionWSServer(),
[WS_DEBUGGER_URL]: this.#createDebuggerConnectionWSServer(),
};
}
#buildPageDescription(deviceId, device, page, requestorRelativeBaseUrl) {
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 = (0, _getDevToolsFrontendUrl.default)(
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: (0, _nullthrows.default)(page.capabilities),
},
};
}
#sendJsonResponse(response, object) {
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() {
const timestamp = this.#lastMessageTimestamp;
return timestamp == null ? null : Date.now() - timestamp;
}
#onMessageFromDeviceOrDebugger(
message,
debuggerSessionIDs,
connectionUptime
) {
if (message.includes('"event":"getPages"')) {
return;
}
this.#lastMessageTimestamp = Date.now();
this.#eventLoopPerfTracker?.trackPerfThrottled(
debuggerSessionIDs,
connectionUptime
);
}
#createDeviceConnectionWSServer() {
const wss = new _ws.default.Server({
noServer: true,
perMessageDeflate: true,
maxPayload: 0,
});
wss.on("connection", async (socket, req) => {
const wssTimestamp = Date.now();
const fallbackDeviceId = String(this.#deviceCounter++);
const query = _url.default.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 =
(0, _getBaseUrlFromRequest.default)(req) ?? this.#serverBaseUrl;
const oldDevice = this.#devices.get(deviceId);
let newDevice;
const 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.default(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 = {
appId: newDevice?.getApp() || null,
deviceId,
deviceName: newDevice?.getName() || null,
pageId: null,
};
const heartbeat = new _InspectorProxyHeartbeat.default({
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) => {
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, reason) => {
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;
}
#createDebuggerConnectionWSServer() {
const wss = new _ws.default.Server({
noServer: true,
perMessageDeflate: false,
maxPayload: 0,
});
wss.on("connection", async (socket, req) => {
const wssTimestamp = Date.now();
const query = _url.default.parse(req.url || "", true).query || {};
const deviceId = query.device;
const pageId = query.page;
const debuggerRelativeBaseUrl =
(0, _getBaseUrlFromRequest.default)(req) ?? this.#serverBaseUrl;
const device = deviceId ? this.#devices.get(deviceId) : undefined;
const 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.default({
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) => {
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, reason) => {
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;
}
}
exports.default = InspectorProxy;