feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,61 @@
/**
* 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
* @oncall react_native
*/
import type { CreateCustomMessageHandlerFn } from "./inspector-proxy/CustomMessageHandler";
import type { BrowserLauncher } from "./types/BrowserLauncher";
import type { EventReporter } from "./types/EventReporter";
import type { ExperimentsConfig } from "./types/Experiments";
import type { Logger } from "./types/Logger";
import type { NextHandleFunction } from "connect";
type Options = Readonly<{
projectRoot: string;
/**
* The base URL to the dev server, as addressible from the local developer
* machine. This is used in responses which return URLs to other endpoints,
* e.g. the debugger frontend and inspector proxy targets.
*
* Example: `'http://localhost:8081'`.
*/
serverBaseUrl: string;
logger?: Logger;
/**
* An interface for integrators to provide a custom implementation for
* opening URLs in a web browser.
*
* This is an unstable API with no semver guarantees.
*/
unstable_browserLauncher?: BrowserLauncher;
/**
* An interface for logging events.
*
* 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;
/**
* 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;
}>;
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,90 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = createDevMiddleware;
var _InspectorProxy = _interopRequireDefault(
require("./inspector-proxy/InspectorProxy")
);
var _deprecated_openFlipperMiddleware = _interopRequireDefault(
require("./middleware/deprecated_openFlipperMiddleware")
);
var _openDebuggerMiddleware = _interopRequireDefault(
require("./middleware/openDebuggerMiddleware")
);
var _DefaultBrowserLauncher = _interopRequireDefault(
require("./utils/DefaultBrowserLauncher")
);
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(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
/**
* 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
* @oncall react_native
*/
function createDevMiddleware({
projectRoot,
serverBaseUrl,
logger,
unstable_browserLauncher = _DefaultBrowserLauncher.default,
unstable_eventReporter,
unstable_experiments: experimentConfig = {},
unstable_customInspectorMessageHandler,
}) {
const experiments = getExperiments(experimentConfig);
const inspectorProxy = new _InspectorProxy.default(
projectRoot,
serverBaseUrl,
unstable_eventReporter,
experiments,
unstable_customInspectorMessageHandler
);
const middleware = (0, _connect.default)()
.use(
"/open-debugger",
experiments.enableNewDebugger
? (0, _openDebuggerMiddleware.default)({
serverBaseUrl,
inspectorProxy,
browserLauncher: unstable_browserLauncher,
eventReporter: unstable_eventReporter,
experiments,
logger,
})
: (0, _deprecated_openFlipperMiddleware.default)({
logger,
})
)
.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 {
enableNewDebugger: config.enableNewDebugger ?? false,
enableOpenDebuggerRedirect: config.enableOpenDebuggerRedirect ?? false,
enableNetworkInspector: config.enableNetworkInspector ?? false,
};
}

View File

@@ -0,0 +1,69 @@
/**
* 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
* @oncall react_native
*/
import type { CreateCustomMessageHandlerFn } from "./inspector-proxy/CustomMessageHandler";
import type { BrowserLauncher } from "./types/BrowserLauncher";
import type { EventReporter } from "./types/EventReporter";
import type { ExperimentsConfig } from "./types/Experiments";
import type { Logger } from "./types/Logger";
import type { NextHandleFunction } from "connect";
type Options = $ReadOnly<{
projectRoot: string,
/**
* The base URL to the dev server, as addressible from the local developer
* machine. This is used in responses which return URLs to other endpoints,
* e.g. the debugger frontend and inspector proxy targets.
*
* Example: `'http://localhost:8081'`.
*/
serverBaseUrl: string,
logger?: Logger,
/**
* An interface for integrators to provide a custom implementation for
* opening URLs in a web browser.
*
* This is an unstable API with no semver guarantees.
*/
unstable_browserLauncher?: BrowserLauncher,
/**
* An interface for logging events.
*
* 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,
/**
* 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,
}>;
type DevMiddlewareAPI = $ReadOnly<{
middleware: NextHandleFunction,
websocketEndpoints: { [path: string]: ws$WebSocketServer },
}>;
declare export default function createDevMiddleware(Options): DevMiddlewareAPI;

View File

@@ -0,0 +1,12 @@
/**
* 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
* @oncall react_native
*/
export * from "./index.flow";

View File

@@ -0,0 +1,19 @@
/**
* 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
* @oncall react_native
*/
export { default as createDevMiddleware } from "./createDevMiddleware";
export type { BrowserLauncher, LaunchedBrowser } from "./types/BrowserLauncher";
export type { EventReporter, ReportableEvent } from "./types/EventReporter";
export type {
CustomMessageHandler,
CustomMessageHandlerConnection,
CreateCustomMessageHandlerFn,
} from "./inspector-proxy/CustomMessageHandler";

View File

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

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
* @oncall react_native
*/
export { default as createDevMiddleware } from "./createDevMiddleware";
export type { BrowserLauncher, LaunchedBrowser } from "./types/BrowserLauncher";
export type { EventReporter, ReportableEvent } from "./types/EventReporter";
export type {
CustomMessageHandler,
CustomMessageHandlerConnection,
CreateCustomMessageHandlerFn,
} from "./inspector-proxy/CustomMessageHandler";

View File

@@ -0,0 +1,16 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
var _index = require("./index.flow");
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function () {
return _index[key];
},
});
});

View File

@@ -0,0 +1,12 @@
/**
* 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
* @format
* @oncall react_native
*/
export * from "./index.flow";

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,40 @@
/**
* 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
* @oncall react_native
*/
import type { EventReporter } from "../types/EventReporter";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { Page } from "./types";
import WS from "ws";
/**
* Device class represents single device connection to Inspector Proxy. Each device
* can have multiple inspectable pages.
*/
declare class Device {
constructor(
id: string,
name: string,
app: string,
socket: WS,
projectRoot: string,
eventReporter: null | undefined | EventReporter,
createMessageMiddleware: null | undefined | CreateCustomMessageHandlerFn
);
getName(): string;
getApp(): string;
getPagesList(): ReadonlyArray<Page>;
handleDebuggerConnection(
socket: WS,
pageId: string,
metadata: Readonly<{ userAgent: string | null }>
): void;
handleDuplicateDeviceConnection(newDevice: Device): void;
}
export default Device;

View File

@@ -0,0 +1,872 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _DeviceEventReporter = _interopRequireDefault(
require("./DeviceEventReporter")
);
var fs = _interopRequireWildcard(require("fs"));
var _nodeFetch = _interopRequireDefault(require("node-fetch"));
var path = _interopRequireWildcard(require("path"));
var _ws = _interopRequireDefault(require("ws"));
function _getRequireWildcardCache(nodeInterop) {
if (typeof WeakMap !== "function") return null;
var cacheBabelInterop = new WeakMap();
var cacheNodeInterop = new WeakMap();
return (_getRequireWildcardCache = function (nodeInterop) {
return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
})(nodeInterop);
}
function _interopRequireWildcard(obj, nodeInterop) {
if (!nodeInterop && obj && obj.__esModule) {
return obj;
}
if (obj === null || (typeof obj !== "object" && typeof obj !== "function")) {
return { default: obj };
}
var cache = _getRequireWildcardCache(nodeInterop);
if (cache && cache.has(obj)) {
return cache.get(obj);
}
var newObj = {};
var hasPropertyDescriptor =
Object.defineProperty && Object.getOwnPropertyDescriptor;
for (var key in obj) {
if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) {
var desc = hasPropertyDescriptor
? Object.getOwnPropertyDescriptor(obj, key)
: null;
if (desc && (desc.get || desc.set)) {
Object.defineProperty(newObj, key, desc);
} else {
newObj[key] = obj[key];
}
}
}
newObj.default = obj;
if (cache) {
cache.set(obj, newObj);
}
return newObj;
}
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
/**
* 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
* @oncall react_native
*/
const debug = require("debug")("Metro:InspectorProxy");
const PAGES_POLLING_INTERVAL = 1000;
// Android's stock emulator and other emulators such as genymotion use a standard localhost alias.
const EMULATOR_LOCALHOST_ADDRESSES = ["10.0.2.2", "10.0.3.2"];
// Prefix for script URLs that are alphanumeric IDs. See comment in #processMessageFromDeviceLegacy method for
// more details.
const FILE_PREFIX = "file://";
const REACT_NATIVE_RELOADABLE_PAGE_ID = "-1";
/**
* Device class represents single device connection to Inspector Proxy. Each device
* can have multiple inspectable pages.
*/
class Device {
// ID of the device.
#id;
// Name of the device.
#name;
// Package name of the app.
#app;
// Stores socket connection between Inspector Proxy and device.
#deviceSocket;
// Stores the most recent listing of device's pages, keyed by the `id` field.
#pages;
// Stores information about currently connected debugger (if any).
#debuggerConnection = null;
// Last known Page ID of the React Native page.
// This is used by debugger connections that don't have PageID specified
// (and will interact with the latest React Native page).
#lastConnectedLegacyReactNativePage = null;
// Whether we are in the middle of a reload in the REACT_NATIVE_RELOADABLE_PAGE.
#isLegacyPageReloading = false;
// The previous "GetPages" message, for deduplication in debug logs.
#lastGetPagesMessage = "";
// Mapping built from scriptParsed events and used to fetch file content in `Debugger.getScriptSource`.
#scriptIdToSourcePathMapping = new Map();
// Root of the project used for relative to absolute source path conversion.
#projectRoot;
#deviceEventReporter;
#pagesPollingIntervalId;
// The device message middleware factory function allowing implementers to handle unsupported CDP messages.
#createCustomMessageHandler;
constructor(
id,
name,
app,
socket,
projectRoot,
eventReporter,
createMessageMiddleware
) {
this.#id = id;
this.#name = name;
this.#app = app;
this.#pages = new Map();
this.#deviceSocket = socket;
this.#projectRoot = projectRoot;
this.#deviceEventReporter = eventReporter
? new _DeviceEventReporter.default(eventReporter, {
deviceId: id,
deviceName: name,
appId: app,
})
: null;
this.#createCustomMessageHandler = createMessageMiddleware;
// $FlowFixMe[incompatible-call]
this.#deviceSocket.on("message", (message) => {
const parsedMessage = JSON.parse(message);
if (parsedMessage.event === "getPages") {
// There's a 'getPages' message every second, so only show them if they change
if (message !== this.#lastGetPagesMessage) {
debug(
"(Debugger) (Proxy) <- (Device), getPages ping has changed: " +
message
);
this.#lastGetPagesMessage = message;
}
} else {
debug("(Debugger) (Proxy) <- (Device): " + message);
}
this.#handleMessageFromDevice(parsedMessage);
});
// Sends 'getPages' request to device every PAGES_POLLING_INTERVAL milliseconds.
this.#pagesPollingIntervalId = setInterval(
() =>
this.#sendMessageToDevice({
event: "getPages",
}),
PAGES_POLLING_INTERVAL
);
this.#deviceSocket.on("close", () => {
this.#deviceEventReporter?.logDisconnection("device");
// Device disconnected - close debugger connection.
if (this.#debuggerConnection) {
this.#debuggerConnection.socket.close();
this.#debuggerConnection = null;
}
clearInterval(this.#pagesPollingIntervalId);
});
}
getName() {
return this.#name;
}
getApp() {
return this.#app;
}
getPagesList() {
if (this.#lastConnectedLegacyReactNativePage) {
return [...this.#pages.values(), this.#createSyntheticPage()];
} else {
return [...this.#pages.values()];
}
}
// 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, pageId, metadata) {
// Clear any commands we were waiting on.
this.#deviceEventReporter?.logDisconnection("debugger");
this.#deviceEventReporter?.logConnection("debugger", {
pageId,
frontendUserAgent: metadata.userAgent,
});
// Disconnect current debugger if we already have debugger connected.
if (this.#debuggerConnection) {
this.#debuggerConnection.socket.close();
this.#debuggerConnection = null;
}
const debuggerInfo = {
socket,
prependedFilePrefix: false,
pageId,
userAgent: metadata.userAgent,
customHandler: null,
};
// TODO(moti): Handle null case explicitly, e.g. refuse to connect to
// unknown pages.
const page =
pageId === REACT_NATIVE_RELOADABLE_PAGE_ID
? this.#createSyntheticPage()
: this.#pages.get(pageId);
this.#debuggerConnection = debuggerInfo;
debug(`Got new debugger connection for page ${pageId} of ${this.#name}`);
if (page && this.#debuggerConnection && this.#createCustomMessageHandler) {
this.#debuggerConnection.customHandler = this.#createCustomMessageHandler(
{
page,
debugger: {
userAgent: debuggerInfo.userAgent,
sendMessage: (message) => {
try {
const payload = JSON.stringify(message);
debug("(Debugger) <- (Proxy) (Device): " + 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),
},
});
debug("(Debugger) -> (Proxy) (Device): " + payload);
this.#deviceSocket.send(payload);
} catch {}
},
},
}
);
if (this.#debuggerConnection.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.#sendMessageToDevice({
event: "connect",
payload: {
pageId: this.#mapToDevicePageId(pageId),
},
});
// $FlowFixMe[incompatible-call]
socket.on("message", (message) => {
debug("(Debugger) -> (Proxy) (Device): " + message);
const debuggerRequest = JSON.parse(message);
this.#deviceEventReporter?.logRequest(debuggerRequest, "debugger", {
pageId: this.#debuggerConnection?.pageId ?? null,
frontendUserAgent: metadata.userAgent,
});
let processedReq = debuggerRequest;
if (
this.#debuggerConnection?.customHandler?.handleDebuggerMessage(
debuggerRequest
) === true
) {
return;
}
if (!page || !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),
},
});
}
});
socket.on("close", () => {
debug(`Debugger for page ${pageId} and ${this.#name} disconnected.`);
this.#deviceEventReporter?.logDisconnection("debugger");
this.#sendMessageToDevice({
event: "disconnect",
payload: {
pageId: this.#mapToDevicePageId(pageId),
},
});
this.#debuggerConnection = null;
});
// $FlowFixMe[method-unbinding]
const sendFunc = socket.send;
// $FlowFixMe[cannot-write]
socket.send = function (message) {
debug("(Debugger) <- (Proxy) (Device): " + message);
return sendFunc.call(socket, message);
};
}
/**
* Handles cleaning up a duplicate device connection, by client-side device ID.
* 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 allows users to reload the app, either as result of a crash, or manually
* reloading, without having to restart the debugger.
*/
handleDuplicateDeviceConnection(newDevice) {
if (
this.#app !== newDevice.getApp() ||
this.#name !== newDevice.getName()
) {
this.#deviceSocket.close();
this.#debuggerConnection?.socket.close();
}
const oldDebugger = this.#debuggerConnection;
this.#debuggerConnection = null;
if (oldDebugger) {
oldDebugger.socket.removeAllListeners();
this.#deviceSocket.close();
newDevice.handleDebuggerConnection(
oldDebugger.socket,
oldDebugger.pageId,
{
userAgent: oldDebugger.userAgent,
}
);
}
}
/**
* Returns `true` if a page supports the given target capability flag.
*/
#pageHasCapability(page, flag) {
return page.capabilities[flag] === true;
}
/**
* Returns the synthetic "React Native Experimental (Improved Chrome Reloads)" page.
*/
#createSyntheticPage() {
return {
id: REACT_NATIVE_RELOADABLE_PAGE_ID,
title: "React Native Experimental (Improved Chrome Reloads)",
vm: "don't use",
app: this.#app,
capabilities: {},
};
}
// Handles messages received from device:
// 1. For getPages responses updates local #pages list.
// 2. All other messages are forwarded to debugger as wrappedEvent.
//
// In the future more logic will be added to this method for modifying
// some of the messages (like updating messages with source maps and file
// locations).
#handleMessageFromDevice(message) {
if (message.event === "getPages") {
this.#pages = new Map(
message.payload.map(({ capabilities, ...page }) => [
page.id,
{
...page,
capabilities: 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(
", "
)}`
);
}
// Check if device has a new legacy React Native page.
// There is usually no more than 2-3 pages per device so this operation
// is not expensive.
// TODO(hypuk): It is better for VM to send update event when new page is
// created instead of manually checking this on every getPages result.
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") {
// Device sends disconnect events only when page is reloaded or
// if debugger socket was disconnected.
const pageId = message.payload.pageId;
// TODO(moti): Handle null case explicitly, e.g. swallow disconnect events
// for unknown pages.
const page = this.#pages.get(pageId);
if (page != null && this.#pageHasCapability(page, "nativePageReloads")) {
return;
}
const debuggerSocket = this.#debuggerConnection
? this.#debuggerConnection.socket
: null;
if (debuggerSocket && debuggerSocket.readyState === _ws.default.OPEN) {
if (
this.#debuggerConnection != null &&
this.#debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID
) {
debug(`Legacy page ${pageId} is reloading.`);
debuggerSocket.send(
JSON.stringify({
method: "reload",
})
);
}
}
} else if (message.event === "wrappedEvent") {
if (this.#debuggerConnection == null) {
return;
}
// FIXME: Is it possible that we received message for pageID that does not
// correspond to current debugger connection?
// TODO(moti): yes, fix multi-debugger case
const debuggerSocket = this.#debuggerConnection.socket;
if (
debuggerSocket == null ||
debuggerSocket.readyState !== _ws.default.OPEN
) {
// TODO(hypuk): Send error back to device?
return;
}
const parsedPayload = JSON.parse(message.payload.wrappedEvent);
const pageId = this.#debuggerConnection?.pageId ?? null;
if ("id" in parsedPayload) {
this.#deviceEventReporter?.logResponse(parsedPayload, "device", {
pageId,
frontendUserAgent: this.#debuggerConnection?.userAgent ?? null,
});
}
const debuggerConnection = this.#debuggerConnection;
if (debuggerConnection != null) {
if (
debuggerConnection.customHandler?.handleDeviceMessage(
parsedPayload
) === true
) {
return;
}
// Wrapping just to make flow happy :)
// $FlowFixMe[unused-promise]
this.#processMessageFromDeviceLegacy(
parsedPayload,
debuggerConnection,
pageId
).then(() => {
const messageToSend = JSON.stringify(parsedPayload);
debuggerSocket.send(messageToSend);
});
} else {
debuggerSocket.send(message.payload.wrappedEvent);
}
}
}
// Sends single message to device.
#sendMessageToDevice(message) {
try {
if (message.event !== "getPages") {
debug("(Debugger) (Proxy) -> (Device): " + JSON.stringify(message));
}
this.#deviceSocket.send(JSON.stringify(message));
} catch (error) {}
}
// We received new React Native Page ID.
#newLegacyReactNativePage(page) {
debug(`React Native page updated to ${page.id}`);
if (
this.#debuggerConnection == null ||
this.#debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID
) {
// We can just remember new page ID without any further actions if no
// debugger is currently attached or attached debugger is not
// "Reloadable React Native" connection.
this.#lastConnectedLegacyReactNativePage = page;
return;
}
const oldPageId = this.#lastConnectedLegacyReactNativePage?.id;
this.#lastConnectedLegacyReactNativePage = page;
this.#isLegacyPageReloading = true;
// We already had a debugger connected to React Native page and a
// new one appeared - in this case we need to emulate execution context
// detroy and resend Debugger.enable and Runtime.enable commands to new
// page.
if (oldPageId != null) {
this.#sendMessageToDevice({
event: "disconnect",
payload: {
pageId: oldPageId,
},
});
}
this.#sendMessageToDevice({
event: "connect",
payload: {
pageId: page.id,
},
});
const toSend = [
{
method: "Runtime.enable",
id: 1e9,
},
{
method: "Debugger.enable",
id: 1e9,
},
];
for (const message of toSend) {
this.#deviceEventReporter?.logRequest(message, "proxy", {
pageId: this.#debuggerConnection?.pageId ?? null,
frontendUserAgent: this.#debuggerConnection?.userAgent ?? null,
});
this.#sendMessageToDevice({
event: "wrappedEvent",
payload: {
pageId: this.#mapToDevicePageId(page.id),
wrappedEvent: JSON.stringify(message),
},
});
}
}
// Allows to make changes in incoming message from device.
async #processMessageFromDeviceLegacy(payload, debuggerInfo, pageId) {
// TODO(moti): Handle null case explicitly, or ideally associate a copy
// of the page metadata object with the connection so this can never be
// null.
const page = pageId != null ? this.#pages.get(pageId) : null;
// Replace Android addresses for scriptParsed event.
if (
(!page || !this.#pageHasCapability(page, "nativeSourceCodeFetching")) &&
payload.method === "Debugger.scriptParsed" &&
payload.params != null
) {
const params = payload.params;
if ("sourceMapURL" in params) {
for (let i = 0; i < EMULATOR_LOCALHOST_ADDRESSES.length; ++i) {
const address = EMULATOR_LOCALHOST_ADDRESSES[i];
if (params.sourceMapURL.includes(address)) {
// $FlowFixMe[cannot-write]
payload.params.sourceMapURL = params.sourceMapURL.replace(
address,
"localhost"
);
debuggerInfo.originalSourceURLAddress = address;
}
}
const sourceMapURL = this.#tryParseHTTPURL(params.sourceMapURL);
if (sourceMapURL) {
// Some debug clients do not support fetching HTTP URLs. If the
// message headed to the debug client identifies the source map with
// an HTTP URL, fetch the content here and convert the content to a
// Data URL (which is more widely supported) before passing the
// message to the debug client.
try {
const sourceMap = await this.#fetchText(sourceMapURL);
// $FlowFixMe[cannot-write]
payload.params.sourceMapURL =
"data:application/json;charset=utf-8;base64," +
new Buffer(sourceMap).toString("base64");
} catch (exception) {
this.#sendErrorToDebugger(
`Failed to fetch source map ${params.sourceMapURL}: ${exception.message}`
);
}
}
}
if ("url" in params) {
for (let i = 0; i < EMULATOR_LOCALHOST_ADDRESSES.length; ++i) {
const address = EMULATOR_LOCALHOST_ADDRESSES[i];
if (params.url.indexOf(address) >= 0) {
// $FlowFixMe[cannot-write]
payload.params.url = params.url.replace(address, "localhost");
debuggerInfo.originalSourceURLAddress = address;
}
}
// Chrome doesn't download source maps if URL param is not a valid
// URL. Some frameworks pass alphanumeric script ID instead of URL which causes
// Chrome to not download source maps. In this case we want to prepend script ID
// with 'file://' prefix.
if (payload.params.url.match(/^[0-9a-z]+$/)) {
// $FlowFixMe[cannot-write]
payload.params.url = FILE_PREFIX + payload.params.url;
debuggerInfo.prependedFilePrefix = true;
}
// $FlowFixMe[prop-missing]
if (params.scriptId != null) {
this.#scriptIdToSourcePathMapping.set(params.scriptId, params.url);
}
}
}
if (
payload.method === "Runtime.executionContextCreated" &&
this.#isLegacyPageReloading
) {
// The new context is ready. First notify Chrome that we've reloaded so
// it'll resend its breakpoints. If we do this earlier, we may not be
// ready to receive them.
debuggerInfo.socket.send(
JSON.stringify({
method: "Runtime.executionContextsCleared",
})
);
// The VM starts in a paused mode. Ask it to resume.
// Note that if setting breakpoints in early initialization functions,
// there's a currently race condition between these functions executing
// and Chrome re-applying the breakpoints due to the message above.
//
// This is not an issue in VSCode/Nuclide where the IDE knows to resume
// at its convenience.
const resumeMessage = {
method: "Debugger.resume",
id: 0,
};
this.#deviceEventReporter?.logRequest(resumeMessage, "proxy", {
pageId: this.#debuggerConnection?.pageId ?? null,
frontendUserAgent: this.#debuggerConnection?.userAgent ?? null,
});
this.#sendMessageToDevice({
event: "wrappedEvent",
payload: {
pageId: this.#mapToDevicePageId(debuggerInfo.pageId),
wrappedEvent: JSON.stringify(resumeMessage),
},
});
this.#isLegacyPageReloading = false;
}
}
/**
* Intercept an incoming message from a connected debugger. Returns either an
* original/replacement CDP message object, or `null` (will forward nothing
* to the target).
*/
#interceptClientMessageForSourceFetching(req, debuggerInfo, socket) {
switch (req.method) {
case "Debugger.setBreakpointByUrl":
return this.#processDebuggerSetBreakpointByUrl(req, debuggerInfo);
case "Debugger.getScriptSource":
// Sends response to debugger via side-effect
this.#processDebuggerGetScriptSource(req, socket);
return null;
default:
return req;
}
}
#processDebuggerSetBreakpointByUrl(req, debuggerInfo) {
// If we replaced Android emulator's address to localhost we need to change it back.
if (debuggerInfo.originalSourceURLAddress != null) {
const processedReq = {
...req,
params: {
...req.params,
},
};
if (processedReq.params.url != null) {
processedReq.params.url = processedReq.params.url.replace(
"localhost",
debuggerInfo.originalSourceURLAddress
);
if (
processedReq.params.url &&
processedReq.params.url.startsWith(FILE_PREFIX) &&
debuggerInfo.prependedFilePrefix
) {
// Remove fake URL prefix if we modified URL in #processMessageFromDeviceLegacy.
// $FlowFixMe[incompatible-use]
processedReq.params.url = processedReq.params.url.slice(
FILE_PREFIX.length
);
}
}
if (processedReq.params.urlRegex != null) {
processedReq.params.urlRegex = processedReq.params.urlRegex.replace(
/localhost/g,
// $FlowFixMe[incompatible-call]
debuggerInfo.originalSourceURLAddress
);
}
return processedReq;
}
return req;
}
#processDebuggerGetScriptSource(req, socket) {
const sendSuccessResponse = (scriptSource) => {
const response = {
id: req.id,
result: {
scriptSource,
},
};
socket.send(JSON.stringify(response));
this.#deviceEventReporter?.logResponse(response, "proxy", {
pageId: this.#debuggerConnection?.pageId ?? null,
frontendUserAgent: this.#debuggerConnection?.userAgent ?? null,
});
};
const sendErrorResponse = (error) => {
// Tell the client that the request failed
const response = {
id: req.id,
result: {
error: {
message: error,
},
},
};
socket.send(JSON.stringify(response));
// Send to the console as well, so the user can see it
this.#sendErrorToDebugger(error);
this.#deviceEventReporter?.logResponse(response, "proxy", {
pageId: this.#debuggerConnection?.pageId ?? null,
frontendUserAgent: this.#debuggerConnection?.userAgent ?? null,
});
};
const pathToSource = this.#scriptIdToSourcePathMapping.get(
req.params.scriptId
);
if (pathToSource != null) {
const httpURL = this.#tryParseHTTPURL(pathToSource);
if (httpURL) {
this.#fetchText(httpURL).then(
(text) => sendSuccessResponse(text),
(err) =>
sendErrorResponse(
`Failed to fetch source url ${pathToSource}: ${err.message}`
)
);
} else {
let file;
try {
file = fs.readFileSync(
path.resolve(this.#projectRoot, pathToSource),
"utf8"
);
} catch (err) {
sendErrorResponse(
`Failed to fetch source file ${pathToSource}: ${err.message}`
);
}
if (file != null) {
sendSuccessResponse(file);
}
}
}
}
#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;
}
// Fetch text, raising an exception if the text could not be fetched,
// or is too large.
async #fetchText(url) {
// $FlowFixMe[incompatible-call] Suppress arvr node-fetch flow error
const response = await (0, _nodeFetch.default)(url);
if (!response.ok) {
throw new Error("HTTP " + response.status + " " + response.statusText);
}
const text = await response.text();
// Restrict the length to well below the 500MB limit for nodejs (leaving
// room some some later manipulation, e.g. base64 or wrapping in JSON)
if (text.length > 350000000) {
throw new Error("file too large to fetch via HTTP");
}
return text;
}
#sendErrorToDebugger(message) {
const debuggerSocket = this.#debuggerConnection?.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",
},
})
);
}
}
}
exports.default = Device;

View File

@@ -0,0 +1,43 @@
/**
* 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
* @oncall react_native
*/
import type { EventReporter } from "../types/EventReporter";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { Page } from "./types";
import WS from "ws";
/**
* Device class represents single device connection to Inspector Proxy. Each device
* can have multiple inspectable pages.
*/
declare export default class Device {
constructor(
id: string,
name: string,
app: string,
socket: WS,
projectRoot: string,
eventReporter: ?EventReporter,
createMessageMiddleware: ?CreateCustomMessageHandlerFn
): void;
getName(): string;
getApp(): string;
getPagesList(): $ReadOnlyArray<Page>;
handleDebuggerConnection(
socket: WS,
pageId: string,
metadata: $ReadOnly<{
userAgent: string | null,
}>
): void;
handleDuplicateDeviceConnection(newDevice: Device): void;
}

View File

@@ -0,0 +1,44 @@
/**
* 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";
type DeviceMetadata = Readonly<{
appId: string;
deviceId: string;
deviceName: string;
}>;
type RequestMetadata = 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: CDPResponse<>,
origin: "device" | "proxy",
metadata: Readonly<{
pageId: string | null;
frontendUserAgent: string | null;
}>
): void;
logConnection(
connectedEntity: "debugger",
metadata: Readonly<{ pageId: string; frontendUserAgent: string | null }>
): void;
logDisconnection(disconnectedEntity: "device" | "debugger"): void;
}
declare const $$EXPORT_DEFAULT_DECLARATION$$: typeof DeviceEventReporter;
export default $$EXPORT_DEFAULT_DECLARATION$$;

View File

@@ -0,0 +1,164 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _ttlcache = _interopRequireDefault(require("@isaacs/ttlcache"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
/**
* 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
*/
class DeviceEventReporter {
#eventReporter;
#pendingCommands = new _ttlcache.default({
ttl: 10000,
dispose: (command, id, reason) => {
if (reason === "delete" || reason === "set") {
// TODO: Report clobbering ('set') using a dedicated error code
return;
}
this.#logExpiredCommand(command);
},
});
#metadata;
constructor(eventReporter, metadata) {
this.#eventReporter = eventReporter;
this.#metadata = metadata;
}
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,
});
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,
});
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,
});
}
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,
});
}
this.#pendingCommands.clear();
}
#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,
});
}
}
var _default = DeviceEventReporter;
exports.default = _default;

View File

@@ -0,0 +1,50 @@
/**
* 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";
type DeviceMetadata = $ReadOnly<{
appId: string,
deviceId: string,
deviceName: string,
}>;
type RequestMetadata = $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: CDPResponse<>,
origin: "device" | "proxy",
metadata: $ReadOnly<{
pageId: string | null,
frontendUserAgent: string | null,
}>
): void;
logConnection(
connectedEntity: "debugger",
metadata: $ReadOnly<{
pageId: string,
frontendUserAgent: string | null,
}>
): void;
logDisconnection(disconnectedEntity: "device" | "debugger"): void;
}
declare export default typeof DeviceEventReporter;

View File

@@ -0,0 +1,40 @@
/**
* 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
* @oncall react_native
*/
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { PageDescription } from "./types";
import type { IncomingMessage, ServerResponse } from "http";
import WS from "ws";
export interface InspectorProxyQueries {
getPageDescriptions(): Array<PageDescription>;
}
/**
* Main Inspector Proxy class that connects JavaScript VM inside Android/iOS apps and JS debugger.
*/
declare class InspectorProxy implements InspectorProxyQueries {
constructor(
projectRoot: string,
serverBaseUrl: string,
eventReporter: null | undefined | EventReporter,
experiments: Experiments,
customMessageHandler: null | undefined | CreateCustomMessageHandlerFn
);
getPageDescriptions(): 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,240 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _Device = _interopRequireDefault(require("./Device"));
var _nullthrows = _interopRequireDefault(require("nullthrows"));
var _url = _interopRequireDefault(require("url"));
var _ws = _interopRequireDefault(require("ws"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
/**
* 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
* @oncall react_native
*/
const debug = require("debug")("Metro:InspectorProxy");
const WS_DEVICE_URL = "/inspector/device";
const WS_DEBUGGER_URL = "/inspector/debug";
const PAGES_LIST_JSON_URL = "/json";
const PAGES_LIST_JSON_URL_2 = "/json/list";
const PAGES_LIST_JSON_VERSION_URL = "/json/version";
const INTERNAL_ERROR_CODE = 1011;
/**
* Main Inspector Proxy class that connects JavaScript VM inside Android/iOS apps and JS debugger.
*/
class InspectorProxy {
// Root of the project used for relative to absolute source path conversion.
#projectRoot;
/** The base URL to the dev server from the developer machine. */
#serverBaseUrl;
// Maps device ID to Device instance.
#devices;
// Internal counter for device IDs -- just gets incremented for each new device.
#deviceCounter = 0;
#eventReporter;
#experiments;
// custom message handler factory allowing implementers to handle unsupported CDP messages.
#customMessageHandler;
constructor(
projectRoot,
serverBaseUrl,
eventReporter,
experiments,
customMessageHandler
) {
this.#projectRoot = projectRoot;
this.#serverBaseUrl = serverBaseUrl;
this.#devices = new Map();
this.#eventReporter = eventReporter;
this.#experiments = experiments;
this.#customMessageHandler = customMessageHandler;
}
getPageDescriptions() {
// Build list of pages from all devices.
let result = [];
Array.from(this.#devices.entries()).forEach(([deviceId, device]) => {
result = result.concat(
device
.getPagesList()
.map((page) => this.#buildPageDescription(deviceId, device, page))
);
});
return result;
}
// Process HTTP request sent to server. We only respond to 2 HTTP requests:
// 1. /json/version returns Chrome debugger protocol version that we use
// 2. /json and /json/list returns list of page descriptions (list of inspectable apps).
// This list is combined from all the connected devices.
processRequest(request, response, next) {
const pathname = _url.default.parse(request.url).pathname;
if (
pathname === PAGES_LIST_JSON_URL ||
pathname === PAGES_LIST_JSON_URL_2
) {
this.#sendJsonResponse(response, this.getPageDescriptions());
} 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(),
};
}
// Converts page information received from device into PageDescription object
// that is sent to debugger.
#buildPageDescription(deviceId, device, page) {
const { host, protocol } = new URL(this.#serverBaseUrl);
const webSocketScheme = protocol === "https:" ? "wss" : "ws";
const webSocketUrlWithoutProtocol = `${host}${WS_DEBUGGER_URL}?device=${deviceId}&page=${page.id}`;
// For now, `/json/list` returns the legacy built-in `devtools://` URL, to
// preserve existing handling by Flipper. This may return a placeholder in
// future -- please use the `/open-debugger` endpoint.
const devtoolsFrontendUrl =
`devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&${webSocketScheme}=` +
encodeURIComponent(webSocketUrlWithoutProtocol);
return {
id: `${deviceId}-${page.id}`,
description: page.app,
title: page.title,
faviconUrl: "https://reactjs.org/favicon.ico",
devtoolsFrontendUrl,
type: "node",
webSocketDebuggerUrl: `${webSocketScheme}://${webSocketUrlWithoutProtocol}`,
vm: page.vm,
deviceName: device.getName(),
reactNative: {
logicalDeviceId: deviceId,
capabilities: (0, _nullthrows.default)(page.capabilities),
},
};
}
// Sends object as response to HTTP request.
// Just serializes object using JSON and sets required headers.
#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);
}
// Adds websocket handler for device connections.
// Device connects to /inspector/device and passes device and app names as
// HTTP GET params.
// For each new websocket connection we parse device and app names and create
// new instance of Device class.
#createDeviceConnectionWSServer() {
const wss = new _ws.default.Server({
noServer: true,
perMessageDeflate: true,
// Don't crash on exceptionally large messages - assume the device is
// well-behaved and the debugger is prepared to handle large messages.
maxPayload: 0,
});
// $FlowFixMe[value-as-type]
wss.on("connection", async (socket, req) => {
try {
const fallbackDeviceId = String(this.#deviceCounter++);
const query = _url.default.parse(req.url || "", true).query || {};
const deviceId = query.device || fallbackDeviceId;
const deviceName = query.name || "Unknown";
const appName = query.app || "Unknown";
const oldDevice = this.#devices.get(deviceId);
const newDevice = new _Device.default(
deviceId,
deviceName,
appName,
socket,
this.#projectRoot,
this.#eventReporter,
this.#customMessageHandler
);
if (oldDevice) {
oldDevice.handleDuplicateDeviceConnection(newDevice);
}
this.#devices.set(deviceId, newDevice);
debug(
`Got new connection: name=${deviceName}, app=${appName}, device=${deviceId}`
);
socket.on("close", () => {
this.#devices.delete(deviceId);
debug(`Device ${deviceName} disconnected.`);
});
} catch (e) {
console.error("error", e);
socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? "Unknown error");
}
});
return wss;
}
// Returns websocket handler for debugger connections.
// Debugger connects to webSocketDebuggerUrl that we return as part of page description
// in /json response.
// When debugger connects we try to parse device and page IDs from the query and pass
// websocket object to corresponding Device instance.
#createDebuggerConnectionWSServer() {
const wss = new _ws.default.Server({
noServer: true,
perMessageDeflate: false,
// Don't crash on exceptionally large messages - assume the debugger is
// well-behaved and the device is prepared to handle large messages.
maxPayload: 0,
});
// $FlowFixMe[value-as-type]
wss.on("connection", async (socket, req) => {
try {
const query = _url.default.parse(req.url || "", true).query || {};
const deviceId = query.device;
const pageId = query.page;
if (deviceId == null || pageId == null) {
throw new Error("Incorrect URL - must provide device and page IDs");
}
const device = this.#devices.get(deviceId);
if (device == null) {
throw new Error("Unknown device with ID " + deviceId);
}
device.handleDebuggerConnection(socket, pageId, {
userAgent: req.headers["user-agent"] ?? query.userAgent ?? null,
});
} catch (e) {
console.error(e);
socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? "Unknown error");
this.#eventReporter?.logEvent({
type: "connect_debugger_frontend",
status: "error",
error: e,
});
}
});
return wss;
}
}
exports.default = InspectorProxy;

View File

@@ -0,0 +1,44 @@
/**
* 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
* @oncall react_native
*/
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { CreateCustomMessageHandlerFn } from "./CustomMessageHandler";
import type { PageDescription } from "./types";
import type { IncomingMessage, ServerResponse } from "http";
import WS from "ws";
export interface InspectorProxyQueries {
getPageDescriptions(): 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(
projectRoot: string,
serverBaseUrl: string,
eventReporter: ?EventReporter,
experiments: Experiments,
customMessageHandler: ?CreateCustomMessageHandlerFn
): void;
getPageDescriptions(): Array<PageDescription>;
processRequest(
request: IncomingMessage,
response: ServerResponse,
next: (?Error) => mixed
): void;
createWebSocketListeners(): {
[path: string]: WS.Server,
};
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
/**
* 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
* @oncall react_native
*/
type integer = number;
export interface Debugger {
GetScriptSourceParams: Readonly<{
/**
* Id of the script to get source for.
*/
scriptId: string;
}>;
GetScriptSourceResult: Readonly<{
/**
* Script source (empty in case of Wasm bytecode).
*/
scriptSource: string;
/**
* Wasm bytecode. (Encoded as a base64 string when passed over JSON)
*/
bytecode?: string;
}>;
SetBreakpointByUrlParams: Readonly<{
/**
* 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: Readonly<{
/**
* 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;
}>;
}
export type Events = {
"Debugger.scriptParsed": Debugger["ScriptParsedEvent"];
[method: string]: unknown;
};
export type Commands = {
"Debugger.getScriptSource": {
paramsType: Debugger["GetScriptSourceParams"];
resultType: Debugger["GetScriptSourceResult"];
};
"Debugger.setBreakpointByUrl": {
paramsType: Debugger["SetBreakpointByUrlParams"];
resultType: void;
};
[method: string]: { paramsType: unknown; resultType: unknown };
};

View File

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

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.
*
* @flow strict-local
* @format
* @oncall react_native
*/
// Adapted from https://github.com/ChromeDevTools/devtools-protocol/blob/master/types/protocol.d.ts
type integer = number;
export interface Debugger {
GetScriptSourceParams: $ReadOnly<{
/**
* Id of the script to get source for.
*/
scriptId: string,
}>;
GetScriptSourceResult: $ReadOnly<{
/**
* Script source (empty in case of Wasm bytecode).
*/
scriptSource: string,
/**
* Wasm bytecode. (Encoded as a base64 string when passed over JSON)
*/
bytecode?: string,
}>;
SetBreakpointByUrlParams: $ReadOnly<{
/**
* 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: $ReadOnly<{
/**
* 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,
}>;
}
export type Events = {
"Debugger.scriptParsed": Debugger["ScriptParsedEvent"],
[method: string]: mixed,
};
export type Commands = {
"Debugger.getScriptSource": {
paramsType: Debugger["GetScriptSourceParams"],
resultType: Debugger["GetScriptSourceResult"],
},
"Debugger.setBreakpointByUrl": {
paramsType: Debugger["SetBreakpointByUrlParams"],
resultType: void,
},
[method: string]: {
paramsType: mixed,
resultType: mixed,
},
};

View File

@@ -0,0 +1,99 @@
/**
* 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
* @oncall react_native
*/
/**
* 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 '(Experimental)' target in `/json/list`.
*
* In the launch flow, this allows targets to be matched directly by `appId`.
*/
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 native network inspection.
*
* In the proxy, this disables intercepting and storing network requests.
*/
nativeNetworkInspection?: boolean;
}>;
export type PageFromDevice = Readonly<{
id: string;
title: string;
vm: string;
app: string;
capabilities?: TargetCapabilityFlags;
}>;
export type Page = Required<PageFromDevice>;
export type WrappedEvent = Readonly<{
event: "wrappedEvent";
payload: Readonly<{ pageId: string; wrappedEvent: string }>;
}>;
export type ConnectRequest = Readonly<{
event: "connect";
payload: Readonly<{ pageId: string }>;
}>;
export type DisconnectRequest = Readonly<{
event: "disconnect";
payload: Readonly<{ pageId: string }>;
}>;
export type GetPagesRequest = { event: "getPages" };
export type GetPagesResponse = {
event: "getPages";
payload: ReadonlyArray<PageFromDevice>;
};
export type MessageFromDevice =
| GetPagesResponse
| WrappedEvent
| DisconnectRequest;
export type MessageToDevice =
| GetPagesRequest
| WrappedEvent
| ConnectRequest
| DisconnectRequest;
export type PageDescription = Readonly<{
id: string;
description: string;
title: string;
faviconUrl: string;
devtoolsFrontendUrl: string;
type: string;
webSocketDebuggerUrl: string;
deviceName: string;
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 };

View File

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

View File

@@ -0,0 +1,134 @@
/**
* 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
* @oncall react_native
*/
/**
* 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 '(Experimental)' target in `/json/list`.
*
* In the launch flow, this allows targets to be matched directly by `appId`.
*/
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 native network inspection.
*
* In the proxy, this disables intercepting and storing network requests.
*/
nativeNetworkInspection?: 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,
vm: string,
app: string,
capabilities?: TargetCapabilityFlags,
}>;
export type Page = Required<PageFromDevice>;
// Chrome Debugger Protocol message/event passed between device and debugger.
export type WrappedEvent = $ReadOnly<{
event: "wrappedEvent",
payload: $ReadOnly<{
pageId: string,
wrappedEvent: 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 }>,
}>;
// Request sent from Inspector Proxy to Device to notify that debugger is
// disconnected.
export type DisconnectRequest = $ReadOnly<{
event: "disconnect",
payload: $ReadOnly<{ pageId: 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
| WrappedEvent
| DisconnectRequest;
// Union type for all possible messages sent from Inspector Proxy to device.
export type MessageToDevice =
| GetPagesRequest
| WrappedEvent
| ConnectRequest
| DisconnectRequest;
// Page description object that is sent in response to /json HTTP request from debugger.
export type PageDescription = $ReadOnly<{
id: string,
description: string,
title: string,
faviconUrl: string,
devtoolsFrontendUrl: string,
type: string,
webSocketDebuggerUrl: string,
deviceName: string,
vm: string,
// Metadata specific to React Native
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 };

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.
*
*
* @format
* @oncall react_native
*/
import type { Logger } from "../types/Logger";
import type { NextHandleFunction } from "connect";
type Options = Readonly<{ logger?: Logger }>;
/**
* Open the legacy Flipper debugger (Hermes).
*
* @deprecated This replicates the pre-0.73 workflow of opening Flipper via the
* `flipper://` URL scheme, failing if Flipper is not installed locally. This
* flow will be removed in a future version.
*/
declare function deprecated_openFlipperMiddleware(
$$PARAM_0$$: Options
): NextHandleFunction;
export default deprecated_openFlipperMiddleware;

View File

@@ -0,0 +1,60 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = deprecated_openFlipperMiddleware;
var _open = _interopRequireDefault(require("open"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
/**
* 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
* @oncall react_native
*/
const FLIPPER_SELF_CONNECT_URL =
"flipper://null/Hermesdebuggerrn?device=React%20Native";
/**
* Open the legacy Flipper debugger (Hermes).
*
* @deprecated This replicates the pre-0.73 workflow of opening Flipper via the
* `flipper://` URL scheme, failing if Flipper is not installed locally. This
* flow will be removed in a future version.
*/
function deprecated_openFlipperMiddleware({ logger }) {
return async (req, res, next) => {
if (req.method === "POST") {
logger?.info("Launching JS debugger...");
try {
logger?.warn(
"Attempting to debug JS in Flipper (deprecated). This requires " +
"Flipper to be installed on your system to handle the " +
"'flipper://' URL scheme."
);
logger?.info(
"In React Native 0.74, Flipper is no longer included for new React " +
"Native projects. The Flipper React Native plugin is also " +
"unsupported. You can continue to use Flipper to debug " +
"your app's JavaScript code, however we recommend switching to " +
"a modern alternative.\nSee " +
"https://reactnative.dev/docs/debugging#opening-the-debugger."
);
await (0, _open.default)(FLIPPER_SELF_CONNECT_URL);
res.end();
} catch (e) {
logger?.error(
"Error launching Flipper: " + e.message ?? "Unknown error"
);
res.writeHead(500);
res.end();
}
}
};
}

View File

@@ -0,0 +1,27 @@
/**
* 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
* @oncall react_native
*/
import type { Logger } from "../types/Logger";
import type { NextHandleFunction } from "connect";
type Options = $ReadOnly<{
logger?: Logger,
}>;
/**
* Open the legacy Flipper debugger (Hermes).
*
* @deprecated This replicates the pre-0.73 workflow of opening Flipper via the
* `flipper://` URL scheme, failing if Flipper is not installed locally. This
* flow will be removed in a future version.
*/
declare export default function deprecated_openFlipperMiddleware(
Options
): NextHandleFunction;

View File

@@ -0,0 +1,37 @@
/**
* 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
* @oncall react_native
*/
import type { InspectorProxyQueries } from "../inspector-proxy/InspectorProxy";
import type { BrowserLauncher } from "../types/BrowserLauncher";
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { Logger } from "../types/Logger";
import type { NextHandleFunction } from "connect";
type Options = Readonly<{
serverBaseUrl: string;
logger?: Logger;
browserLauncher: BrowserLauncher;
eventReporter?: EventReporter;
experiments: Experiments;
inspectorProxy: InspectorProxyQueries;
}>;
/**
* Open the JavaScript debugger for a given CDP target (direct Hermes debugging).
*
* Currently supports Hermes targets, opening debugger websocket URL in Chrome
* DevTools.
*
* @see https://chromedevtools.github.io/devtools-protocol/
*/
declare function openDebuggerMiddleware(
$$PARAM_0$$: Options
): NextHandleFunction;
export default openDebuggerMiddleware;

View File

@@ -0,0 +1,149 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = openDebuggerMiddleware;
var _getDevToolsFrontendUrl = _interopRequireDefault(
require("../utils/getDevToolsFrontendUrl")
);
var _url = _interopRequireDefault(require("url"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
/**
* 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
* @oncall react_native
*/
const debuggerInstances = new Map();
/**
* Open the JavaScript debugger for a given CDP target (direct Hermes debugging).
*
* Currently supports Hermes targets, opening debugger websocket URL in Chrome
* DevTools.
*
* @see https://chromedevtools.github.io/devtools-protocol/
*/
function openDebuggerMiddleware({
serverBaseUrl,
logger,
browserLauncher,
eventReporter,
experiments,
inspectorProxy,
}) {
return async (req, res, next) => {
if (
req.method === "POST" ||
(experiments.enableOpenDebuggerRedirect && req.method === "GET")
) {
const { query } = _url.default.parse(req.url, true);
const { appId, device } = query;
const targets = inspectorProxy.getPageDescriptions().filter(
// Only use targets with better reloading support
(app) =>
app.title === "React Native Experimental (Improved Chrome Reloads)" ||
app.reactNative.capabilities?.nativePageReloads === true
);
let target;
const launchType = req.method === "POST" ? "launch" : "redirect";
if (typeof appId === "string" || typeof device === "string") {
logger?.info(
(launchType === "launch" ? "Launching" : "Redirecting to") +
" JS debugger (experimental)..."
);
if (typeof device === "string") {
target = targets.find(
(_target) => _target.reactNative.logicalDeviceId === device
);
}
if (!target && typeof appId === "string") {
target = targets.find((_target) => _target.description === appId);
}
} else {
logger?.info(
(launchType === "launch" ? "Launching" : "Redirecting to") +
" JS debugger for first available target..."
);
target = targets[0];
}
if (!target) {
res.writeHead(404);
res.end("Unable to find Chrome DevTools inspector target");
logger?.warn(
"No compatible apps connected. JavaScript debugging 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 frontendInstanceId =
device != null
? "device:" + device
: "app:" + (appId ?? "<null>");
await debuggerInstances.get(frontendInstanceId)?.kill();
debuggerInstances.set(
frontendInstanceId,
await browserLauncher.launchDebuggerAppWindow(
(0, _getDevToolsFrontendUrl.default)(
experiments,
target.webSocketDebuggerUrl,
serverBaseUrl
)
)
);
res.end();
break;
case "redirect":
res.writeHead(302, {
Location: (0, _getDevToolsFrontendUrl.default)(
experiments,
target.webSocketDebuggerUrl,
// Use a relative URL.
""
),
});
res.end();
break;
default:
}
eventReporter?.logEvent({
type: "launch_debugger_frontend",
launchType,
status: "success",
appId: appId ?? null,
deviceId: device ?? null,
});
return;
} catch (e) {
logger?.error(
"Error launching JS debugger: " + 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,37 @@
/**
* 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
* @oncall react_native
*/
import type { InspectorProxyQueries } from "../inspector-proxy/InspectorProxy";
import type { BrowserLauncher } from "../types/BrowserLauncher";
import type { EventReporter } from "../types/EventReporter";
import type { Experiments } from "../types/Experiments";
import type { Logger } from "../types/Logger";
import type { NextHandleFunction } from "connect";
type Options = $ReadOnly<{
serverBaseUrl: string,
logger?: Logger,
browserLauncher: BrowserLauncher,
eventReporter?: EventReporter,
experiments: Experiments,
inspectorProxy: InspectorProxyQueries,
}>;
/**
* Open the JavaScript debugger for a given CDP target (direct Hermes debugging).
*
* Currently supports Hermes targets, opening debugger websocket URL in Chrome
* DevTools.
*
* @see https://chromedevtools.github.io/devtools-protocol/
*/
declare export default function openDebuggerMiddleware(
Options
): NextHandleFunction;

View File

@@ -0,0 +1,27 @@
/**
* 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
* @oncall react_native
*/
/**
* Represents a launched web browser instance.
*/
export type LaunchedBrowser = { kill: () => void | Promise<void> };
/**
* An interface for integrators to provide a custom implementation for
* opening URLs in a web browser.
*/
export interface BrowserLauncher {
/**
* Attempt to open a debugger frontend URL in a browser app window,
* optionally returning an object to control the launched browser instance.
* The browser used should be capable of running Chrome DevTools.
*/
launchDebuggerAppWindow: (url: string) => Promise<LaunchedBrowser | void>;
}

View File

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

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.
*
* @flow strict
* @format
* @oncall react_native
*/
/**
* Represents a launched web browser instance.
*/
export type LaunchedBrowser = {
kill: () => void | Promise<void>,
...
};
/**
* An interface for integrators to provide a custom implementation for
* opening URLs in a web browser.
*/
export interface BrowserLauncher {
/**
* Attempt to open a debugger frontend URL in a browser app window,
* optionally returning an object to control the launched browser instance.
* The browser used should be capable of running Chrome DevTools.
*/
launchDebuggerAppWindow: (url: string) => Promise<LaunchedBrowser | void>;
}

View File

@@ -0,0 +1,69 @@
/**
* 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
*/
type SuccessResult<Props extends {} | void = {}> =
/**
* > 13 | ...Props,
* | ^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any;
type ErrorResult<ErrorT = unknown> = { status: "error"; error: ErrorT };
type CodedErrorResult<ErrorCode extends string> = {
status: "coded_error";
errorCode: ErrorCode;
errorDetails?: string;
};
type DebuggerSessionIDs = {
appId: string;
deviceName: string;
deviceId: string;
pageId: string | null;
};
export type ReportableEvent =
| /**
* > 38 | ...
* | ^^^
* > 39 | | SuccessResult<{ appId: string | null, deviceId: string | null }>
* | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* > 40 | | ErrorResult<mixed>
* | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* > 41 | | CodedErrorResult<"NO_APPS_FOUND">,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 45 | ...
* | ^^^
* > 46 | | SuccessResult<{
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 47 | ...DebuggerSessionIDs,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 48 | frontendUserAgent: string | null,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 49 | }>
* | ^^^^^^^^^^^^^^^^^^^^^^^^^
* > 50 | | ErrorResult<mixed>,
* | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any
| /**
* > 60 | ...DebuggerSessionIDs,
* | ^^^^^^^^^^^^^^^^^^^^^ Unsupported feature: Translating "object types with spreads in the middle or at the end" is currently not supported.
**/
any;
/**
* 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,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
*/
type SuccessResult<Props: { ... } | void = {}> = {
status: "success",
...Props,
};
type ErrorResult<ErrorT = mixed> = {
status: "error",
error: ErrorT,
};
type CodedErrorResult<ErrorCode: string> = {
status: "coded_error",
errorCode: ErrorCode,
errorDetails?: string,
};
type DebuggerSessionIDs = {
appId: string,
deviceName: string,
deviceId: string,
pageId: string | null,
};
export type ReportableEvent =
| {
type: "launch_debugger_frontend",
launchType: "launch" | "redirect",
...
| SuccessResult<{ appId: string | null, deviceId: string | null }>
| ErrorResult<mixed>
| CodedErrorResult<"NO_APPS_FOUND">,
}
| {
type: "connect_debugger_frontend",
...
| SuccessResult<{
...DebuggerSessionIDs,
frontendUserAgent: string | null,
}>
| ErrorResult<mixed>,
}
| {
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,
frontendUserAgent: string | null,
...
| SuccessResult<void>
| CodedErrorResult<
| "TIMED_OUT"
| "DEVICE_DISCONNECTED"
| "DEBUGGER_DISCONNECTED"
| "UNMATCHED_REQUEST_ID"
| "PROTOCOL_ERROR",
>,
};
/**
* 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,30 @@
/**
* 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 new JS debugger launch flow and custom debugger frontend
* (@react-native/debugger-frontend). When disabled, /open-debugger will
* trigger the legacy Flipper connection flow.
*/
enableNewDebugger: boolean;
/**
* 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 BrowserLauncher
* interface.
*/
enableOpenDebuggerRedirect: boolean;
/**
* Enables the Network panel when launching the custom debugger frontend.
*/
enableNetworkInspector: boolean;
}>;
export type ExperimentsConfig = Partial<Experiments>;

View File

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

View File

@@ -0,0 +1,33 @@
/**
* 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 new JS debugger launch flow and custom debugger frontend
* (@react-native/debugger-frontend). When disabled, /open-debugger will
* trigger the legacy Flipper connection flow.
*/
enableNewDebugger: boolean,
/**
* 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 BrowserLauncher
* interface.
*/
enableOpenDebuggerRedirect: boolean,
/**
* Enables the Network panel when launching the custom debugger frontend.
*/
enableNetworkInspector: boolean,
}>;
export type ExperimentsConfig = Partial<Experiments>;

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.
*
*
* @format
* @oncall react_native
*/
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,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
* @format
* @oncall react_native
*/
export type Logger = $ReadOnly<{
error: (...message: Array<string>) => void,
info: (...message: Array<string>) => void,
warn: (...message: Array<string>) => void,
...
}>;

View File

@@ -0,0 +1,19 @@
/**
* 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
* @oncall react_native
*/
import type { BrowserLauncher } from "../types/BrowserLauncher";
/**
* Default `BrowserLauncher` implementation which opens URLs on the host
* machine.
*/
declare const DefaultBrowserLauncher: BrowserLauncher;
declare const $$EXPORT_DEFAULT_DECLARATION$$: typeof DefaultBrowserLauncher;
export default $$EXPORT_DEFAULT_DECLARATION$$;

View File

@@ -0,0 +1,88 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _fs = require("fs");
var _path = _interopRequireDefault(require("path"));
var _tempDir = _interopRequireDefault(require("temp-dir"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
/**
* 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
* @oncall react_native
*/
const { Launcher: EdgeLauncher } = require("@rnx-kit/chromium-edge-launcher");
const ChromeLauncher = require("chrome-launcher");
/**
* Default `BrowserLauncher` implementation which opens URLs on the host
* machine.
*/
const DefaultBrowserLauncher = {
/**
* Attempt to open the debugger frontend in a Google Chrome or Microsoft Edge
* app window.
*/
launchDebuggerAppWindow: async (url) => {
let browserType = "chrome";
let chromePath;
try {
// Locate Chrome installation path, will throw if not found
chromePath = ChromeLauncher.getChromePath();
} catch (e) {
browserType = "edge";
chromePath = EdgeLauncher.getFirstInstallation();
if (chromePath == null) {
throw new Error(
"Unable to find a browser on the host to open the debugger. " +
"Supported browsers: Google Chrome, Microsoft Edge.\n" +
url
);
}
}
const userDataDir = await createTempDir(
`react-native-debugger-frontend-${browserType}`
);
const launchedChrome = await ChromeLauncher.launch({
chromeFlags: [
...ChromeLauncher.Launcher.defaultFlags().filter(
/**
* This flag controls whether Chrome treats a visually covered (occluded) tab
* as "backgrounded". We launch CDT as a single tab/window via `--app`, so we
* do want Chrome to treat our tab as "backgrounded" when the UI is covered.
* Omitting this flag allows "visibilitychange" events to fire properly.
*/
(flag) => flag !== "--disable-backgrounding-occluded-windows"
),
`--app=${url}`,
`--user-data-dir=${userDataDir}`,
"--window-size=1200,600",
"--guest",
],
chromePath,
ignoreDefaultFlags: true,
});
return {
kill: async () => launchedChrome.kill(),
};
},
};
async function createTempDir(dirName) {
const tempDir = _path.default.join(_tempDir.default, dirName);
await _fs.promises.mkdir(tempDir, {
recursive: true,
});
return tempDir;
}
var _default = DefaultBrowserLauncher;
exports.default = _default;

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
* @format
* @oncall react_native
*/
import type { BrowserLauncher } from "../types/BrowserLauncher";
/**
* Default `BrowserLauncher` implementation which opens URLs on the host
* machine.
*/
declare const DefaultBrowserLauncher: BrowserLauncher;
declare export default typeof DefaultBrowserLauncher;

View File

@@ -0,0 +1,21 @@
/**
* 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
* @oncall react_native
*/
import type { Experiments } from "../types/Experiments";
/**
* Get the DevTools frontend URL to debug a given React Native CDP target.
*/
declare function getDevToolsFrontendUrl(
experiments: Experiments,
webSocketDebuggerUrl: string,
devServerUrl: string
): string;
export default getDevToolsFrontendUrl;

View File

@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = getDevToolsFrontendUrl;
/**
* 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
* @oncall react_native
*/
/**
* Get the DevTools frontend URL to debug a given React Native CDP target.
*/
function getDevToolsFrontendUrl(
experiments,
webSocketDebuggerUrl,
devServerUrl
) {
const scheme = new URL(webSocketDebuggerUrl).protocol.slice(0, -1);
const webSocketUrlWithoutProtocol = webSocketDebuggerUrl.replace(
/^wss?:\/\//,
""
);
const searchParams = new URLSearchParams([
[scheme, webSocketUrlWithoutProtocol],
["sources.hide_add_folder", "true"],
]);
if (experiments.enableNetworkInspector) {
searchParams.append("unstable_enableNetworkPanel", "true");
}
return (
`${devServerUrl}/debugger-frontend/rn_inspector.html` +
"?" +
searchParams.toString()
);
}

View File

@@ -0,0 +1,21 @@
/**
* 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
* @oncall react_native
*/
import type { Experiments } from "../types/Experiments";
/**
* Get the DevTools frontend URL to debug a given React Native CDP target.
*/
declare export default function getDevToolsFrontendUrl(
experiments: Experiments,
webSocketDebuggerUrl: string,
devServerUrl: string
): string;