@react-native/dev-middleware
Version:
Dev server middleware for React Native
413 lines (411 loc) • 13.5 kB
JavaScript
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _getBaseUrlFromRequest = _interopRequireDefault(
require("../utils/getBaseUrlFromRequest")
);
var _Device = _interopRequireDefault(require("./Device"));
var _nullthrows = _interopRequireDefault(require("nullthrows"));
var _timers = require("timers");
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_TIMEOUT_MS = 60000;
const HEARTBEAT_INTERVAL_MS = 10000;
const PROXY_IDLE_TIMEOUT_MS = 10000;
const INTERNAL_ERROR_CODE = 1011;
class InspectorProxy {
#projectRoot;
#serverBaseUrl;
#devices;
#deviceCounter = 0;
#eventReporter;
#experiments;
#customMessageHandler;
#logger;
#lastMessageTimestamp = 0;
constructor(
projectRoot,
serverBaseUrl,
eventReporter,
experiments,
logger,
customMessageHandler
) {
this.#projectRoot = projectRoot;
this.#serverBaseUrl = new URL(serverBaseUrl);
this.#devices = new Map();
this.#eventReporter = eventReporter;
this.#experiments = experiments;
this.#logger = logger;
this.#customMessageHandler = customMessageHandler;
}
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 it's established. If no connection occurs, try to:
- Restart the app
- Ensure a stable connection to the device
- Ensure that the app is built in a mode that supports debugging`,
device.getApp(),
device.getName()
);
}
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 devtoolsFrontendUrl =
`devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&${webSocketScheme}=` +
encodeURIComponent(webSocketUrlWithoutProtocol);
return {
id: `${deviceId}-${page.id}`,
title: page.title,
description: page.description ?? page.app,
appId: page.app,
type: "node",
devtoolsFrontendUrl,
webSocketDebuggerUrl: `${webSocketScheme}://${webSocketUrlWithoutProtocol}`,
...(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);
}
#isIdle() {
return (
new Date().getTime() - this.#lastMessageTimestamp > PROXY_IDLE_TIMEOUT_MS
);
}
#trackLastMessageTimestamp(socket) {
socket.on("message", (message) => {
if (message.toString().includes('"event":"getPages"')) {
return;
}
this.#lastMessageTimestamp = new Date().getTime();
});
}
#createDeviceConnectionWSServer() {
const wss = new _ws.default.Server({
noServer: true,
perMessageDeflate: true,
maxPayload: 0,
});
wss.on("connection", async (socket, req) => {
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,
};
this.#startHeartbeat({
socketName: "Device",
socket,
intervalMs: HEARTBEAT_INTERVAL_MS,
debuggerSessionIDs,
timeoutEventName: "device_timeout",
heartbeatEventName: "device_heartbeat",
});
this.#trackLastMessageTimestamp(socket);
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,
isIdle: this.#isIdle(),
...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 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("Incorrect URL - must provide device and page IDs");
}
if (device == null) {
throw new Error("Unknown device with ID " + deviceId);
}
this.#logger?.info(
"Connection established to DevTools for app='%s' on device='%s'.",
device.getApp() || "unknown",
device.getName() || "unknown"
);
this.#startHeartbeat({
socketName: "DevTools",
socket,
intervalMs: HEARTBEAT_INTERVAL_MS,
debuggerSessionIDs,
timeoutEventName: "debugger_timeout",
heartbeatEventName: "debugger_heartbeat",
});
device.handleDebuggerConnection(socket, pageId, {
debuggerRelativeBaseUrl,
userAgent: req.headers["user-agent"] ?? query.userAgent ?? null,
});
this.#trackLastMessageTimestamp(socket);
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,
isIdle: this.#isIdle(),
...debuggerSessionIDs,
});
});
} catch (error) {
this.#logger?.error(
"Connection failed to be established with DevTools for app='%s' on device='%s' with error:",
device?.getApp() || "unknown",
device?.getName() || "unknown",
error
);
socket.close(INTERNAL_ERROR_CODE, error?.toString() ?? "Unknown error");
this.#eventReporter?.logEvent({
type: "connect_debugger_frontend",
status: "error",
error,
...debuggerSessionIDs,
});
}
});
return wss;
}
#startHeartbeat({
socketName,
socket,
intervalMs,
debuggerSessionIDs,
timeoutEventName,
heartbeatEventName,
}) {
let latestPingMs = Date.now();
let terminateTimeout;
const pingTimeout = (0, _timers.setTimeout)(() => {
if (socket.readyState !== _ws.default.OPEN) {
pingTimeout.refresh();
return;
}
if (!terminateTimeout) {
terminateTimeout = (0, _timers.setTimeout)(() => {
if (socket.readyState !== _ws.default.OPEN) {
terminateTimeout?.refresh();
return;
}
socket.terminate();
const isIdle = this.#isIdle();
this.#logger?.error(
"Connection terminated with %s for app='%s' on device='%s' with idle='%s' after not responding for %s seconds.",
socketName,
debuggerSessionIDs.appId ?? "unknown",
debuggerSessionIDs.deviceName ?? "unknown",
isIdle ? "true" : "false",
String(HEARTBEAT_TIMEOUT_MS / 1000)
);
this.#eventReporter?.logEvent({
type: timeoutEventName,
duration: HEARTBEAT_TIMEOUT_MS,
isIdle,
...debuggerSessionIDs,
});
}, HEARTBEAT_TIMEOUT_MS).unref();
}
latestPingMs = Date.now();
socket.ping();
}, intervalMs).unref();
socket.on("pong", () => {
const roundtripDuration = Date.now() - latestPingMs;
const isIdle = this.#isIdle();
debug(
"[heartbeat ping-pong] [%s] %sms for app='%s' on device='%s' with idle='%s'",
socketName.padStart(7).padEnd(8),
String(roundtripDuration).padStart(5),
debuggerSessionIDs.appId,
debuggerSessionIDs.deviceName,
isIdle ? "true" : "false"
);
this.#eventReporter?.logEvent({
type: heartbeatEventName,
duration: roundtripDuration,
isIdle,
...debuggerSessionIDs,
});
terminateTimeout?.refresh();
pingTimeout.refresh();
});
socket.on("message", () => {
terminateTimeout?.refresh();
});
socket.on("close", (code, reason) => {
terminateTimeout && (0, _timers.clearTimeout)(terminateTimeout);
(0, _timers.clearTimeout)(pingTimeout);
});
}
}
exports.default = InspectorProxy;
;