FRE-600: Fix code review blockers

- Consolidated duplicate UndoManagers to single instance
- Fixed connection promise to only resolve on 'connected' status
- Fixed WebSocketProvider import (WebsocketProvider)
- Added proper doc.destroy() cleanup
- Renamed isPresenceInitialized property to avoid conflict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 00:08:01 -04:00
parent 65b552bb08
commit 7c684a42cc
48450 changed files with 5679671 additions and 383 deletions

View File

@@ -0,0 +1,71 @@
/**
* 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.
*
*
* @format
*/
import type { CreateCustomMessageHandlerFn } from "./inspector-proxy/CustomMessageHandler";
import type { DevToolLauncher } from "./types/DevToolLauncher";
import type { EventReporter } from "./types/EventReporter";
import type { ExperimentsConfig } from "./types/Experiments";
import type { Logger } from "./types/Logger";
import type { ReadonlyURL } from "./types/ReadonlyURL";
import type { NextHandleFunction } from "connect";
type Options = Readonly<{
/**
* The base URL to the dev server, as reachable from the machine on which
* dev-middleware is hosted. Typically `http://localhost:${metroPort}`.
*/
serverBaseUrl: string | ReadonlyURL;
/**
* An implementation for logging messages to the terminal (recommended).
*
* In `@react-native/community-cli-plugin`, this reuses Metro's
* 'unstable_server_log' event in `TerminalReporter`.
*/
logger?: Logger;
/**
* An `EventReporter` implementation for logging structured events
* (recommended).
*
* This is an unstable API with no semver guarantees.
*/
unstable_eventReporter?: EventReporter;
/**
* The set of experimental features to enable.
*
* This is an unstable API with no semver guarantees.
*/
unstable_experiments?: ExperimentsConfig;
/**
* Override the default handlers for launching external applications (the
* debugger frontend) on the host machine (or target dev machine).
*
* This is an unstable API with no semver guarantees.
*/
unstable_toolLauncher?: DevToolLauncher;
/**
* Create custom handler to add support for unsupported CDP events, or debuggers.
* This handler is instantiated per logical device and debugger pair.
*
* This is an unstable API with no semver guarantees.
*/
unstable_customInspectorMessageHandler?: CreateCustomMessageHandlerFn;
/**
* Whether to measure the event loop performance of inspector proxy and
* report it via the event reporter.
*
* This is an unstable API with no semver guarantees.
*/
unstable_trackInspectorProxyEventLoopPerf?: boolean;
}>;
type DevMiddlewareAPI = Readonly<{
middleware: NextHandleFunction;
websocketEndpoints: { [path: string]: ws$WebSocketServer };
}>;
declare function createDevMiddleware($$PARAM_0$$: Options): DevMiddlewareAPI;
export default createDevMiddleware;

View File

@@ -0,0 +1,130 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = createDevMiddleware;
var _InspectorProxy = _interopRequireDefault(
require("./inspector-proxy/InspectorProxy"),
);
var _openDebuggerMiddleware = _interopRequireDefault(
require("./middleware/openDebuggerMiddleware"),
);
var _DefaultToolLauncher = _interopRequireDefault(
require("./utils/DefaultToolLauncher"),
);
var _debuggerFrontend = _interopRequireDefault(
require("@react-native/debugger-frontend"),
);
var _connect = _interopRequireDefault(require("connect"));
var _path = _interopRequireDefault(require("path"));
var _serveStatic = _interopRequireDefault(require("serve-static"));
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
function createDevMiddleware({
serverBaseUrl,
logger,
unstable_eventReporter,
unstable_experiments: experimentConfig = {},
unstable_toolLauncher = _DefaultToolLauncher.default,
unstable_customInspectorMessageHandler,
unstable_trackInspectorProxyEventLoopPerf = false,
}) {
const normalizedServerBaseUrl = new URL(serverBaseUrl);
const experiments = getExperiments(experimentConfig);
const eventReporter = createWrappedEventReporter(
unstable_eventReporter,
logger,
experiments,
);
const inspectorProxy = new _InspectorProxy.default(
normalizedServerBaseUrl,
eventReporter,
experiments,
logger,
unstable_customInspectorMessageHandler,
unstable_trackInspectorProxyEventLoopPerf,
);
const middleware = (0, _connect.default)()
.use(
"/open-debugger",
(0, _openDebuggerMiddleware.default)({
serverBaseUrl: normalizedServerBaseUrl,
inspectorProxy,
toolLauncher: unstable_toolLauncher,
eventReporter,
experiments,
logger,
}),
)
.use(
"/debugger-frontend/embedder-static/embedderScript.js",
(_req, res) => {
res.setHeader("Content-Type", "application/javascript");
res.end("");
},
)
.use(
"/debugger-frontend",
(0, _serveStatic.default)(_path.default.join(_debuggerFrontend.default), {
fallthrough: false,
}),
)
.use((...args) => inspectorProxy.processRequest(...args));
return {
middleware,
websocketEndpoints: inspectorProxy.createWebSocketListeners(),
};
}
function getExperiments(config) {
return {
enableOpenDebuggerRedirect: config.enableOpenDebuggerRedirect ?? false,
enableNetworkInspector: config.enableNetworkInspector ?? false,
enableStandaloneFuseboxShell: config.enableStandaloneFuseboxShell ?? true,
};
}
function createWrappedEventReporter(reporter, logger, experiments) {
return {
logEvent(event) {
switch (event.type) {
case "profiling_target_registered":
logger?.info(
"Profiling build target '%s' registered for debugging",
event.appId ?? "unknown",
);
break;
case "fusebox_shell_preparation_attempt":
switch (event.result.code) {
case "success":
case "not_implemented":
break;
case "unexpected_error": {
let message =
event.result.humanReadableMessage ??
"An unknown error occurred while installing React Native DevTools.";
if (event.result.verboseInfo != null) {
message += ` Details:\n\n${event.result.verboseInfo}`;
} else {
message += ".";
}
logger?.error(message);
break;
}
case "possible_corruption":
case "platform_not_supported":
case "likely_offline":
logger?.warn(
event.result.humanReadableMessage ??
`An error of type ${event.result.code} occurred while installing React Native DevTools.`,
);
break;
default:
event.result.code;
break;
}
}
reporter?.logEvent(event);
},
};
}

View File

@@ -0,0 +1,81 @@
/**
* 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 { CreateCustomMessageHandlerFn } from "./inspector-proxy/CustomMessageHandler";
import type { DevToolLauncher } from "./types/DevToolLauncher";
import type { EventReporter } from "./types/EventReporter";
import type { ExperimentsConfig } from "./types/Experiments";
import type { Logger } from "./types/Logger";
import type { ReadonlyURL } from "./types/ReadonlyURL";
import type { NextHandleFunction } from "connect";
type Options = Readonly<{
/**
* The base URL to the dev server, as reachable from the machine on which
* dev-middleware is hosted. Typically `http://localhost:${metroPort}`.
*/
serverBaseUrl: string | ReadonlyURL,
/**
* An implementation for logging messages to the terminal (recommended).
*
* In `@react-native/community-cli-plugin`, this reuses Metro's
* 'unstable_server_log' event in `TerminalReporter`.
*/
logger?: Logger,
/**
* An `EventReporter` implementation for logging structured events
* (recommended).
*
* This is an unstable API with no semver guarantees.
*/
unstable_eventReporter?: EventReporter,
/**
* The set of experimental features to enable.
*
* This is an unstable API with no semver guarantees.
*/
unstable_experiments?: ExperimentsConfig,
/**
* Override the default handlers for launching external applications (the
* debugger frontend) on the host machine (or target dev machine).
*
* This is an unstable API with no semver guarantees.
*/
unstable_toolLauncher?: DevToolLauncher,
/**
* Create custom handler to add support for unsupported CDP events, or debuggers.
* This handler is instantiated per logical device and debugger pair.
*
* This is an unstable API with no semver guarantees.
*/
unstable_customInspectorMessageHandler?: CreateCustomMessageHandlerFn,
/**
* Whether to measure the event loop performance of inspector proxy and
* report it via the event reporter.
*
* This is an unstable API with no semver guarantees.
*/
unstable_trackInspectorProxyEventLoopPerf?: boolean,
}>;
type DevMiddlewareAPI = Readonly<{
middleware: NextHandleFunction,
websocketEndpoints: { [path: string]: ws$WebSocketServer },
}>;
declare export default function createDevMiddleware(
$$PARAM_0$$: Options,
): DevMiddlewareAPI;

View File

@@ -0,0 +1,24 @@
/**
* 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.
*
*
* @format
*/
export type {
DevToolLauncher,
DebuggerShellPreparationResult,
} from "./types/DevToolLauncher";
export type { EventReporter, ReportableEvent } from "./types/EventReporter";
export type {
CustomMessageHandler,
CustomMessageHandlerConnection,
CreateCustomMessageHandlerFn,
} from "./inspector-proxy/CustomMessageHandler";
export type { Logger } from "./types/Logger";
export type { ReadonlyURL } from "./types/ReadonlyURL";
export { default as unstable_DefaultToolLauncher } from "./utils/DefaultToolLauncher";
export { default as createDevMiddleware } from "./createDevMiddleware";

View File

@@ -0,0 +1,26 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
Object.defineProperty(exports, "createDevMiddleware", {
enumerable: true,
get: function () {
return _createDevMiddleware.default;
},
});
Object.defineProperty(exports, "unstable_DefaultToolLauncher", {
enumerable: true,
get: function () {
return _DefaultToolLauncher.default;
},
});
var _DefaultToolLauncher = _interopRequireDefault(
require("./utils/DefaultToolLauncher"),
);
var _createDevMiddleware = _interopRequireDefault(
require("./createDevMiddleware"),
);
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}

View File

@@ -0,0 +1,25 @@
/**
* 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
*/
export type {
DevToolLauncher,
DebuggerShellPreparationResult,
} from "./types/DevToolLauncher";
export type { EventReporter, ReportableEvent } from "./types/EventReporter";
export type {
CustomMessageHandler,
CustomMessageHandlerConnection,
CreateCustomMessageHandlerFn,
} from "./inspector-proxy/CustomMessageHandler";
export type { Logger } from "./types/Logger";
export type { ReadonlyURL } from "./types/ReadonlyURL";
export { default as unstable_DefaultToolLauncher } from "./utils/DefaultToolLauncher";
export { default as createDevMiddleware } from "./createDevMiddleware";

View File

@@ -0,0 +1,20 @@
/**
* 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.
*
*
* @format
*/
export type CDPMessageDestination =
| "DebuggerToProxy"
| "ProxyToDebugger"
| "DeviceToProxy"
| "ProxyToDevice";
declare class CdpDebugLogging {
constructor();
log(destination: CDPMessageDestination, message: string): void;
}
export default CdpDebugLogging;

View File

@@ -0,0 +1,117 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _timers = require("timers");
var _util = _interopRequireDefault(require("util"));
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
const debug = require("debug")("Metro:InspectorProxy");
const debugCDPMessages = require("debug")("Metro:InspectorProxyCDPMessages");
const CDP_MESSAGES_BATCH_DEBUGGING_THROTTLE_MS = 5000;
function getCDPLogPrefix(destination) {
return _util.default.format(
"[(Debugger) %s (Proxy) %s (Device)]",
destination === "DebuggerToProxy"
? "->"
: destination === "ProxyToDebugger"
? "<-"
: " ",
destination === "ProxyToDevice"
? "->"
: destination === "DeviceToProxy"
? "<-"
: " ",
);
}
class CdpDebugLogging {
#cdpMessagesLoggingBatchingFn = {
DebuggerToProxy: () => {},
ProxyToDebugger: () => {},
DeviceToProxy: () => {},
ProxyToDevice: () => {},
};
constructor() {
if (debug.enabled) {
this.#initializeThrottledCDPMessageLogging();
}
}
#initializeThrottledCDPMessageLogging() {
const batchingCounters = {
DebuggerToProxy: {
count: 0,
size: 0,
},
ProxyToDebugger: {
count: 0,
size: 0,
},
DeviceToProxy: {
count: 0,
size: 0,
},
ProxyToDevice: {
count: 0,
size: 0,
},
};
Object.keys(batchingCounters).forEach((destination) => {
let timeout = null;
this.#cdpMessagesLoggingBatchingFn[destination] = (message) => {
if (message.length > 1024 * 100) {
const messagePreview = JSON.stringify(
JSON.parse(message, (key, value) => {
if (Array.isArray(value)) {
return "[ARRAY]";
}
if (typeof value === "string" && value.length > 50) {
return value.slice(0, 50) + "...";
}
return value;
}),
null,
2,
);
debug(
"%s A large message (%s MB) was %s- %s",
getCDPLogPrefix(destination),
(message.length / (1024 * 1024)).toFixed(2),
destination.startsWith("Proxy") ? " sent " : "received",
messagePreview,
);
}
if (timeout == null) {
timeout = (0, _timers.setTimeout)(() => {
debug(
"%s %s CDP messages of size %s MB %s in the last %ss.",
getCDPLogPrefix(destination),
String(batchingCounters[destination].count).padStart(4),
String(
(batchingCounters[destination].size / (1024 * 1024)).toFixed(2),
).padStart(6),
destination.startsWith("Proxy") ? " sent " : "received",
CDP_MESSAGES_BATCH_DEBUGGING_THROTTLE_MS / 1000,
);
batchingCounters[destination].count = 0;
batchingCounters[destination].size = 0;
timeout = null;
}, CDP_MESSAGES_BATCH_DEBUGGING_THROTTLE_MS).unref();
}
batchingCounters[destination].count++;
batchingCounters[destination].size += message.length;
};
});
}
log(destination, message) {
if (debugCDPMessages.enabled) {
debugCDPMessages("%s message: %s", getCDPLogPrefix(destination), message);
}
if (debug.enabled) {
this.#cdpMessagesLoggingBatchingFn[destination](message);
}
}
}
exports.default = CdpDebugLogging;

View File

@@ -0,0 +1,20 @@
/**
* 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
*/
export type CDPMessageDestination =
| "DebuggerToProxy"
| "ProxyToDebugger"
| "DeviceToProxy"
| "ProxyToDevice";
declare export default class CdpDebugLogging {
constructor(): void;
log(destination: CDPMessageDestination, message: string): void;
}

View File

@@ -0,0 +1,48 @@
/**
* 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.
*
*
* @format
*/
import type { JSONSerializable, Page } from "./types";
type ExposedDevice = Readonly<{
appId: string;
id: string;
name: string;
sendMessage: (message: JSONSerializable) => void;
}>;
type ExposedDebugger = Readonly<{
userAgent: string | null;
sendMessage: (message: JSONSerializable) => void;
}>;
export type CustomMessageHandlerConnection = Readonly<{
page: Page;
device: ExposedDevice;
debugger: ExposedDebugger;
}>;
export type CreateCustomMessageHandlerFn = (
connection: CustomMessageHandlerConnection,
) => null | undefined | CustomMessageHandler;
/**
* The device message middleware allows implementers to handle unsupported CDP messages.
* It is instantiated per device and may contain state that is specific to that device.
* The middleware can also mark messages from the device or debugger as handled, which stops propagating.
*/
export interface CustomMessageHandler {
/**
* Handle a CDP message coming from the device.
* This is invoked before the message is sent to the debugger.
* When returning true, the message is considered handled and will not be sent to the debugger.
*/
handleDeviceMessage(message: JSONSerializable): true | void;
/**
* Handle a CDP message coming from the debugger.
* This is invoked before the message is sent to the device.
* When returning true, the message is considered handled and will not be sent to the device.
*/
handleDebuggerMessage(message: JSONSerializable): true | void;
}

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,54 @@
/**
* 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 { JSONSerializable, Page } from "./types";
type ExposedDevice = Readonly<{
appId: string,
id: string,
name: string,
sendMessage: (message: JSONSerializable) => void,
}>;
type ExposedDebugger = Readonly<{
userAgent: string | null,
sendMessage: (message: JSONSerializable) => void,
}>;
export type CustomMessageHandlerConnection = Readonly<{
page: Page,
device: ExposedDevice,
debugger: ExposedDebugger,
}>;
export type CreateCustomMessageHandlerFn = (
connection: CustomMessageHandlerConnection,
) => ?CustomMessageHandler;
/**
* The device message middleware allows implementers to handle unsupported CDP messages.
* It is instantiated per device and may contain state that is specific to that device.
* The middleware can also mark messages from the device or debugger as handled, which stops propagating.
*/
export interface CustomMessageHandler {
/**
* Handle a CDP message coming from the device.
* This is invoked before the message is sent to the debugger.
* When returning true, the message is considered handled and will not be sent to the debugger.
*/
handleDeviceMessage(message: JSONSerializable): true | void;
/**
* Handle a CDP message coming from the debugger.
* This is invoked before the message is sent to the device.
* When returning true, the message is considered handled and will not be sent to the device.
*/
handleDebuggerMessage(message: JSONSerializable): true | void;
}

View File

@@ -0,0 +1,65 @@
/**
* 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.
*
*
* @format
*/
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { ReadonlyURL } from "../types/ReadonlyURL";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { Page } from "./types";
import WS from "ws";
export declare const WS_CLOSE_REASON: {
PAGE_NOT_FOUND: "[PAGE_NOT_FOUND] Debugger page not found";
CONNECTION_LOST: "[CONNECTION_LOST] Connection lost to corresponding device";
RECREATING_DEVICE: "[RECREATING_DEVICE] Recreating device connection";
NEW_DEBUGGER_OPENED: "[NEW_DEBUGGER_OPENED] New debugger opened for the same app instance";
};
export declare type WS_CLOSE_REASON = typeof WS_CLOSE_REASON;
export type DeviceOptions = Readonly<{
id: string;
name: string;
app: string;
socket: WS;
eventReporter: null | undefined | EventReporter;
createMessageMiddleware: null | undefined | CreateCustomMessageHandlerFn;
deviceRelativeBaseUrl: ReadonlyURL;
serverRelativeBaseUrl: ReadonlyURL;
isProfilingBuild: boolean;
experiments: Experiments;
}>;
/**
* Device class represents single device connection to Inspector Proxy. Each device
* can have multiple inspectable pages.
*/
declare class Device {
constructor(deviceOptions: DeviceOptions);
/**
* Used to recreate the device connection if there is a device ID collision.
* 1. Checks if the same device is attempting to reconnect for the same app.
* 2. If not, close both the device and debugger socket.
* 3. If the debugger connection can be reused, close the device socket only.
*
* This hack attempts to allow users to reload the app, either as result of a
* crash, or manually reloading, without having to restart the debugger.
*/
dangerouslyRecreateDevice(deviceOptions: DeviceOptions): void;
getName(): string;
getApp(): string;
getPagesList(): ReadonlyArray<Page>;
handleDebuggerConnection(
socket: WS,
pageId: string,
$$PARAM_2$$: Readonly<{
debuggerRelativeBaseUrl: ReadonlyURL;
userAgent: string | null;
}>,
): void;
dangerouslyGetSocket(): WS;
}
export default Device;

View File

@@ -0,0 +1,846 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = exports.WS_CLOSE_REASON = void 0;
var _CdpDebugLogging = _interopRequireDefault(require("./CdpDebugLogging"));
var _DeviceEventReporter = _interopRequireDefault(
require("./DeviceEventReporter"),
);
var _crypto = _interopRequireDefault(require("crypto"));
var _invariant = _interopRequireDefault(require("invariant"));
var _ws = _interopRequireDefault(require("ws"));
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
const debug = require("debug")("Metro:InspectorProxy");
const PAGES_POLLING_INTERVAL = 1000;
const WS_CLOSURE_CODE = {
NORMAL: 1000,
INTERNAL_ERROR: 1011,
};
const WS_CLOSE_REASON = (exports.WS_CLOSE_REASON = {
PAGE_NOT_FOUND: "[PAGE_NOT_FOUND] Debugger page not found",
CONNECTION_LOST: "[CONNECTION_LOST] Connection lost to corresponding device",
RECREATING_DEVICE: "[RECREATING_DEVICE] Recreating device connection",
NEW_DEBUGGER_OPENED:
"[NEW_DEBUGGER_OPENED] New debugger opened for the same app instance",
});
const FILE_PREFIX = "file://";
const REACT_NATIVE_RELOADABLE_PAGE_ID = "-1";
class Device {
#id;
#name;
#app;
#deviceSocket;
#pages = new Map();
#debuggerConnections = new Map();
#lastConnectedLegacyReactNativePage = null;
#isLegacyPageReloading = false;
#lastGetPagesMessage = "";
#scriptIdToSourcePathMapping = new Map();
#deviceEventReporter;
#pagesPollingIntervalId;
#createCustomMessageHandler;
#deviceRelativeBaseUrl;
#serverRelativeBaseUrl;
#cdpDebugLogging;
#experiments;
constructor(deviceOptions) {
this.#experiments = deviceOptions.experiments;
this.#dangerouslyConstruct(deviceOptions);
}
#dangerouslyConstruct({
id,
name,
app,
socket,
eventReporter,
createMessageMiddleware,
serverRelativeBaseUrl,
deviceRelativeBaseUrl,
isProfilingBuild,
}) {
this.#cdpDebugLogging = new _CdpDebugLogging.default();
this.#id = id;
this.#name = name;
this.#app = app;
this.#deviceSocket = socket;
this.#serverRelativeBaseUrl = serverRelativeBaseUrl;
this.#deviceRelativeBaseUrl = deviceRelativeBaseUrl;
this.#deviceEventReporter = eventReporter
? new _DeviceEventReporter.default(eventReporter, {
deviceId: id,
deviceName: name,
appId: app,
})
: null;
this.#createCustomMessageHandler = createMessageMiddleware;
if (isProfilingBuild) {
this.#deviceEventReporter?.logProfilingTargetRegistered();
}
this.#deviceSocket.on("message", (message) => {
try {
const parsedMessage = JSON.parse(message);
if (parsedMessage.event === "getPages") {
if (message !== this.#lastGetPagesMessage) {
debug("Device getPages ping has changed: %s", message);
this.#lastGetPagesMessage = message;
}
} else {
this.#cdpDebugLogging.log("DeviceToProxy", message);
}
this.#handleMessageFromDevice(parsedMessage);
} catch (error) {
debug("%O\nHandling device message: %s", error, message);
try {
this.#deviceEventReporter?.logProxyMessageHandlingError(
"device",
error,
message,
);
} catch (loggingError) {
debug(
"Error logging message handling error to reporter: %O",
loggingError,
);
}
}
});
this.#pagesPollingIntervalId = setInterval(
() =>
this.#sendMessageToDevice({
event: "getPages",
}),
PAGES_POLLING_INTERVAL,
);
this.#deviceSocket.on("close", () => {
if (socket === this.#deviceSocket) {
this.#deviceEventReporter?.logDisconnection("device");
this.#terminateDebuggerConnection(
WS_CLOSURE_CODE.NORMAL,
WS_CLOSE_REASON.CONNECTION_LOST,
);
clearInterval(this.#pagesPollingIntervalId);
}
});
}
#terminateDebuggerConnection(code, reason, sessionId) {
if (sessionId != null) {
const debuggerConnection = this.#debuggerConnections.get(sessionId);
if (debuggerConnection) {
this.#debuggerConnections.delete(sessionId);
this.#sendDisconnectEventToDevice(
this.#mapToDevicePageId(debuggerConnection.pageId),
sessionId,
);
debuggerConnection.socket.close(code, reason);
}
} else {
const connections = Array.from(this.#debuggerConnections.entries());
this.#debuggerConnections.clear();
for (const [sid, debuggerConnection] of connections) {
this.#sendDisconnectEventToDevice(
this.#mapToDevicePageId(debuggerConnection.pageId),
sid,
);
debuggerConnection.socket.close(code, reason);
}
}
}
dangerouslyRecreateDevice(deviceOptions) {
(0, _invariant.default)(
deviceOptions.id === this.#id,
"dangerouslyRecreateDevice() can only be used for the same device ID",
);
const oldDebuggerConnections = new Map(this.#debuggerConnections);
if (this.#app !== deviceOptions.app || this.#name !== deviceOptions.name) {
this.#deviceSocket.close(
WS_CLOSURE_CODE.NORMAL,
WS_CLOSE_REASON.RECREATING_DEVICE,
);
this.#terminateDebuggerConnection(
WS_CLOSURE_CODE.NORMAL,
WS_CLOSE_REASON.RECREATING_DEVICE,
);
}
this.#debuggerConnections.clear();
if (oldDebuggerConnections.size > 0) {
this.#deviceSocket.close(
WS_CLOSURE_CODE.NORMAL,
WS_CLOSE_REASON.RECREATING_DEVICE,
);
}
this.#dangerouslyConstruct(deviceOptions);
for (const oldDebugger of oldDebuggerConnections.values()) {
oldDebugger.socket.removeAllListeners();
this.handleDebuggerConnection(oldDebugger.socket, oldDebugger.pageId, {
debuggerRelativeBaseUrl: oldDebugger.debuggerRelativeBaseUrl,
userAgent: oldDebugger.userAgent,
});
}
}
getName() {
return this.#name;
}
getApp() {
return this.#app;
}
getPagesList() {
if (this.#lastConnectedLegacyReactNativePage) {
return [...this.#pages.values(), this.#createSyntheticPage()];
} else {
return [...this.#pages.values()];
}
}
handleDebuggerConnection(
socket,
pageId,
{ debuggerRelativeBaseUrl, userAgent },
) {
const page =
pageId === REACT_NATIVE_RELOADABLE_PAGE_ID
? this.#createSyntheticPage()
: this.#pages.get(pageId);
if (!page) {
debug(
`Got new debugger connection via ${debuggerRelativeBaseUrl.href} for ` +
`page ${pageId} of ${this.#name}, but no such page exists`,
);
socket.close(
WS_CLOSURE_CODE.INTERNAL_ERROR,
WS_CLOSE_REASON.PAGE_NOT_FOUND,
);
return;
}
this.#deviceEventReporter?.logDisconnection("debugger");
if (!this.#pageHasCapability(page, "supportsMultipleDebuggers")) {
for (const [sid, conn] of this.#debuggerConnections) {
if (conn.pageId === pageId) {
this.#terminateDebuggerConnection(
WS_CLOSURE_CODE.NORMAL,
WS_CLOSE_REASON.NEW_DEBUGGER_OPENED,
sid,
);
}
}
}
const sessionId = _crypto.default.randomUUID();
this.#deviceEventReporter?.logConnection("debugger", {
pageId,
frontendUserAgent: userAgent,
});
const debuggerInfo = {
socket,
prependedFilePrefix: false,
pageId,
userAgent: userAgent,
customHandler: null,
debuggerRelativeBaseUrl,
sessionId,
};
this.#debuggerConnections.set(sessionId, debuggerInfo);
debug(
`Got new debugger connection via ${debuggerRelativeBaseUrl.href} for ` +
`page ${pageId} of ${this.#name} with sessionId ${sessionId}`,
);
if (this.#createCustomMessageHandler) {
debuggerInfo.customHandler = this.#createCustomMessageHandler({
page,
debugger: {
userAgent: debuggerInfo.userAgent,
sendMessage: (message) => {
try {
const payload = JSON.stringify(message);
this.#cdpDebugLogging.log("ProxyToDebugger", payload);
socket.send(payload);
} catch {}
},
},
device: {
appId: this.#app,
id: this.#id,
name: this.#name,
sendMessage: (message) => {
try {
const payload = JSON.stringify({
event: "wrappedEvent",
payload: {
pageId: this.#mapToDevicePageId(pageId),
wrappedEvent: JSON.stringify(message),
sessionId,
},
});
this.#cdpDebugLogging.log("DebuggerToProxy", payload);
this.#deviceSocket.send(payload);
} catch {}
},
},
});
if (debuggerInfo.customHandler) {
debug("Created new custom message handler for debugger connection");
} else {
debug(
"Skipping new custom message handler for debugger connection, factory function returned null",
);
}
}
this.#sendConnectEventToDevice(this.#mapToDevicePageId(pageId), sessionId);
socket.on("message", (message) => {
this.#cdpDebugLogging.log("DebuggerToProxy", message);
const debuggerRequest = JSON.parse(message);
this.#deviceEventReporter?.logRequest(debuggerRequest, "debugger", {
pageId: debuggerInfo.pageId,
frontendUserAgent: userAgent,
});
let processedReq = debuggerRequest;
if (
debuggerInfo.customHandler?.handleDebuggerMessage(debuggerRequest) ===
true
) {
return;
}
if (!this.#pageHasCapability(page, "nativeSourceCodeFetching")) {
processedReq = this.#interceptClientMessageForSourceFetching(
debuggerRequest,
debuggerInfo,
socket,
);
}
if (processedReq) {
this.#sendMessageToDevice({
event: "wrappedEvent",
payload: {
pageId: this.#mapToDevicePageId(pageId),
wrappedEvent: JSON.stringify(processedReq),
sessionId,
},
});
}
});
socket.on("close", () => {
debug(
`Debugger for page ${pageId} and ${this.#name} disconnected (sessionId: ${sessionId}).`,
);
this.#deviceEventReporter?.logDisconnection("debugger");
this.#terminateDebuggerConnection(undefined, undefined, sessionId);
});
const cdpDebugLogging = this.#cdpDebugLogging;
const sendFunc = socket.send;
socket.send = function (message) {
cdpDebugLogging.log("ProxyToDebugger", message);
return sendFunc.call(socket, message);
};
}
#sendConnectEventToDevice(devicePageId, sessionId) {
this.#sendMessageToDevice({
event: "connect",
payload: {
pageId: devicePageId,
sessionId,
},
});
}
#sendDisconnectEventToDevice(devicePageId, sessionId) {
this.#sendMessageToDevice({
event: "disconnect",
payload: {
pageId: devicePageId,
sessionId,
},
});
}
#pageHasCapability(page, flag) {
return page.capabilities[flag] === true;
}
#createSyntheticPage() {
return {
id: REACT_NATIVE_RELOADABLE_PAGE_ID,
title: "React Native Experimental (Improved Chrome Reloads)",
vm: "don't use",
app: this.#app,
capabilities: {},
};
}
#handleMessageFromDevice(message) {
if (message.event === "getPages") {
const shouldDisableMultipleDebuggers =
!this.#experiments.enableStandaloneFuseboxShell;
this.#pages = new Map(
message.payload.map(({ capabilities: rawCapabilities, ...page }) => {
const capabilities = shouldDisableMultipleDebuggers
? {
...(rawCapabilities ?? {}),
supportsMultipleDebuggers: false,
}
: (rawCapabilities ?? {});
return [
page.id,
{
...page,
capabilities,
},
];
}),
);
if (message.payload.length !== this.#pages.size) {
const duplicateIds = new Set();
const idsSeen = new Set();
for (const page of message.payload) {
if (!idsSeen.has(page.id)) {
idsSeen.add(page.id);
} else {
duplicateIds.add(page.id);
}
}
debug(
`Received duplicate page IDs from device: ${[...duplicateIds].join(", ")}`,
);
}
for (const page of this.#pages.values()) {
if (this.#pageHasCapability(page, "nativePageReloads")) {
continue;
}
if (page.title.includes("React")) {
if (page.id !== this.#lastConnectedLegacyReactNativePage?.id) {
this.#newLegacyReactNativePage(page);
break;
}
}
}
} else if (message.event === "disconnect") {
const pageId = message.payload.pageId;
const sessionId = message.payload.sessionId;
const page = this.#pages.get(pageId);
if (page != null && this.#pageHasCapability(page, "nativePageReloads")) {
return;
}
if (sessionId != null) {
const debuggerConnection = this.#debuggerConnections.get(sessionId);
if (
debuggerConnection &&
debuggerConnection.socket.readyState === _ws.default.OPEN
) {
if (debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID) {
debug(
`Legacy page ${pageId} is reloading (sessionId: ${sessionId}).`,
);
debuggerConnection.socket.send(
JSON.stringify({
method: "reload",
}),
);
}
}
} else {
for (const debuggerConnection of this.#debuggerConnections.values()) {
if (
debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID &&
debuggerConnection.socket.readyState === _ws.default.OPEN
) {
debug(`Legacy page ${pageId} is reloading.`);
debuggerConnection.socket.send(
JSON.stringify({
method: "reload",
}),
);
}
}
}
} else if (message.event === "wrappedEvent") {
const sessionId = message.payload.sessionId;
let debuggerConnection = null;
if (sessionId != null) {
debuggerConnection = this.#debuggerConnections.get(sessionId);
} else {
if (this.#debuggerConnections.size > 1) {
debug(
"WARNING: Device sent message without sessionId but multiple debuggers are connected. " +
"This indicates a device/proxy version mismatch.",
);
}
debuggerConnection =
this.#debuggerConnections.values().next().value ?? null;
}
if (debuggerConnection == null) {
return;
}
const debuggerSocket = debuggerConnection.socket;
if (
debuggerSocket == null ||
debuggerSocket.readyState !== _ws.default.OPEN
) {
return;
}
const parsedPayload = JSON.parse(message.payload.wrappedEvent);
const pageId = debuggerConnection.pageId;
if ("id" in parsedPayload) {
this.#deviceEventReporter?.logResponse(parsedPayload, "device", {
pageId,
frontendUserAgent: debuggerConnection.userAgent ?? null,
});
}
if (
debuggerConnection.customHandler?.handleDeviceMessage(parsedPayload) ===
true
) {
return;
}
this.#processMessageFromDeviceLegacy(
parsedPayload,
debuggerConnection,
pageId,
);
const messageToSend = JSON.stringify(parsedPayload);
debuggerSocket.send(messageToSend);
}
}
#sendMessageToDevice(message) {
try {
const messageToSend = JSON.stringify(message);
if (message.event !== "getPages") {
this.#cdpDebugLogging.log("ProxyToDevice", messageToSend);
}
this.#deviceSocket.send(messageToSend);
} catch (error) {}
}
#newLegacyReactNativePage(page) {
debug(`React Native page updated to ${page.id}`);
let reloadablePageDebugger = null;
for (const debuggerConnection of this.#debuggerConnections.values()) {
if (debuggerConnection.pageId === REACT_NATIVE_RELOADABLE_PAGE_ID) {
reloadablePageDebugger = debuggerConnection;
break;
}
}
if (reloadablePageDebugger == null) {
this.#lastConnectedLegacyReactNativePage = page;
return;
}
const oldPageId = this.#lastConnectedLegacyReactNativePage?.id;
this.#lastConnectedLegacyReactNativePage = page;
this.#isLegacyPageReloading = true;
if (oldPageId != null) {
this.#sendDisconnectEventToDevice(
oldPageId,
reloadablePageDebugger.sessionId,
);
}
this.#sendConnectEventToDevice(page.id, reloadablePageDebugger.sessionId);
const toSend = [
{
method: "Runtime.enable",
id: 1e9,
},
{
method: "Debugger.enable",
id: 1e9,
},
];
for (const message of toSend) {
const pageId = reloadablePageDebugger.pageId;
this.#deviceEventReporter?.logRequest(message, "proxy", {
pageId,
frontendUserAgent: reloadablePageDebugger.userAgent ?? null,
});
this.#sendMessageToDevice({
event: "wrappedEvent",
payload: {
pageId: this.#mapToDevicePageId(page.id),
wrappedEvent: JSON.stringify(message),
sessionId: reloadablePageDebugger.sessionId,
},
});
}
}
#debuggerRelativeToDeviceRelativeUrl(
debuggerRelativeUrl,
{ debuggerRelativeBaseUrl },
) {
const deviceRelativeUrl = new URL(debuggerRelativeUrl.href);
if (debuggerRelativeUrl.origin === debuggerRelativeBaseUrl.origin) {
deviceRelativeUrl.hostname = this.#deviceRelativeBaseUrl.hostname;
deviceRelativeUrl.port = this.#deviceRelativeBaseUrl.port;
deviceRelativeUrl.protocol = this.#deviceRelativeBaseUrl.protocol;
}
return deviceRelativeUrl;
}
#deviceRelativeUrlToDebuggerRelativeUrl(
deviceRelativeUrl,
{ debuggerRelativeBaseUrl },
) {
const debuggerRelativeUrl = new URL(deviceRelativeUrl.href);
if (deviceRelativeUrl.origin === this.#deviceRelativeBaseUrl.origin) {
debuggerRelativeUrl.hostname = debuggerRelativeBaseUrl.hostname;
debuggerRelativeUrl.port = debuggerRelativeBaseUrl.port;
debuggerRelativeUrl.protocol = debuggerRelativeUrl.protocol;
}
return debuggerRelativeUrl;
}
#deviceRelativeUrlToServerRelativeUrl(deviceRelativeUrl) {
const debuggerRelativeUrl = new URL(deviceRelativeUrl.href);
if (deviceRelativeUrl.origin === this.#deviceRelativeBaseUrl.origin) {
debuggerRelativeUrl.hostname = this.#serverRelativeBaseUrl.hostname;
debuggerRelativeUrl.port = this.#serverRelativeBaseUrl.port;
debuggerRelativeUrl.protocol = this.#serverRelativeBaseUrl.protocol;
}
return debuggerRelativeUrl;
}
#processMessageFromDeviceLegacy(payload, debuggerInfo, pageId) {
const page = pageId != null ? this.#pages.get(pageId) : null;
if (
(!page || !this.#pageHasCapability(page, "nativeSourceCodeFetching")) &&
payload.method === "Debugger.scriptParsed" &&
payload.params != null
) {
const params = payload.params;
if ("sourceMapURL" in params) {
const sourceMapURL = this.#tryParseHTTPURL(params.sourceMapURL);
if (sourceMapURL) {
payload.params.sourceMapURL =
this.#deviceRelativeUrlToDebuggerRelativeUrl(
sourceMapURL,
debuggerInfo,
).href;
}
}
if ("url" in params) {
let serverRelativeUrl = params.url;
const parsedUrl = this.#tryParseHTTPURL(params.url);
if (parsedUrl) {
payload.params.url = this.#deviceRelativeUrlToDebuggerRelativeUrl(
parsedUrl,
debuggerInfo,
).href;
serverRelativeUrl =
this.#deviceRelativeUrlToServerRelativeUrl(parsedUrl).href;
}
if (payload.params.url.match(/^[0-9a-z]+$/)) {
payload.params.url = FILE_PREFIX + payload.params.url;
debuggerInfo.prependedFilePrefix = true;
}
if ("scriptId" in params && params.scriptId != null) {
this.#scriptIdToSourcePathMapping.set(
params.scriptId,
serverRelativeUrl,
);
}
}
}
if (
payload.method === "Runtime.executionContextCreated" &&
this.#isLegacyPageReloading
) {
debuggerInfo.socket.send(
JSON.stringify({
method: "Runtime.executionContextsCleared",
}),
);
const resumeMessage = {
method: "Debugger.resume",
id: 0,
};
this.#deviceEventReporter?.logRequest(resumeMessage, "proxy", {
pageId: debuggerInfo.pageId,
frontendUserAgent: debuggerInfo.userAgent ?? null,
});
this.#sendMessageToDevice({
event: "wrappedEvent",
payload: {
pageId: this.#mapToDevicePageId(debuggerInfo.pageId),
wrappedEvent: JSON.stringify(resumeMessage),
sessionId: debuggerInfo.sessionId,
},
});
this.#isLegacyPageReloading = false;
}
if (payload.method === "Runtime.consoleAPICalled") {
const callFrames = payload.params?.stackTrace?.callFrames ?? [];
for (const callFrame of callFrames) {
if (callFrame.url) {
const parsedUrl = this.#tryParseHTTPURL(callFrame.url);
if (parsedUrl) {
callFrame.url = this.#deviceRelativeUrlToDebuggerRelativeUrl(
parsedUrl,
debuggerInfo,
).href;
}
}
}
}
}
#interceptClientMessageForSourceFetching(req, debuggerInfo, socket) {
switch (req.method) {
case "Debugger.setBreakpointByUrl":
return this.#processDebuggerSetBreakpointByUrl(req, debuggerInfo);
case "Debugger.getScriptSource":
void this.#processDebuggerGetScriptSource(req, socket, debuggerInfo);
return null;
case "Network.loadNetworkResource":
const response = {
id: req.id,
result: {
error: {
code: -32601,
message:
"[inspector-proxy]: Page lacks nativeSourceCodeFetching capability.",
},
},
};
socket.send(JSON.stringify(response));
const pageId = debuggerInfo.pageId;
this.#deviceEventReporter?.logResponse(response, "proxy", {
pageId,
frontendUserAgent: debuggerInfo.userAgent ?? null,
});
return null;
default:
return req;
}
}
#processDebuggerSetBreakpointByUrl(req, debuggerInfo) {
const { debuggerRelativeBaseUrl, prependedFilePrefix } = debuggerInfo;
const processedReq = {
...req,
params: {
...req.params,
},
};
if (processedReq.params.url != null) {
const originalUrlParam = processedReq.params.url;
const httpUrl = this.#tryParseHTTPURL(originalUrlParam);
if (httpUrl) {
processedReq.params.url = this.#debuggerRelativeToDeviceRelativeUrl(
httpUrl,
debuggerInfo,
).href;
} else if (
originalUrlParam.startsWith(FILE_PREFIX) &&
prependedFilePrefix
) {
processedReq.params.url = originalUrlParam.slice(FILE_PREFIX.length);
}
}
if (
new Set(["10.0.2.2", "10.0.3.2"]).has(
this.#deviceRelativeBaseUrl.hostname,
) &&
debuggerRelativeBaseUrl.hostname === "localhost" &&
processedReq.params.urlRegex != null
) {
processedReq.params.urlRegex = processedReq.params.urlRegex.replaceAll(
"localhost",
this.#deviceRelativeBaseUrl.hostname.replaceAll(".", "\\."),
);
}
return processedReq;
}
async #processDebuggerGetScriptSource(req, socket, debuggerInfo) {
const sendSuccessResponse = (scriptSource) => {
const response = {
id: req.id,
result: {
scriptSource,
},
};
socket.send(JSON.stringify(response));
const pageId = debuggerInfo.pageId;
this.#deviceEventReporter?.logResponse(response, "proxy", {
pageId,
frontendUserAgent: debuggerInfo.userAgent ?? null,
});
};
const sendErrorResponse = (error) => {
const response = {
id: req.id,
result: {
error: {
message: error,
},
},
};
socket.send(JSON.stringify(response));
this.#sendErrorToDebugger(error, debuggerInfo);
const pageId = debuggerInfo.pageId;
this.#deviceEventReporter?.logResponse(response, "proxy", {
pageId,
frontendUserAgent: debuggerInfo.userAgent ?? null,
});
};
const pathToSource = this.#scriptIdToSourcePathMapping.get(
req.params.scriptId,
);
try {
const httpURL =
pathToSource == null ? null : this.#tryParseHTTPURL(pathToSource);
if (!httpURL) {
throw new Error(
`Can't parse requested URL ${pathToSource === undefined ? "undefined" : JSON.stringify(pathToSource)}`,
);
}
const text = await this.#fetchText(httpURL);
sendSuccessResponse(text);
} catch (err) {
sendErrorResponse(
`Failed to fetch source url ${pathToSource === undefined ? "undefined" : JSON.stringify(pathToSource)} for scriptId ${req.params.scriptId}: ${err.message}`,
);
}
}
#mapToDevicePageId(pageId) {
if (
pageId === REACT_NATIVE_RELOADABLE_PAGE_ID &&
this.#lastConnectedLegacyReactNativePage != null
) {
return this.#lastConnectedLegacyReactNativePage.id;
} else {
return pageId;
}
}
#tryParseHTTPURL(url) {
let parsedURL;
try {
parsedURL = new URL(url);
} catch {}
const protocol = parsedURL?.protocol;
if (protocol !== "http:" && protocol !== "https:") {
parsedURL = undefined;
}
return parsedURL;
}
async #fetchText(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error("HTTP " + response.status + " " + response.statusText);
}
const text = await response.text();
if (text.length > 350000000) {
throw new Error("file too large to fetch via HTTP");
}
return text;
}
#sendErrorToDebugger(message, debuggerInfo) {
const debuggerSocket = debuggerInfo?.socket;
if (debuggerSocket && debuggerSocket.readyState === _ws.default.OPEN) {
debuggerSocket.send(
JSON.stringify({
method: "Runtime.consoleAPICalled",
params: {
args: [
{
type: "string",
value: message,
},
],
executionContextId: 0,
type: "error",
},
}),
);
}
}
dangerouslyGetSocket() {
return this.#deviceSocket;
}
}
exports.default = Device;

View File

@@ -0,0 +1,73 @@
/**
* 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 { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { ReadonlyURL } from "../types/ReadonlyURL";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { Page } from "./types";
import WS from "ws";
// should be aligned with
// https://github.com/facebook/react-native-devtools-frontend/blob/3d17e0fd462dc698db34586697cce2371b25e0d3/front_end/ui/legacy/components/utils/TargetDetachedDialog.ts#L50-L64
declare export const WS_CLOSE_REASON: {
PAGE_NOT_FOUND: "[PAGE_NOT_FOUND] Debugger page not found",
CONNECTION_LOST: "[CONNECTION_LOST] Connection lost to corresponding device",
RECREATING_DEVICE: "[RECREATING_DEVICE] Recreating device connection",
NEW_DEBUGGER_OPENED: "[NEW_DEBUGGER_OPENED] New debugger opened for the same app instance",
};
export type DeviceOptions = Readonly<{
id: string,
name: string,
app: string,
socket: WS,
eventReporter: ?EventReporter,
createMessageMiddleware: ?CreateCustomMessageHandlerFn,
deviceRelativeBaseUrl: ReadonlyURL,
serverRelativeBaseUrl: ReadonlyURL,
isProfilingBuild: boolean,
experiments: Experiments,
}>;
/**
* Device class represents single device connection to Inspector Proxy. Each device
* can have multiple inspectable pages.
*/
declare export default class Device {
constructor(deviceOptions: DeviceOptions): void;
/**
* Used to recreate the device connection if there is a device ID collision.
* 1. Checks if the same device is attempting to reconnect for the same app.
* 2. If not, close both the device and debugger socket.
* 3. If the debugger connection can be reused, close the device socket only.
*
* This hack attempts to allow users to reload the app, either as result of a
* crash, or manually reloading, without having to restart the debugger.
*/
dangerouslyRecreateDevice(deviceOptions: DeviceOptions): void;
getName(): string;
getApp(): string;
getPagesList(): ReadonlyArray<Page>;
// Handles new debugger connection to this device:
// 1. Sends connect event to device
// 2. Forwards all messages from the debugger to device as wrappedEvent
// 3. Sends disconnect event to device when debugger connection socket closes.
handleDebuggerConnection(
socket: WS,
pageId: string,
$$PARAM_2$$: Readonly<{
debuggerRelativeBaseUrl: ReadonlyURL,
userAgent: string | null,
}>,
): void;
dangerouslyGetSocket(): WS;
}

View File

@@ -0,0 +1,51 @@
/**
* 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.
*
*
* @format
*/
import type { EventReporter } from "../types/EventReporter";
import type { CDPResponse } from "./cdp-types/messages";
import type { DeepReadOnly } from "./types";
type DeviceMetadata = Readonly<{
appId: string;
deviceId: string;
deviceName: string;
}>;
type RequestMetadata = Readonly<{
pageId: string | null;
frontendUserAgent: string | null;
}>;
type ResponseMetadata = Readonly<{
pageId: string | null;
frontendUserAgent: string | null;
}>;
declare class DeviceEventReporter {
constructor(eventReporter: EventReporter, metadata: DeviceMetadata);
logRequest(
req: Readonly<{ id: number; method: string }>,
origin: "debugger" | "proxy",
metadata: RequestMetadata,
): void;
logResponse(
res: DeepReadOnly<CDPResponse>,
origin: "device" | "proxy",
metadata: ResponseMetadata,
): void;
logProfilingTargetRegistered(): void;
logConnection(
connectedEntity: "debugger",
metadata: Readonly<{ pageId: string; frontendUserAgent: string | null }>,
): void;
logDisconnection(disconnectedEntity: "device" | "debugger"): void;
logProxyMessageHandlingError(
messageOrigin: "device" | "debugger",
error: Error,
message: string,
): void;
}
export default DeviceEventReporter;

View File

@@ -0,0 +1,184 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _ttlcache = _interopRequireDefault(require("@isaacs/ttlcache"));
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
class DeviceEventReporter {
#eventReporter;
#pendingCommands = new _ttlcache.default({
ttl: 10000,
dispose: (command, id, reason) => {
if (reason === "delete" || reason === "set") {
return;
}
this.#logExpiredCommand(command);
},
});
#metadata;
#deviceConnectedTimestamp;
constructor(eventReporter, metadata) {
this.#eventReporter = eventReporter;
this.#metadata = metadata;
this.#deviceConnectedTimestamp = Date.now();
}
logRequest(req, origin, metadata) {
this.#pendingCommands.set(req.id, {
method: req.method,
requestOrigin: origin,
requestTime: Date.now(),
metadata,
});
}
logResponse(res, origin, metadata) {
const pendingCommand = this.#pendingCommands.get(res.id);
if (!pendingCommand) {
this.#eventReporter.logEvent({
type: "debugger_command",
protocol: "CDP",
requestOrigin: null,
method: null,
status: "coded_error",
errorCode: "UNMATCHED_REQUEST_ID",
responseOrigin: "proxy",
timeSinceStart: null,
appId: this.#metadata.appId,
deviceId: this.#metadata.deviceId,
deviceName: this.#metadata.deviceName,
pageId: metadata.pageId,
frontendUserAgent: metadata.frontendUserAgent,
connectionUptime: this.#deviceConnectedTimestamp - Date.now(),
});
return;
}
const timeSinceStart = Date.now() - pendingCommand.requestTime;
this.#pendingCommands.delete(res.id);
if (res.error) {
let { message } = res.error;
if ("data" in res.error) {
message += ` (${String(res.error.data)})`;
}
this.#eventReporter.logEvent({
type: "debugger_command",
requestOrigin: pendingCommand.requestOrigin,
method: pendingCommand.method,
protocol: "CDP",
status: "coded_error",
errorCode: "PROTOCOL_ERROR",
errorDetails: message,
responseOrigin: origin,
timeSinceStart,
appId: this.#metadata.appId,
deviceId: this.#metadata.deviceId,
deviceName: this.#metadata.deviceName,
pageId: pendingCommand.metadata.pageId,
frontendUserAgent: pendingCommand.metadata.frontendUserAgent,
connectionUptime: this.#deviceConnectedTimestamp - Date.now(),
});
return;
}
this.#eventReporter.logEvent({
type: "debugger_command",
protocol: "CDP",
requestOrigin: pendingCommand.requestOrigin,
method: pendingCommand.method,
status: "success",
responseOrigin: origin,
timeSinceStart,
appId: this.#metadata.appId,
deviceId: this.#metadata.deviceId,
deviceName: this.#metadata.deviceName,
pageId: pendingCommand.metadata.pageId,
frontendUserAgent: pendingCommand.metadata.frontendUserAgent,
connectionUptime: this.#deviceConnectedTimestamp - Date.now(),
});
}
logProfilingTargetRegistered() {
this.#eventReporter.logEvent({
type: "profiling_target_registered",
status: "success",
appId: this.#metadata.appId,
deviceName: this.#metadata.deviceName,
deviceId: this.#metadata.deviceId,
pageId: null,
});
}
logConnection(connectedEntity, metadata) {
this.#eventReporter.logEvent({
type: "connect_debugger_frontend",
status: "success",
appId: this.#metadata.appId,
deviceName: this.#metadata.deviceName,
deviceId: this.#metadata.deviceId,
pageId: metadata.pageId,
frontendUserAgent: metadata.frontendUserAgent,
});
}
logDisconnection(disconnectedEntity) {
const eventReporter = this.#eventReporter;
if (!eventReporter) {
return;
}
const errorCode =
disconnectedEntity === "device"
? "DEVICE_DISCONNECTED"
: "DEBUGGER_DISCONNECTED";
for (const pendingCommand of this.#pendingCommands.values()) {
this.#eventReporter.logEvent({
type: "debugger_command",
protocol: "CDP",
requestOrigin: pendingCommand.requestOrigin,
method: pendingCommand.method,
status: "coded_error",
errorCode,
responseOrigin: "proxy",
timeSinceStart: Date.now() - pendingCommand.requestTime,
appId: this.#metadata.appId,
deviceId: this.#metadata.deviceId,
deviceName: this.#metadata.deviceName,
pageId: pendingCommand.metadata.pageId,
frontendUserAgent: pendingCommand.metadata.frontendUserAgent,
connectionUptime: this.#deviceConnectedTimestamp - Date.now(),
});
}
this.#pendingCommands.clear();
}
logProxyMessageHandlingError(messageOrigin, error, message) {
this.#eventReporter.logEvent({
type: "proxy_error",
status: "error",
messageOrigin,
message,
error: error.message,
errorStack: error.stack,
appId: this.#metadata.appId,
deviceId: this.#metadata.deviceId,
deviceName: this.#metadata.deviceName,
pageId: null,
connectionUptime: this.#deviceConnectedTimestamp - Date.now(),
});
}
#logExpiredCommand(pendingCommand) {
this.#eventReporter.logEvent({
type: "debugger_command",
protocol: "CDP",
requestOrigin: pendingCommand.requestOrigin,
method: pendingCommand.method,
status: "coded_error",
errorCode: "TIMED_OUT",
responseOrigin: "proxy",
timeSinceStart: Date.now() - pendingCommand.requestTime,
appId: this.#metadata.appId,
deviceId: this.#metadata.deviceId,
deviceName: this.#metadata.deviceName,
pageId: pendingCommand.metadata.pageId,
frontendUserAgent: pendingCommand.metadata.frontendUserAgent,
connectionUptime: this.#deviceConnectedTimestamp - Date.now(),
});
}
}
var _default = (exports.default = DeviceEventReporter);

View File

@@ -0,0 +1,59 @@
/**
* 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 { EventReporter } from "../types/EventReporter";
import type { CDPResponse } from "./cdp-types/messages";
import type { DeepReadOnly } from "./types";
type DeviceMetadata = Readonly<{
appId: string,
deviceId: string,
deviceName: string,
}>;
type RequestMetadata = Readonly<{
pageId: string | null,
frontendUserAgent: string | null,
}>;
type ResponseMetadata = Readonly<{
pageId: string | null,
frontendUserAgent: string | null,
}>;
declare class DeviceEventReporter {
constructor(eventReporter: EventReporter, metadata: DeviceMetadata): void;
logRequest(
req: Readonly<{ id: number, method: string, ... }>,
origin: "debugger" | "proxy",
metadata: RequestMetadata,
): void;
logResponse(
res: DeepReadOnly<CDPResponse<>>,
origin: "device" | "proxy",
metadata: ResponseMetadata,
): void;
logProfilingTargetRegistered(): void;
logConnection(
connectedEntity: "debugger",
metadata: Readonly<{
pageId: string,
frontendUserAgent: string | null,
}>,
): void;
logDisconnection(disconnectedEntity: "device" | "debugger"): void;
logProxyMessageHandlingError(
messageOrigin: "device" | "debugger",
error: Error,
message: string,
): void;
}
declare export default typeof DeviceEventReporter;

View File

@@ -0,0 +1,31 @@
/**
* 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.
*
*
* @format
*/
import type { DebuggerSessionIDs } from "../types/EventReporter";
export type EventLoopPerfTrackerArgs = {
perfMeasurementDuration: number;
minDelayPercentToReport: number;
onHighDelay: (args: OnHighDelayArgs) => void;
};
export type OnHighDelayArgs = {
eventLoopUtilization: number;
maxEventLoopDelayPercent: number;
duration: number;
debuggerSessionIDs: DebuggerSessionIDs;
connectionUptime: number;
};
declare class EventLoopPerfTracker {
constructor(args: EventLoopPerfTrackerArgs);
trackPerfThrottled(
debuggerSessionIDs: DebuggerSessionIDs,
connectionUptime: number,
): void;
}
export default EventLoopPerfTracker;

View File

@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _perf_hooks = require("perf_hooks");
var _timers = require("timers");
class EventLoopPerfTracker {
#perfMeasurementDuration;
#minDelayPercentToReport;
#onHighDelay;
#eventLoopPerfMeasurementOngoing;
constructor(args) {
this.#perfMeasurementDuration = args.perfMeasurementDuration;
this.#minDelayPercentToReport = args.minDelayPercentToReport;
this.#onHighDelay = args.onHighDelay;
this.#eventLoopPerfMeasurementOngoing = false;
}
trackPerfThrottled(debuggerSessionIDs, connectionUptime) {
if (this.#eventLoopPerfMeasurementOngoing) {
return;
}
this.#eventLoopPerfMeasurementOngoing = true;
const eluStart = _perf_hooks.performance.eventLoopUtilization();
const h = (0, _perf_hooks.monitorEventLoopDelay)({
resolution: 20,
});
h.enable();
(0, _timers.setTimeout)(() => {
const eluEnd = _perf_hooks.performance.eventLoopUtilization(eluStart);
h.disable();
const eventLoopUtilization = Math.floor(eluEnd.utilization * 100);
const maxEventLoopDelayPercent = Math.floor(
(h.max / 1e6 / this.#perfMeasurementDuration) * 100,
);
if (maxEventLoopDelayPercent >= this.#minDelayPercentToReport) {
this.#onHighDelay({
eventLoopUtilization,
maxEventLoopDelayPercent,
duration: this.#perfMeasurementDuration,
debuggerSessionIDs,
connectionUptime,
});
}
this.#eventLoopPerfMeasurementOngoing = false;
}, this.#perfMeasurementDuration).unref();
}
}
exports.default = EventLoopPerfTracker;

View File

@@ -0,0 +1,34 @@
/**
* 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
*/
// $FlowFixMe[cannot-resolve-module] libdef missing in RN OSS
import type { DebuggerSessionIDs } from "../types/EventReporter";
export type EventLoopPerfTrackerArgs = {
perfMeasurementDuration: number,
minDelayPercentToReport: number,
onHighDelay: (args: OnHighDelayArgs) => void,
};
export type OnHighDelayArgs = {
eventLoopUtilization: number,
maxEventLoopDelayPercent: number,
duration: number,
debuggerSessionIDs: DebuggerSessionIDs,
connectionUptime: number,
};
declare export default class EventLoopPerfTracker {
constructor(args: EventLoopPerfTrackerArgs): void;
trackPerfThrottled(
debuggerSessionIDs: DebuggerSessionIDs,
connectionUptime: number,
): void;
}

View File

@@ -0,0 +1,54 @@
/**
* 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.
*
*
* @format
*/
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { Logger } from "../types/Logger";
import type { ReadonlyURL } from "../types/ReadonlyURL";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { PageDescription } from "./types";
import type { IncomingMessage, ServerResponse } from "http";
import WS from "ws";
export type GetPageDescriptionsConfig = {
requestorRelativeBaseUrl: ReadonlyURL;
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.
*/
declare class InspectorProxy implements InspectorProxyQueries {
constructor(
serverBaseUrl: ReadonlyURL,
eventReporter: null | undefined | EventReporter,
experiments: Experiments,
logger?: Logger,
customMessageHandler: null | undefined | CreateCustomMessageHandlerFn,
trackEventLoopPerf?: boolean,
);
getPageDescriptions(
$$PARAM_0$$: GetPageDescriptionsConfig,
): Array<PageDescription>;
processRequest(
request: IncomingMessage,
response: ServerResponse,
next: ($$PARAM_0$$: null | undefined | Error) => unknown,
): void;
createWebSocketListeners(): { [path: string]: WS.Server };
}
export default InspectorProxy;

View File

@@ -0,0 +1,509 @@
"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 _ws = _interopRequireDefault(require("ws"));
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
const debug = require("debug")("Metro:InspectorProxy");
const WS_DEBUGGER_ALLOWED_ORIGIN_HOSTNAMES = new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"[::]",
]);
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 {
#serverBaseUrl;
#devices;
#deviceCounter = 0;
#eventReporter;
#experiments;
#customMessageHandler;
#logger;
#lastMessageTimestamp = null;
#eventLoopPerfTracker;
constructor(
serverBaseUrl,
eventReporter,
experiments,
logger,
customMessageHandler,
trackEventLoopPerf = false,
) {
this.#serverBaseUrl = 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 = new URL(request.url, "http://example.com").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,
new URL(this.#serverBaseUrl),
{
relative: true,
},
);
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 = tryParseQueryParams(req.url);
const deviceId = query?.get("device") || fallbackDeviceId;
const deviceName = query?.get("name") || "Unknown";
const appName = query?.get("app") || "Unknown";
const isProfilingBuild = query?.get("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,
eventReporter: this.#eventReporter,
createMessageMiddleware: this.#customMessageHandler,
deviceRelativeBaseUrl,
serverRelativeBaseUrl: this.#serverBaseUrl,
isProfilingBuild,
experiments: this.#experiments,
};
if (oldDevice) {
oldDevice.dangerouslyRecreateDevice(deviceOptions);
newDevice = oldDevice;
} else {
newDevice = new _Device.default(deviceOptions);
}
this.#devices.set(deviceId, newDevice);
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) => {
debug(
"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,
verifyClient: (info) => {
if (this.#serverBaseUrl.origin === info.origin) {
return true;
}
if (URL.canParse(info.origin)) {
const { hostname } = new URL(info.origin);
if (WS_DEBUGGER_ALLOWED_ORIGIN_HOSTNAMES.has(hostname)) {
return true;
}
}
this.#logger?.error(
"Connection from DevTools failed to be established for origin '%s' and path '%s'. Was expecting origin: '%s', or origin hostname to be one of: %s",
info.origin,
info.req.url,
this.#serverBaseUrl.origin,
Array.from(WS_DEBUGGER_ALLOWED_ORIGIN_HOSTNAMES).join(", "),
);
return false;
},
});
wss.on("connection", async (socket, req) => {
const wssTimestamp = Date.now();
const query = tryParseQueryParams(req.url);
const deviceId = query?.get("device") || null;
const pageId = query?.get("page") || null;
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);
}
debug(
"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?.get("userAgent") ?? null,
});
socket.on("close", (code, reason) => {
debug(
"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 || "unknown",
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;
function tryParseQueryParams(urlString) {
try {
return new URL(urlString, "http://example.com").searchParams;
} catch {
return null;
}
}

View File

@@ -0,0 +1,63 @@
/**
* 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 { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { Logger } from "../types/Logger";
import type { ReadonlyURL } from "../types/ReadonlyURL";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { PageDescription } from "./types";
import type { IncomingMessage, ServerResponse } from "http";
import WS from "ws";
export type GetPageDescriptionsConfig = {
requestorRelativeBaseUrl: ReadonlyURL,
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.
*/
declare export default class InspectorProxy implements InspectorProxyQueries {
constructor(
serverBaseUrl: ReadonlyURL,
eventReporter: ?EventReporter,
experiments: Experiments,
logger?: Logger,
customMessageHandler: ?CreateCustomMessageHandlerFn,
trackEventLoopPerf?: boolean,
): void;
getPageDescriptions(
$$PARAM_0$$: GetPageDescriptionsConfig,
): Array<PageDescription>;
// 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) => unknown,
): void;
createWebSocketListeners(): {
[path: string]: WS.Server,
};
}

View File

@@ -0,0 +1,24 @@
/**
* 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.
*
*
* @format
*/
import WS from "ws";
export type HeartbeatTrackerArgs = {
socket: WS;
timeBetweenPings: number;
minHighPingToReport: number;
timeoutMs: number;
onTimeout: (roundtripDuration: number) => void;
onHighPing: (roundtripDuration: number) => void;
};
declare class InspectorProxyHeartbeat {
constructor(args: HeartbeatTrackerArgs);
start(): void;
}
export default InspectorProxyHeartbeat;

View File

@@ -0,0 +1,64 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _timers = require("timers");
var _ws = _interopRequireDefault(require("ws"));
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
class InspectorProxyHeartbeat {
#socket;
#timeBetweenPings;
#minHighPingToReport;
#timeoutMs;
#onTimeout;
#onHighPing;
constructor(args) {
this.#socket = args.socket;
this.#timeBetweenPings = args.timeBetweenPings;
this.#minHighPingToReport = args.minHighPingToReport;
this.#timeoutMs = args.timeoutMs;
this.#onTimeout = args.onTimeout;
this.#onHighPing = args.onHighPing;
}
start() {
let latestPingMs = Date.now();
let terminateTimeout;
const pingTimeout = (0, _timers.setTimeout)(() => {
if (this.#socket.readyState !== _ws.default.OPEN) {
pingTimeout.refresh();
return;
}
if (!terminateTimeout) {
terminateTimeout = (0, _timers.setTimeout)(() => {
if (this.#socket.readyState !== _ws.default.OPEN) {
terminateTimeout?.refresh();
return;
}
this.#onTimeout(this.#timeoutMs);
}, this.#timeoutMs).unref();
}
latestPingMs = Date.now();
this.#socket.ping();
}, this.#timeBetweenPings).unref();
this.#socket.on("pong", () => {
const roundtripDuration = Date.now() - latestPingMs;
if (roundtripDuration >= this.#minHighPingToReport) {
this.#onHighPing(roundtripDuration);
}
terminateTimeout?.refresh();
pingTimeout.refresh();
});
this.#socket.on("message", () => {
terminateTimeout?.refresh();
});
this.#socket.on("close", (code, reason) => {
terminateTimeout && (0, _timers.clearTimeout)(terminateTimeout);
(0, _timers.clearTimeout)(pingTimeout);
});
}
}
exports.default = InspectorProxyHeartbeat;

View File

@@ -0,0 +1,25 @@
/**
* 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 WS from "ws";
export type HeartbeatTrackerArgs = {
socket: WS,
timeBetweenPings: number,
minHighPingToReport: number,
timeoutMs: number,
onTimeout: (roundtripDuration: number) => void,
onHighPing: (roundtripDuration: number) => void,
};
declare export default class InspectorProxyHeartbeat {
constructor(args: HeartbeatTrackerArgs): void;
start(): void;
}

View File

@@ -0,0 +1,324 @@
# Inspector Proxy Protocol
[🏠 Home](../../../../../__docs__/README.md)
The inspector-proxy protocol facilitates Chrome DevTools Protocol (CDP) target
discovery and communication between **debuggers** (e.g., Chrome DevTools, VS
Code) and **devices** (processes containing React Native hosts). The proxy
multiplexes connections over a single WebSocket per device, allowing multiple
debuggers to connect to multiple pages on the same device.
## 🚀 Usage
### Target Discovery (HTTP)
We implement a subset of the
[Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/)'s
[HTTP endpoints](https://chromedevtools.github.io/devtools-protocol/#:~:text=a%20reconnect%20button.-,HTTP%20Endpoints,-If%20started%20with)
to allow debuggers to discover targets.
| Endpoint | Description |
| --------------------------- | ------------------------ |
| `GET /json` or `/json/list` | List of debuggable pages |
| `GET /json/version` | Protocol version info |
### Device Registration (WebSocket)
Devices register themselves with the proxy by connecting to `/inspector/device`:
```text
ws://{host}/inspector/device?device={id}&name={name}&app={bundle_id}&profiling={true|false}
```
| Parameter | Required | Description |
| ----------- | -------- | ------------------------------------------------------------ |
| `device` | No\* | Logical device identifier. Auto-generated if omitted. |
| `name` | No | Human-readable device name. Defaults to "Unknown". |
| `app` | No | App bundle identifier. Defaults to "Unknown". |
| `profiling` | No | "true" if this is a profiling build. (Used for logging only) |
\*Recommended for connection persistence across app restarts.
#### Requirements for the `device` parameter
The intent of the logical device ID is to help with target discovery and
especially *re*discovery - to reduce the number of times users need to
explicitly close and restart the debugger frontend (e.g. after an app crash).
If provided, the logical device ID:
1. SHOULD be stable for the current combination of physical device (or emulator
instance) and app.
2. SHOULD be stable across installs/launches of the same app on the same device
(or emulator instance), though it MAY be user-resettable (so as to not
require any special privacy permissions).
3. MUST be unique across different apps on the same physical device (or
emulator).
4. MUST be unique across physical devices (or emulators).
5. MUST be unique for each concurrent _instance_ of the same app on the same
physical device (or emulator).
NOTE: The uniqueness requirements are stronger (MUST) than the stability
requirements (SHOULD). In particular, on platforms that allow multiple instances
of the same app to run concurrently, requirements 1 and/or 2 MAY be violated in
order to meet requirement 5. This is relevant, for example, on desktop
platforms.
### Debugger Connection (WebSocket)
Debuggers connect to `/inspector/debug` to form a CDP session with a page:
```text
ws://{host}/inspector/debug?device={device_id}&page={page_id}
```
Both `device` and `page` query parameters are required.
## 📐 Design
### Architecture
```text
┌─────────────────┐ ┌─────────────────────────┐ ┌────────────────┐
│ Debugger │────▶│ Inspector Proxy │◀────│ Device │
│ (Chrome/VSCode) │ │ (Node.js) │ │ (iOS/Android) │
└─────────────────┘ └─────────────────────────┘ └────────────────┘
WebSocket HTTP + WebSocket WebSocket
/inspector/debug /json, /json/list /inspector/device
/json/version
```
### Device ↔ Proxy Protocol
All messages are JSON-encoded WebSocket text frames:
```typescript
interface Message {
event: string;
payload?: /* depends on event */;
}
```
#### Proxy → Device Messages
| Event | Payload | Description |
| -------------- | ------------------------------------------------------------- | --------------------------------------------- |
| `getPages` | _(none)_ | Request current page list. Sent periodically. |
| `connect` | `{ pageId: string, sessionId: string }` | Prepare for debugger connection to page. |
| `disconnect` | `{ pageId: string, sessionId: string }` | Terminate debugger session for page. |
| `wrappedEvent` | `{ pageId: string, sessionId: string, wrappedEvent: string }` | Forward CDP message (JSON string) to page. |
#### Device → Proxy Messages
| Event | Payload | Description |
| -------------- | -------------------------------------------------------------- | ----------------------------------------------------- |
| `getPages` | `Page[]` | Current list of inspectable pages. |
| `disconnect` | `{ pageId: string, sessionId?: string }` | Notify that page disconnected or rejected connection. |
| `wrappedEvent` | `{ pageId: string, sessionId?: string, wrappedEvent: string }` | Forward CDP message (JSON string) from page. |
#### Page Object
```typescript
interface Page {
id: string; // Unique page identifier (typically numeric string)
title: string; // Display title
app: string; // App bundle identifier
description?: string; // Additional description
capabilities?: {
nativePageReloads?: boolean; // Target keeps the socket open across reloads
nativeSourceCodeFetching?: boolean; // Target supports Network.loadNetworkResource
supportsMultipleDebuggers?: boolean; // Supports concurrent debugger sessions
};
}
```
**Note**: The value of `supportsMultipleDebuggers` SHOULD be consistent across
all pages for a given device.
### Connection Lifecycle
**Device Registration:**
```text
Device Proxy
│ │
│──── WS Connect ─────────────────▶│
│ /inspector/device?... │
│ │
│◀──── getPages ───────────────────│ (periodically)
│ │
│───── getPages response ─────────▶│
│ (page list) │
```
**Debugger Session:**
```text
Debugger Proxy Device
│ │ │
│── WS Connect ───▶│ │
│ ?device&page │── connect ────────────────▶│
│ │ {pageId, sessionId} │
│ │ │
│── CDP Request ──▶│── wrappedEvent ───────────▶│
│ │ {pageId, sessionId, │
│ │ wrappedEvent} │
│ │ │
│ │◀── wrappedEvent ───────────│
│◀── CDP Response ─│ {pageId, sessionId, │
│ │ wrappedEvent} │
│ │ │
│── WS Close ─────▶│── disconnect ─────────────▶│
│ │ {pageId, sessionId} │
```
**Connection Rejection:**
If a device cannot accept a `connect` (e.g., page doesn't exist), it should send
a `disconnect` back to the proxy for that `pageId`.
### Connection Semantics
#### Multi-Debugger Support
Multiple debuggers can connect simultaneously to the same page when **both** the
proxy and device support session multiplexing:
1. **Session IDs**: The proxy assigns a unique, non-empty `sessionId` to each
debugger connection. All messages include this `sessionId` for routing. This
SHOULD be a UUID or other suitably unique and ephemeral identifier.
2. **Capability Detection**: Devices report `supportsMultipleDebuggers: true` in
their page capabilities to indicate session support.
3. **Backwards Compatibility**: Legacy devices ignore `sessionId` fields in
incoming messages and don't include them in responses.
#### Connection Rules
1. **Session-Capable Device**: Multiple debuggers can connect to the same page
simultaneously. Each connection has an independent session.
2. **Legacy Device (no `supportsMultipleDebuggers`)**: New debugger connections
to an already-connected page disconnect the existing debugger. The proxy MUST
NOT allow multiple debuggers to connect to the same page.
3. **Device Reconnection**: If a device reconnects with the same `device` ID
while debugger connections to the same logical device are open in the proxy,
the proxy may attempt to preserve active debugger sessions by forwarding them
to the new device.
### WebSocket Close Reasons
The proxy uses specific close reasons that DevTools frontends may recognize:
| Reason | Context |
| ----------------------- | --------------------------------------- |
| `[PAGE_NOT_FOUND]` | Debugger connected to non-existent page |
| `[CONNECTION_LOST]` | Device disconnected |
| `[RECREATING_DEVICE]` | Device is reconnecting |
| `[NEW_DEBUGGER_OPENED]` | Another debugger took over this page |
| `[UNREGISTERED_DEVICE]` | Device ID not found |
| `[INCORRECT_URL]` | Missing device/page query parameters |
### PageDescription (HTTP Response)
The `/json` endpoint returns enriched page descriptions based on those reported
by the device.
```typescript
interface PageDescription {
// Used for target selection
id: string; // "{deviceId}-{pageId}"
// Used for display
title: string;
description: string;
deviceName: string;
// Used for target matching
appId: string;
// Used for debugger connection
webSocketDebuggerUrl: string;
// React Native-specific metadata
reactNative: {
logicalDeviceId: string; // Used for target matching
capabilities: {
nativePageReloads?: boolean; // Used for target filtering
};
};
}
```
## 🔗 Relationship with other systems
### Part of this
- **Device.js** - Per-device connection handler in the proxy
- **InspectorProxy.js** - Main proxy HTTP/WebSocket server
### Used by this
- **Chrome DevTools Protocol (CDP)** - The wrapped messages are CDP messages
exchanged between DevTools frontends and JavaScript runtimes.
- **WebSocket** - Transport layer for device and debugger connections.
### Uses this
- **InspectorPackagerConnection (C++)** - Shared device-side protocol
implementation in `ReactCommon/jsinspector-modern/`.
- **Platform layers** - iOS (`RCTInspectorDevServerHelper.mm`), Android
(`DevServerHelper.kt`), and ReactCxxPlatform (`Inspector.cpp`) provide
WebSocket I/O and threading.
- **openDebuggerMiddleware** - Uses `/json` to discover targets for the
`/open-debugger` endpoint.
- **OpenDebuggerKeyboardHandler** - Uses `/json` to display target selection in
the CLI.
---
## Legacy Features
The following features exist for backward compatibility with older React Native
targets that lack modern capabilities. New implementations should set
appropriate capability flags and may ignore this section.
### Synthetic Reloadable Page (Page ID `-1`)
For targets without the `nativePageReloads` capability, the proxy exposes a
synthetic page with ID `-1` titled "React Native Experimental (Improved Chrome
Reloads)". Debuggers connecting to this page are automatically redirected to the
most recent React Native page, surviving page reloads.
When a new React Native page appears while a debugger is connected to `-1`:
1. Proxy sends `disconnect` for the old page, `connect` for the new page
2. Proxy sends `Runtime.enable` and `Debugger.enable` CDP commands to the new
page
3. When `Runtime.executionContextCreated` is received, proxy sends
`Runtime.executionContextsCleared` to debugger, then `Debugger.resume` to
device
### URL Rewriting
For targets without the `nativeSourceCodeFetching` capability, the proxy
rewrites URLs in CDP messages:
- **Debugger.scriptParsed** (device → debugger): Device-relative URLs are
rewritten to debugger-relative URLs
- **Debugger.setBreakpointByUrl** (debugger → device): URLs are rewritten back
to device-relative form
- **Debugger.getScriptSource**: Intercepted and handled by proxy via HTTP fetch
- **Network.loadNetworkResource**: Returns CDP error (code -32601) to force
frontend fallback
Additionally, if a script URL matches `^[0-9a-z]+$` (alphanumeric ID), the proxy
prepends `file://` to ensure Chrome downloads source maps.
### Legacy Reload Notification
For targets without `nativePageReloads`, when a `disconnect` event is received
for a page, the proxy sends `{method: 'reload'}` to the connected debugger to
signal a page reload.

View File

@@ -0,0 +1,41 @@
/**
* 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.
*
*
* @format
*/
import type { JSONSerializable } from "../types";
import type { Commands, Events } from "./protocol";
export type CDPEvent<TEvent extends keyof Events = "unknown"> = {
method: TEvent;
params: Events[TEvent];
};
export type CDPRequest<TCommand extends keyof Commands = "unknown"> = {
method: TCommand;
params: Commands[TCommand]["paramsType"];
id: number;
};
export type CDPResponse<TCommand extends keyof Commands = "unknown"> =
| { result: Commands[TCommand]["resultType"]; id: number }
| { error: CDPRequestError; id: number };
export type CDPRequestError = {
code: number;
message: string;
data?: JSONSerializable;
};
export type CDPClientMessage =
| CDPRequest<"Debugger.getScriptSource">
| CDPRequest<"Debugger.scriptParsed">
| CDPRequest<"Debugger.setBreakpointByUrl">
| CDPRequest<"Network.loadNetworkResource">
| CDPRequest;
export type CDPServerMessage =
| CDPEvent<"Debugger.scriptParsed">
| CDPEvent<"Runtime.consoleAPICalled">
| CDPEvent
| CDPResponse<"Debugger.getScriptSource">
| CDPResponse;

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,54 @@
/**
* 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 { JSONSerializable } from "../types";
import type { Commands, Events } from "./protocol";
// Note: A CDP event is a JSON-RPC notification with no `id` member.
export type CDPEvent<TEvent: keyof Events = "unknown"> = {
method: TEvent,
params: Events[TEvent],
};
export type CDPRequest<TCommand: keyof Commands = "unknown"> = {
method: TCommand,
params: Commands[TCommand]["paramsType"],
id: number,
};
export type CDPResponse<TCommand: keyof Commands = "unknown"> =
| {
result: Commands[TCommand]["resultType"],
id: number,
}
| {
error: CDPRequestError,
id: number,
};
export type CDPRequestError = {
code: number,
message: string,
data?: JSONSerializable,
};
export type CDPClientMessage =
| CDPRequest<"Debugger.getScriptSource">
| CDPRequest<"Debugger.scriptParsed">
| CDPRequest<"Debugger.setBreakpointByUrl">
| CDPRequest<"Network.loadNetworkResource">
| CDPRequest<>;
export type CDPServerMessage =
| CDPEvent<"Debugger.scriptParsed">
| CDPEvent<"Runtime.consoleAPICalled">
| CDPEvent<>
| CDPResponse<"Debugger.getScriptSource">
| CDPResponse<>;

View File

@@ -0,0 +1,106 @@
/**
* 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.
*
*
* @format
*/
import type { JSONSerializable } from "../types";
type integer = number;
export interface Debugger {
GetScriptSourceParams: {
/**
* Id of the script to get source for.
*/
scriptId: string;
};
GetScriptSourceResult: {
/**
* Script source (empty in case of Wasm bytecode).
*/
scriptSource: string;
/**
* Wasm bytecode. (Encoded as a base64 string when passed over JSON)
*/
bytecode?: string;
};
SetBreakpointByUrlParams: {
/**
* Line number to set breakpoint at.
*/
lineNumber: integer;
/**
* URL of the resources to set breakpoint on.
*/
url?: string;
/**
* Regex pattern for the URLs of the resources to set breakpoints on. Either `url` or
* `urlRegex` must be specified.
*/
urlRegex?: string;
/**
* Script hash of the resources to set breakpoint on.
*/
scriptHash?: string;
/**
* Offset in the line to set breakpoint at.
*/
columnNumber?: integer;
/**
* Expression to use as a breakpoint condition. When specified, debugger will only stop on the
* breakpoint if this expression evaluates to true.
*/
condition?: string;
};
ScriptParsedEvent: {
/**
* Identifier of the script parsed.
*/
scriptId: string;
/**
* URL or name of the script parsed (if any).
*/
url: string;
/**
* URL of source map associated with script (if any).
*/
sourceMapURL: string;
};
ConsoleAPICalled: {
args: Array<{ type: string; value: string }>;
executionContextId: number;
stackTrace: {
timestamp: number;
type: string;
callFrames: Array<{
columnNumber: number;
lineNumber: number;
functionName: string;
scriptId: string;
url: string;
}>;
};
};
}
export type Events = {
"Debugger.scriptParsed": Debugger["ScriptParsedEvent"];
"Runtime.consoleAPICalled": Debugger["ConsoleAPICalled"];
[method: string]: JSONSerializable;
};
export type Commands = {
"Debugger.getScriptSource": {
paramsType: Debugger["GetScriptSourceParams"];
resultType: Debugger["GetScriptSourceResult"];
};
"Debugger.setBreakpointByUrl": {
paramsType: Debugger["SetBreakpointByUrlParams"];
resultType: void;
};
[method: string]: {
paramsType: JSONSerializable;
resultType: JSONSerializable;
};
};

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,124 @@
/**
* 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
*/
// Adapted from https://github.com/ChromeDevTools/devtools-protocol/blob/master/types/protocol.d.ts
import type { JSONSerializable } from "../types";
type integer = number;
export interface Debugger {
GetScriptSourceParams: {
/**
* Id of the script to get source for.
*/
scriptId: string,
};
GetScriptSourceResult: {
/**
* Script source (empty in case of Wasm bytecode).
*/
scriptSource: string,
/**
* Wasm bytecode. (Encoded as a base64 string when passed over JSON)
*/
bytecode?: string,
};
SetBreakpointByUrlParams: {
/**
* Line number to set breakpoint at.
*/
lineNumber: integer,
/**
* URL of the resources to set breakpoint on.
*/
url?: string,
/**
* Regex pattern for the URLs of the resources to set breakpoints on. Either `url` or
* `urlRegex` must be specified.
*/
urlRegex?: string,
/**
* Script hash of the resources to set breakpoint on.
*/
scriptHash?: string,
/**
* Offset in the line to set breakpoint at.
*/
columnNumber?: integer,
/**
* Expression to use as a breakpoint condition. When specified, debugger will only stop on the
* breakpoint if this expression evaluates to true.
*/
condition?: string,
};
ScriptParsedEvent: {
/**
* Identifier of the script parsed.
*/
scriptId: string,
/**
* URL or name of the script parsed (if any).
*/
url: string,
/**
* URL of source map associated with script (if any).
*/
sourceMapURL: string,
};
ConsoleAPICalled: {
args: Array<{ type: string, value: string }>,
executionContextId: number,
stackTrace: {
timestamp: number,
type: string,
callFrames: Array<{
columnNumber: number,
lineNumber: number,
functionName: string,
scriptId: string,
url: string,
}>,
},
};
}
export type Events = {
"Debugger.scriptParsed": Debugger["ScriptParsedEvent"],
"Runtime.consoleAPICalled": Debugger["ConsoleAPICalled"],
[method: string]: JSONSerializable,
};
export type Commands = {
"Debugger.getScriptSource": {
paramsType: Debugger["GetScriptSourceParams"],
resultType: Debugger["GetScriptSourceResult"],
},
"Debugger.setBreakpointByUrl": {
paramsType: Debugger["SetBreakpointByUrlParams"],
resultType: void,
},
[method: string]: {
paramsType: JSONSerializable,
resultType: JSONSerializable,
},
};

View File

@@ -0,0 +1,130 @@
/**
* 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.
*
*
* @format
*/
/**
* A capability flag disables a specific feature/hack in the InspectorProxy
* layer by indicating that the target supports one or more modern CDP features.
*/
export type TargetCapabilityFlags = Readonly<{
/**
* The target supports a stable page representation across reloads.
*
* In the proxy, this disables legacy page reload emulation and the
* additional 'React Native Experimental' target in `/json/list`.
*
* In the launch flow, this allows targets to be matched directly by
* `logicalDeviceId`.
*/
nativePageReloads?: boolean;
/**
* The target supports fetching source code and source maps.
*
* In the proxy, this disables source fetching emulation and host rewrites.
*/
nativeSourceCodeFetching?: boolean;
/**
* The target supports multiple concurrent debugger connections.
*
* When true, the proxy allows multiple debuggers to connect to the same
* page simultaneously, each identified by a unique session ID.
* When false (default/legacy), connecting a new debugger disconnects
* any existing debugger connection to that page.
*/
supportsMultipleDebuggers?: boolean;
}>;
export type PageFromDevice = Readonly<{
id: string;
title: string;
/** Sent from modern targets only */
description?: string;
/** @deprecated This is sent from legacy targets only */
vm?: string;
app: string;
capabilities?: TargetCapabilityFlags;
}>;
export type Page = Readonly<
Omit<
PageFromDevice,
keyof { capabilities: NonNullable<PageFromDevice["capabilities"]> }
> & { capabilities: NonNullable<PageFromDevice["capabilities"]> }
>;
export type WrappedEventFromDevice = Readonly<{
event: "wrappedEvent";
payload: Readonly<{
pageId: string;
wrappedEvent: string;
sessionId?: string;
}>;
}>;
export type WrappedEventToDevice = Readonly<{
event: "wrappedEvent";
payload: Readonly<{
pageId: string;
wrappedEvent: string;
sessionId: string;
}>;
}>;
export type ConnectRequest = Readonly<{
event: "connect";
payload: Readonly<{ pageId: string; sessionId: string }>;
}>;
export type DisconnectRequest = Readonly<{
event: "disconnect";
payload: Readonly<{ pageId: string; sessionId: string }>;
}>;
export type GetPagesRequest = { event: "getPages" };
export type GetPagesResponse = {
event: "getPages";
payload: ReadonlyArray<PageFromDevice>;
};
export type MessageFromDevice =
| GetPagesResponse
| WrappedEventFromDevice
| DisconnectRequest;
export type MessageToDevice =
| GetPagesRequest
| WrappedEventToDevice
| ConnectRequest
| DisconnectRequest;
export type PageDescription = Readonly<{
id: string;
title: string;
appId: string;
description: string;
type: string;
devtoolsFrontendUrl: string;
webSocketDebuggerUrl: string;
/** @deprecated Prefer `title` */
deviceName: string;
/** @deprecated This is sent from legacy targets only */
vm?: string;
reactNative: Readonly<{
logicalDeviceId: string;
capabilities: Page["capabilities"];
}>;
}>;
export type JsonPagesListResponse = Array<PageDescription>;
export type JsonVersionResponse = Readonly<{
Browser: string;
"Protocol-Version": string;
}>;
export type JSONSerializable =
| boolean
| number
| string
| null
| ReadonlyArray<JSONSerializable>
| { readonly [$$Key$$: string]: JSONSerializable };
export type DeepReadOnly<T> =
T extends ReadonlyArray<infer V>
? ReadonlyArray<DeepReadOnly<V>>
: T extends {}
? { readonly [K in keyof T]: DeepReadOnly<T[K]> }
: T;

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,166 @@
/**
* 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
*/
/**
* A capability flag disables a specific feature/hack in the InspectorProxy
* layer by indicating that the target supports one or more modern CDP features.
*/
export type TargetCapabilityFlags = Readonly<{
/**
* The target supports a stable page representation across reloads.
*
* In the proxy, this disables legacy page reload emulation and the
* additional 'React Native Experimental' target in `/json/list`.
*
* In the launch flow, this allows targets to be matched directly by
* `logicalDeviceId`.
*/
nativePageReloads?: boolean,
/**
* The target supports fetching source code and source maps.
*
* In the proxy, this disables source fetching emulation and host rewrites.
*/
nativeSourceCodeFetching?: boolean,
/**
* The target supports multiple concurrent debugger connections.
*
* When true, the proxy allows multiple debuggers to connect to the same
* page simultaneously, each identified by a unique session ID.
* When false (default/legacy), connecting a new debugger disconnects
* any existing debugger connection to that page.
*/
supportsMultipleDebuggers?: boolean,
}>;
// Page information received from the device. New page is created for
// each new instance of VM and can appear when user reloads React Native
// application.
export type PageFromDevice = Readonly<{
id: string,
title: string,
/** Sent from modern targets only */
description?: string,
/** @deprecated This is sent from legacy targets only */
vm?: string,
app: string,
capabilities?: TargetCapabilityFlags,
}>;
export type Page = Readonly<{
...PageFromDevice,
capabilities: NonNullable<PageFromDevice["capabilities"]>,
}>;
// Chrome Debugger Protocol message/event passed from device to proxy.
export type WrappedEventFromDevice = Readonly<{
event: "wrappedEvent",
payload: Readonly<{
pageId: string,
wrappedEvent: string,
sessionId?: string,
}>,
}>;
// Chrome Debugger Protocol message/event passed from proxy to device.
export type WrappedEventToDevice = Readonly<{
event: "wrappedEvent",
payload: Readonly<{
pageId: string,
wrappedEvent: string,
sessionId: string,
}>,
}>;
// Request sent from Inspector Proxy to Device when new debugger is connected
// to particular page.
export type ConnectRequest = Readonly<{
event: "connect",
payload: Readonly<{ pageId: string, sessionId: string }>,
}>;
// Request sent from Inspector Proxy to Device to notify that debugger is
// disconnected.
export type DisconnectRequest = Readonly<{
event: "disconnect",
payload: Readonly<{ pageId: string, sessionId: string }>,
}>;
// Request sent from Inspector Proxy to Device to get a list of pages.
export type GetPagesRequest = { event: "getPages" };
// Response to GetPagesRequest containing a list of page infos.
export type GetPagesResponse = {
event: "getPages",
payload: ReadonlyArray<PageFromDevice>,
};
// Union type for all possible messages sent from device to Inspector Proxy.
export type MessageFromDevice =
| GetPagesResponse
| WrappedEventFromDevice
| DisconnectRequest;
// Union type for all possible messages sent from Inspector Proxy to device.
export type MessageToDevice =
| GetPagesRequest
| WrappedEventToDevice
| ConnectRequest
| DisconnectRequest;
// Page description object that is sent in response to /json HTTP request from debugger.
export type PageDescription = Readonly<{
id: string,
title: string,
appId: string,
description: string,
type: string,
devtoolsFrontendUrl: string,
webSocketDebuggerUrl: string,
// React Native specific fields
/** @deprecated Prefer `title` */
deviceName: string,
/** @deprecated This is sent from legacy targets only */
vm?: string,
// React Native specific metadata
reactNative: Readonly<{
logicalDeviceId: string,
capabilities: Page["capabilities"],
}>,
}>;
export type JsonPagesListResponse = Array<PageDescription>;
// Response to /json/version HTTP request from the debugger specifying browser type and
// Chrome protocol version.
export type JsonVersionResponse = Readonly<{
Browser: string,
"Protocol-Version": string,
}>;
export type JSONSerializable =
| boolean
| number
| string
| null
| ReadonlyArray<JSONSerializable>
| { +[string]: JSONSerializable };
export type DeepReadOnly<T> =
T extends ReadonlyArray<infer V>
? ReadonlyArray<DeepReadOnly<V>>
: T extends { ... }
? { +[K in keyof T]: DeepReadOnly<T[K]> }
: T;

View File

@@ -0,0 +1,34 @@
/**
* 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.
*
*
* @format
*/
import type { InspectorProxyQueries } from "../inspector-proxy/InspectorProxy";
import type { DevToolLauncher } from "../types/DevToolLauncher";
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { Logger } from "../types/Logger";
import type { ReadonlyURL } from "../types/ReadonlyURL";
import type { NextHandleFunction } from "connect";
type Options = Readonly<{
serverBaseUrl: ReadonlyURL;
logger?: Logger;
toolLauncher: DevToolLauncher;
eventReporter?: EventReporter;
experiments: Experiments;
inspectorProxy: InspectorProxyQueries;
}>;
/**
* Open the debugger frontend for a given CDP target.
*
* @see https://chromedevtools.github.io/devtools-protocol/
*/
declare function openDebuggerMiddleware(
$$PARAM_0$$: Options,
): NextHandleFunction;
export default openDebuggerMiddleware;

View File

@@ -0,0 +1,206 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = openDebuggerMiddleware;
var _getDevToolsFrontendUrl = _interopRequireDefault(
require("../utils/getDevToolsFrontendUrl"),
);
var _crypto = require("crypto");
function _interopRequireDefault(e) {
return e && e.__esModule ? e : { default: e };
}
const LEGACY_SYNTHETIC_PAGE_TITLE =
"React Native Experimental (Improved Chrome Reloads)";
function openDebuggerMiddleware({
serverBaseUrl,
logger,
toolLauncher,
eventReporter,
experiments,
inspectorProxy,
}) {
let shellPreparationPromise;
if (experiments.enableStandaloneFuseboxShell) {
shellPreparationPromise =
toolLauncher?.prepareDebuggerShell?.() ??
Promise.resolve({
code: "not_implemented",
});
shellPreparationPromise = shellPreparationPromise.then((result) => {
eventReporter?.logEvent({
type: "fusebox_shell_preparation_attempt",
result,
});
return result;
});
}
return async (req, res, next) => {
if (
req.method === "POST" ||
(experiments.enableOpenDebuggerRedirect && req.method === "GET")
) {
const { searchParams } = new URL(req.url, "http://example.com");
const query = Object.fromEntries(searchParams);
const targets = inspectorProxy
.getPageDescriptions({
requestorRelativeBaseUrl: serverBaseUrl,
})
.filter((app) => {
const betterReloadingSupport =
app.title === LEGACY_SYNTHETIC_PAGE_TITLE ||
app.reactNative.capabilities?.nativePageReloads === true;
if (!betterReloadingSupport) {
logger?.warn(
"Ignoring DevTools app debug target for '%s' with title '%s' and 'nativePageReloads' capability set to '%s'. ",
app.appId,
app.title,
String(app.reactNative.capabilities?.nativePageReloads),
);
}
return betterReloadingSupport;
});
let target;
const launchType = req.method === "POST" ? "launch" : "redirect";
if (
typeof query.target === "string" ||
typeof query.appId === "string" ||
typeof query.device === "string"
) {
logger?.info(
(launchType === "launch" ? "Launching" : "Redirecting to") +
" DevTools...",
);
target = targets.find(
(_target) =>
(query.target == null || _target.id === query.target) &&
(query.appId == null ||
(_target.appId === query.appId &&
_target.title === LEGACY_SYNTHETIC_PAGE_TITLE)) &&
(query.device == null ||
_target.reactNative.logicalDeviceId === query.device),
);
} else if (targets.length > 0) {
logger?.info(
(launchType === "launch" ? "Launching" : "Redirecting to") +
` DevTools${targets.length === 1 ? "" : " for most recently connected target"}...`,
);
target = targets[targets.length - 1];
}
if (!target) {
res.writeHead(404);
res.end("Unable to find debugger target");
logger?.warn(
"No compatible apps connected. React Native DevTools can only be used with the Hermes engine.",
);
eventReporter?.logEvent({
type: "launch_debugger_frontend",
launchType,
status: "coded_error",
errorCode: "NO_APPS_FOUND",
});
return;
}
try {
switch (launchType) {
case "launch": {
const frontendUrl = (0, _getDevToolsFrontendUrl.default)(
experiments,
target.webSocketDebuggerUrl,
new URL(serverBaseUrl),
{
launchId: query.launchId,
telemetryInfo: query.telemetryInfo,
appId: target.appId,
panel: query.panel,
},
);
let shouldUseStandaloneFuseboxShell =
experiments.enableStandaloneFuseboxShell;
if (shouldUseStandaloneFuseboxShell) {
const shellPreparationResult = await shellPreparationPromise;
switch (shellPreparationResult.code) {
case "success":
case "not_implemented":
break;
case "platform_not_supported":
case "possible_corruption":
case "likely_offline":
case "unexpected_error":
shouldUseStandaloneFuseboxShell = false;
break;
default:
shellPreparationResult.code;
}
}
if (shouldUseStandaloneFuseboxShell) {
const windowKey = (0, _crypto.createHash)("sha256")
.update(
[
serverBaseUrl,
target.webSocketDebuggerUrl,
target.appId,
].join("-"),
)
.digest("hex");
if (!toolLauncher.launchDebuggerShell) {
throw new Error(
"Fusebox shell is not supported by the current app launcher",
);
}
await toolLauncher.launchDebuggerShell(frontendUrl, windowKey);
} else {
await toolLauncher.launchDebuggerAppWindow(frontendUrl);
}
res.writeHead(200);
res.end();
break;
}
case "redirect":
res.writeHead(302, {
Location: (0, _getDevToolsFrontendUrl.default)(
experiments,
target.webSocketDebuggerUrl,
new URL(serverBaseUrl),
{
relative: true,
launchId: query.launchId,
telemetryInfo: query.telemetryInfo,
appId: target.appId,
},
),
});
res.end();
break;
default:
}
eventReporter?.logEvent({
type: "launch_debugger_frontend",
launchType,
status: "success",
appId: target.appId,
deviceId: target.reactNative.logicalDeviceId,
pageId: target.id,
deviceName: target.deviceName,
targetDescription: target.description,
});
return;
} catch (e) {
logger?.error(
"Error launching DevTools: " + e.message ?? "Unknown error",
);
res.writeHead(500);
res.end();
eventReporter?.logEvent({
type: "launch_debugger_frontend",
launchType,
status: "error",
error: e,
});
return;
}
}
next();
};
}

View File

@@ -0,0 +1,34 @@
/**
* 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 { InspectorProxyQueries } from "../inspector-proxy/InspectorProxy";
import type { DevToolLauncher } from "../types/DevToolLauncher";
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { Logger } from "../types/Logger";
import type { ReadonlyURL } from "../types/ReadonlyURL";
import type { NextHandleFunction } from "connect";
type Options = Readonly<{
serverBaseUrl: ReadonlyURL,
logger?: Logger,
toolLauncher: DevToolLauncher,
eventReporter?: EventReporter,
experiments: Experiments,
inspectorProxy: InspectorProxyQueries,
}>;
/**
* Open the debugger frontend for a given CDP target.
*
* @see https://chromedevtools.github.io/devtools-protocol/
*/
declare export default function openDebuggerMiddleware(
$$PARAM_0$$: Options,
): NextHandleFunction;

View File

@@ -0,0 +1,63 @@
/**
* 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.
*
*
* @format
*/
import type { DebuggerShellPreparationResult } from "@react-native/debugger-shell";
export type { DebuggerShellPreparationResult };
/**
* An interface for integrators to provide a custom implementation for
* launching external applications on the host machine (or target dev machine).
*
* This is an unstable API with no semver guarantees.
*/
export interface DevToolLauncher {
/**
* Attempt to open a debugger frontend URL in a browser app window. The
* browser used should be capable of running Chrome DevTools.
*
* The provided URL is based on serverBaseUrl, and therefore reachable from
* the host of dev-middleware. Implementations are responsible for rewriting
* this as necessary where the server is remote.
*/
readonly launchDebuggerAppWindow: (url: string) => Promise<void>;
/**
* Attempt to open a debugger frontend URL in a standalone shell window
* designed specifically for React Native DevTools. The provided windowKey
* should be used to identify an existing window that can be reused instead
* of opening a new one.
*
* Implementations SHOULD treat an existing session with the same windowKey
* (as long as it's still connected and healthy) as equaivalent to a new
* session with the new URL, even if the launch URLs for the two sessions are
* not identical. Implementations SHOULD NOT unnecessarily close and reopen
* the connection when reusing a session. Implementations SHOULD process any
* changed/new parameters in the URL and update the session accordingly (e.g.
* to preserve telemetry data that may have changed).
*
* The provided URL is based on serverBaseUrl, and therefore reachable from
* the host of dev-middleware. Implementations are responsible for rewriting
* this as necessary where the server is remote.
*/
readonly launchDebuggerShell?: (
url: string,
windowKey: string,
) => Promise<void>;
/**
* Attempt to prepare the debugger shell for use and returns a coded result
* that can be used to advise the user on how to proceed in case of failure.
*
* This function MAY be called multiple times or not at all. Implementers
* SHOULD use the opportunity to prefetch and cache any expensive resources (e.g
* platform-specific binaries needed in order to show the Fusebox shell). After a
* successful call, subsequent calls SHOULD complete quickly. The implementation
* SHOULD NOT return a rejecting promise in any case, and instead SHOULD report
* errors via the returned result object.
*/
readonly prepareDebuggerShell?: () => Promise<DebuggerShellPreparationResult>;
}

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,64 @@
/**
* 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 { DebuggerShellPreparationResult } from "@react-native/debugger-shell";
export type { DebuggerShellPreparationResult };
/**
* An interface for integrators to provide a custom implementation for
* launching external applications on the host machine (or target dev machine).
*
* This is an unstable API with no semver guarantees.
*/
export interface DevToolLauncher {
/**
* Attempt to open a debugger frontend URL in a browser app window. The
* browser used should be capable of running Chrome DevTools.
*
* The provided URL is based on serverBaseUrl, and therefore reachable from
* the host of dev-middleware. Implementations are responsible for rewriting
* this as necessary where the server is remote.
*/
+launchDebuggerAppWindow: (url: string) => Promise<void>;
/**
* Attempt to open a debugger frontend URL in a standalone shell window
* designed specifically for React Native DevTools. The provided windowKey
* should be used to identify an existing window that can be reused instead
* of opening a new one.
*
* Implementations SHOULD treat an existing session with the same windowKey
* (as long as it's still connected and healthy) as equaivalent to a new
* session with the new URL, even if the launch URLs for the two sessions are
* not identical. Implementations SHOULD NOT unnecessarily close and reopen
* the connection when reusing a session. Implementations SHOULD process any
* changed/new parameters in the URL and update the session accordingly (e.g.
* to preserve telemetry data that may have changed).
*
* The provided URL is based on serverBaseUrl, and therefore reachable from
* the host of dev-middleware. Implementations are responsible for rewriting
* this as necessary where the server is remote.
*/
+launchDebuggerShell?: (url: string, windowKey: string) => Promise<void>;
/**
* Attempt to prepare the debugger shell for use and returns a coded result
* that can be used to advise the user on how to proceed in case of failure.
*
* This function MAY be called multiple times or not at all. Implementers
* SHOULD use the opportunity to prefetch and cache any expensive resources (e.g
* platform-specific binaries needed in order to show the Fusebox shell). After a
* successful call, subsequent calls SHOULD complete quickly. The implementation
* SHOULD NOT return a rejecting promise in any case, and instead SHOULD report
* errors via the returned result object.
*/
+prepareDebuggerShell?: () => Promise<DebuggerShellPreparationResult>;
}

View File

@@ -0,0 +1,121 @@
/**
* 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.
*
*
* @format
*/
import type { DebuggerShellPreparationResult } from "./DevToolLauncher";
type SuccessResult<Props extends {} | void = {}> =
/**
* > 15 | ...Props,
* | ^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any;
type ErrorResult<ErrorT = unknown, Props extends {} | void = {}> =
/**
* > 21 | ...Props,
* | ^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any;
type CodedErrorResult<ErrorCode extends string> = {
status: "coded_error";
errorCode: ErrorCode;
errorDetails?: string;
};
export type DebuggerSessionIDs = {
appId: string | null;
deviceName: string | null;
deviceId: string | null;
pageId: string | null;
};
export type ConnectionUptime = { connectionUptime: number };
export type ReportableEvent =
| /**
* > 45 | ...
* | ^^^
* > 46 | | SuccessResult<{
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 47 | targetDescription: string,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 48 | ...DebuggerSessionIDs,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 49 | }>
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 50 | | ErrorResult<unknown>
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 51 | | CodedErrorResult<"NO_APPS_FOUND">,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 55 | ...
* | ^^^
* > 56 | | SuccessResult<{
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 57 | ...DebuggerSessionIDs,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 58 | frontendUserAgent: string | null,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 59 | }>
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 60 | | ErrorResult<unknown, DebuggerSessionIDs>,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 70 | ...DebuggerSessionIDs,
* | ^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 86 | ...DebuggerSessionIDs,
* | ^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 90 | ...DebuggerSessionIDs,
* | ^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 99 | ...ConnectionUptime,
* | ^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 106 | ...ConnectionUptime,
* | ^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 113 | ...ConnectionUptime,
* | ^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 121 | ...ConnectionUptime,
* | ^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 129 | ...ConnectionUptime,
* | ^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| {
type: "fusebox_shell_preparation_attempt";
result: DebuggerShellPreparationResult;
};
/**
* A simple interface for logging events, to be implemented by integrators of
* `dev-middleware`.
*
* This is an unstable API with no semver guarantees.
*/
export interface EventReporter {
logEvent(event: ReportableEvent): void;
}

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,145 @@
/**
* 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 { DebuggerShellPreparationResult } from "./DevToolLauncher";
type SuccessResult<Props: { ... } | void = {}> = {
status: "success",
...Props,
};
type ErrorResult<ErrorT = unknown, Props: { ... } | void = {}> = {
status: "error",
error: ErrorT,
...Props,
};
type CodedErrorResult<ErrorCode: string> = {
status: "coded_error",
errorCode: ErrorCode,
errorDetails?: string,
};
export type DebuggerSessionIDs = {
appId: string | null,
deviceName: string | null,
deviceId: string | null,
pageId: string | null,
};
export type ConnectionUptime = {
connectionUptime: number,
};
export type ReportableEvent =
| {
type: "launch_debugger_frontend",
launchType: "launch" | "redirect",
...
| SuccessResult<{
targetDescription: string,
...DebuggerSessionIDs,
}>
| ErrorResult<unknown>
| CodedErrorResult<"NO_APPS_FOUND">,
}
| {
type: "connect_debugger_frontend",
...
| SuccessResult<{
...DebuggerSessionIDs,
frontendUserAgent: string | null,
}>
| ErrorResult<unknown, DebuggerSessionIDs>,
}
| {
type: "debugger_command",
protocol: "CDP",
// With some errors, the method might not be known
method: string | null,
requestOrigin: "proxy" | "debugger" | null,
responseOrigin: "proxy" | "device",
timeSinceStart: number | null,
...DebuggerSessionIDs,
...ConnectionUptime,
frontendUserAgent: string | null,
...
| SuccessResult<void>
| CodedErrorResult<
| "TIMED_OUT"
| "DEVICE_DISCONNECTED"
| "DEBUGGER_DISCONNECTED"
| "UNMATCHED_REQUEST_ID"
| "PROTOCOL_ERROR",
>,
}
| {
type: "profiling_target_registered",
status: "success",
...DebuggerSessionIDs,
}
| {
type: "no_debug_pages_for_device",
...DebuggerSessionIDs,
}
| {
type: "proxy_error",
status: "error",
messageOrigin: "debugger" | "device",
message: string,
error: string,
errorStack: string,
...ConnectionUptime,
...DebuggerSessionIDs,
}
| {
type: "debugger_high_ping" | "device_high_ping",
duration: number,
timeSinceLastCommunication: number | null,
...ConnectionUptime,
...DebuggerSessionIDs,
}
| {
type: "debugger_timeout" | "device_timeout",
duration: number,
timeSinceLastCommunication: number | null,
...ConnectionUptime,
...DebuggerSessionIDs,
}
| {
type: "debugger_connection_closed" | "device_connection_closed",
code: number,
reason: string,
timeSinceLastCommunication: number | null,
...ConnectionUptime,
...DebuggerSessionIDs,
}
| {
type: "high_event_loop_delay",
eventLoopUtilization: number,
maxEventLoopDelayPercent: number,
duration: number,
...ConnectionUptime,
...DebuggerSessionIDs,
}
| {
type: "fusebox_shell_preparation_attempt",
result: DebuggerShellPreparationResult,
};
/**
* A simple interface for logging events, to be implemented by integrators of
* `dev-middleware`.
*
* This is an unstable API with no semver guarantees.
*/
export interface EventReporter {
logEvent(event: ReportableEvent): void;
}

View File

@@ -0,0 +1,34 @@
/**
* 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.
*
*
* @format
*/
export type Experiments = Readonly<{
/**
* Enables the handling of GET requests in the /open-debugger endpoint,
* in addition to POST requests. GET requests respond by redirecting to
* the debugger frontend, instead of opening it using the DevToolLauncher
* interface.
*/
enableOpenDebuggerRedirect: boolean;
/**
* Enables the Network panel in the debugger frontend.
*/
enableNetworkInspector: boolean;
/**
* Launch the debugger frontend in a standalone shell instead of a browser.
* When this is enabled, we will use the optional launchDebuggerShell
* method on the DevToolLauncher, or throw an error if the method is missing.
*
* NOTE: Disabling this also disables support for concurrent sessions in the
* inspector proxy. Without the standalone shell, the proxy remains responsible
* for keeping only one debugger frontend active at a time per page.
*/
enableStandaloneFuseboxShell: boolean;
}>;
export type ExperimentsConfig = Partial<Experiments>;

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,38 @@
/**
* 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
*/
export type Experiments = Readonly<{
/**
* Enables the handling of GET requests in the /open-debugger endpoint,
* in addition to POST requests. GET requests respond by redirecting to
* the debugger frontend, instead of opening it using the DevToolLauncher
* interface.
*/
enableOpenDebuggerRedirect: boolean,
/**
* Enables the Network panel in the debugger frontend.
*/
// NOTE: Used by Expo, exposing a tab labelled "Network (Expo)"
enableNetworkInspector: boolean,
/**
* Launch the debugger frontend in a standalone shell instead of a browser.
* When this is enabled, we will use the optional launchDebuggerShell
* method on the DevToolLauncher, or throw an error if the method is missing.
*
* NOTE: Disabling this also disables support for concurrent sessions in the
* inspector proxy. Without the standalone shell, the proxy remains responsible
* for keeping only one debugger frontend active at a time per page.
*/
enableStandaloneFuseboxShell: boolean,
}>;
export type ExperimentsConfig = Partial<Experiments>;

View File

@@ -0,0 +1,15 @@
/**
* 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.
*
*
* @format
*/
export type Logger = Readonly<{
error: (...message: Array<string>) => void;
info: (...message: Array<string>) => void;
warn: (...message: Array<string>) => void;
}>;

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,16 @@
/**
* 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
* @format
*/
export type Logger = Readonly<{
error: (...message: Array<string>) => void,
info: (...message: Array<string>) => void,
warn: (...message: Array<string>) => void,
...
}>;

View File

@@ -0,0 +1,53 @@
/**
* 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.
*
*
* @format
*/
/**
* A readonly view of URLSearchParams that only exposes read operations.
*/
export interface ReadonlyURLSearchParams {
get(name: string): string | null;
getAll(name: string): Array<string>;
has(name: string, value?: string): boolean;
readonly size: number;
entries(): Iterator<[string, string]>;
keys(): Iterator<string>;
values(): Iterator<string>;
forEach<This>(
callback: (
this: This,
value: string,
name: string,
params: URLSearchParams,
) => unknown,
thisArg: This,
): void;
toString(): string;
@@iterator(): Iterator<[string, string]>;
}
/**
* A readonly view of URL that prevents mutation of URL properties.
* Used for URLs passed between module boundaries.
*/
export interface ReadonlyURL {
readonly hash: string;
readonly host: string;
readonly hostname: string;
readonly href: string;
readonly origin: string;
readonly password: string;
readonly pathname: string;
readonly port: string;
readonly protocol: string;
readonly search: string;
readonly searchParams: ReadonlyURLSearchParams;
readonly username: string;
toString(): string;
toJSON(): string;
}

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,54 @@
/**
* 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
*/
/**
* A readonly view of URLSearchParams that only exposes read operations.
*/
export interface ReadonlyURLSearchParams {
get(name: string): string | null;
getAll(name: string): Array<string>;
has(name: string, value?: string): boolean;
+size: number;
entries(): Iterator<[string, string]>;
keys(): Iterator<string>;
values(): Iterator<string>;
forEach<This>(
callback: (
this: This,
value: string,
name: string,
params: URLSearchParams,
) => mixed,
thisArg: This,
): void;
toString(): string;
@@iterator(): Iterator<[string, string]>;
}
/**
* A readonly view of URL that prevents mutation of URL properties.
* Used for URLs passed between module boundaries.
*/
export interface ReadonlyURL {
+hash: string;
+host: string;
+hostname: string;
+href: string;
+origin: string;
+password: string;
+pathname: string;
+port: string;
+protocol: string;
+search: string;
+searchParams: ReadonlyURLSearchParams;
+username: string;
toString(): string;
toJSON(): string;
}

View File

@@ -0,0 +1,26 @@
/**
* 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.
*
*
* @format
*/
import type { DebuggerShellPreparationResult } from "../types/DevToolLauncher";
/**
* Default `DevToolLauncher` implementation which handles opening apps on the
* local machine.
*/
declare const DefaultToolLauncher: {
launchDebuggerAppWindow: (url: string) => Promise<void>;
launchDebuggerShell(url: string, windowKey: string): Promise<void>;
prepareDebuggerShell(
prebuiltBinaryPath?: null | undefined | string,
): Promise<DebuggerShellPreparationResult>;
};
declare const $$EXPORT_DEFAULT_DECLARATION$$: typeof DefaultToolLauncher;
declare type $$EXPORT_DEFAULT_DECLARATION$$ =
typeof $$EXPORT_DEFAULT_DECLARATION$$;
export default $$EXPORT_DEFAULT_DECLARATION$$;

View File

@@ -0,0 +1,74 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
const {
unstable_prepareDebuggerShell,
unstable_spawnDebuggerShellWithArgs,
} = require("@react-native/debugger-shell");
const { spawn } = require("child_process");
const ChromeLauncher = require("chrome-launcher");
const { Launcher: EdgeLauncher } = require("chromium-edge-launcher");
const open = require("open");
const DefaultToolLauncher = {
launchDebuggerAppWindow: async (url) => {
if (process.env.NODE_ENV === "test") {
assertMockedInTests();
}
let chromePath;
try {
chromePath = ChromeLauncher.getChromePath();
} catch (e) {
chromePath = EdgeLauncher.getFirstInstallation();
}
if (chromePath == null) {
await open(url);
return;
}
const chromeFlags = [`--app=${url}`, "--window-size=1200,600"];
return new Promise((resolve, reject) => {
const childProcess = spawn(chromePath, chromeFlags, {
detached: true,
stdio: "ignore",
});
childProcess.on("data", () => {
resolve();
});
childProcess.on("close", (code) => {
if (code !== 0) {
reject(
new Error(
`Failed to launch debugger app window: ${chromePath} exited with code ${code}`,
),
);
}
});
});
},
async launchDebuggerShell(url, windowKey) {
if (process.env.NODE_ENV === "test") {
assertMockedInTests();
}
return await unstable_spawnDebuggerShellWithArgs([
"--frontendUrl=" + url,
"--windowKey=" + windowKey,
]);
},
async prepareDebuggerShell(prebuiltBinaryPath) {
if (process.env.NODE_ENV === "test") {
assertMockedInTests();
}
return await unstable_prepareDebuggerShell();
},
};
function assertMockedInTests() {
if (process.env.NODE_ENV === "test") {
throw new Error(
"DefaultToolLauncher must be mocked or overridden in tests. " +
"Add jest.mock('../utils/DefaultAppLauncher') to test setup.",
);
}
}
var _default = (exports.default = DefaultToolLauncher);

View File

@@ -0,0 +1,25 @@
/**
* 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 { DebuggerShellPreparationResult } from "../types/DevToolLauncher";
/**
* Default `DevToolLauncher` implementation which handles opening apps on the
* local machine.
*/
declare const DefaultToolLauncher: {
launchDebuggerAppWindow: (url: string) => Promise<void>,
launchDebuggerShell(url: string, windowKey: string): Promise<void>,
prepareDebuggerShell(
prebuiltBinaryPath?: ?string,
): Promise<DebuggerShellPreparationResult>,
};
declare export default typeof DefaultToolLauncher;

View File

@@ -0,0 +1,14 @@
/**
* 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.
*
*
* @format
*/
declare function getBaseUrlFromRequest(
req: http$IncomingMessage<tls$TLSSocket> | http$IncomingMessage<net$Socket>,
): null | undefined | URL;
export default getBaseUrlFromRequest;

View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = getBaseUrlFromRequest;
function getBaseUrlFromRequest(req) {
const hostHeader = req.headers.host;
if (hostHeader == null) {
return null;
}
const scheme = req.socket.encrypted === true ? "https" : "http";
const url = `${scheme}://${req.headers.host}`;
try {
return new URL(url);
} catch {
return null;
}
}

View File

@@ -0,0 +1,17 @@
/**
* 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
*/
// Determine the base URL (scheme and host) used by a client to reach this
// server.
//
// TODO: Support X-Forwarded-Host, etc. for trusted proxies
declare export default function getBaseUrlFromRequest(
req: http$IncomingMessage<tls$TLSSocket> | http$IncomingMessage<net$Socket>,
): ?URL;

View File

@@ -0,0 +1,28 @@
/**
* 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.
*
*
* @format
*/
import type { Experiments } from "../types/Experiments";
import type { ReadonlyURL } from "../types/ReadonlyURL";
/**
* Get the DevTools frontend URL to debug a given React Native CDP target.
*/
declare function getDevToolsFrontendUrl(
experiments: Experiments,
webSocketDebuggerUrl: string,
devServerUrl: ReadonlyURL,
options?: Readonly<{
relative?: boolean;
launchId?: string;
telemetryInfo?: string;
appId?: string;
panel?: string;
}>,
): string;
export default getDevToolsFrontendUrl;

View File

@@ -0,0 +1,55 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = getDevToolsFrontendUrl;
function getDevToolsFrontendUrl(
experiments,
webSocketDebuggerUrl,
devServerUrl,
options,
) {
const wsParam = getWsParam({
webSocketDebuggerUrl,
devServerUrl,
});
const appUrl =
(options?.relative === true ? "" : devServerUrl.origin) +
"/debugger-frontend/rn_fusebox.html";
const searchParams = new URLSearchParams([
[wsParam.key, wsParam.value],
["sources.hide_add_folder", "true"],
]);
if (experiments.enableNetworkInspector) {
searchParams.append("unstable_enableNetworkPanel", "true");
}
if (options?.launchId != null && options.launchId !== "") {
searchParams.append("launchId", options.launchId);
}
if (options?.appId != null && options.appId !== "") {
searchParams.append("appId", options.appId);
}
if (options?.telemetryInfo != null && options.telemetryInfo !== "") {
searchParams.append("telemetryInfo", options.telemetryInfo);
}
if (options?.panel != null && options.panel !== "") {
searchParams.append("panel", options.panel);
}
return appUrl + "?" + searchParams.toString();
}
function getWsParam({ webSocketDebuggerUrl, devServerUrl }) {
const wsUrl = new URL(webSocketDebuggerUrl);
const serverHost = devServerUrl.host;
let value;
if (wsUrl.host === serverHost) {
value = wsUrl.pathname + wsUrl.search + wsUrl.hash;
} else {
value = wsUrl.host + wsUrl.pathname + wsUrl.search + wsUrl.hash;
}
const key = wsUrl.protocol.slice(0, -1);
return {
key,
value,
};
}

View File

@@ -0,0 +1,28 @@
/**
* 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 { Experiments } from "../types/Experiments";
import type { ReadonlyURL } from "../types/ReadonlyURL";
/**
* Get the DevTools frontend URL to debug a given React Native CDP target.
*/
declare export default function getDevToolsFrontendUrl(
experiments: Experiments,
webSocketDebuggerUrl: string,
devServerUrl: ReadonlyURL,
options?: Readonly<{
relative?: boolean,
launchId?: string,
telemetryInfo?: string,
appId?: string,
panel?: string,
}>,
): string;