feature(dev-middleware): add custom message handlers to extend CDP capabilities (#43291)

Summary:
This is a proposal for the `react-native/dev-middleware` package, to allow implementers to extend the CDP capabilities of the `InspectorProxy`. It's unfortunately needed until we can move to the native Hermes CDP layer.

At Expo, we extend the CDP capabilities of this `InspectorProxy` by injecting functionality on the device level. This proposed API does the same, but without having to overwrite internal functions of both the `InspectorProxy` and `InspectorDevice`.

A good example of this is the network inspector's capabilities. This currently works through the inspection proxy, and roughly like:
- Handle any incoming `Expo(Network.receivedResponseBody)` from the _**device**_, store it, and stop event from propagating
- Handle the incoming `Network.getResponseBody` from the _**debugger**_, return the data, and stop event from propagating.

This API brings back that capability in a more structured way.

## API:

```ts
import { createDevMiddleware } from 'react-native/dev-middleware';

const { middleware, websocketEndpoints } = createDevMiddleware({
  unstable_customInspectorMessageHandler: ({ page, deviceInfo, debuggerInfo }) => {
    // Do not enable handler for page other than "SOMETHING", or for vscode debugging
    // Can also include `page.capabilities` to determine if handler is required
    if (page.title !== 'SOMETHING' || debuggerInfo.userAgent?.includes('vscode')) {
      return null;
    }

    return {
      handleDeviceMessage(message) {
        if (message.type === 'CDP_MESSAGE') {
          // Do something and stop message from propagating with return `true`
          return true;
        }
      },
      handleDebuggerMessage(message) {
        if (message.type === 'CDP_MESSAGE') {
          // Do something and stop message from propagating with return `true`
          return true;
        }
      },
    };
  },
});
```

## Changelog:

<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests
-->

[GENERAL] [ADDED] - Add inspector proxy device message middleware API

Pull Request resolved: https://github.com/facebook/react-native/pull/43291

Test Plan: See added tests and code above

Reviewed By: huntie

Differential Revision: D54804503

Pulled By: motiz88

fbshipit-source-id: ae918dcd5b7e76d3fb31db4c84717567ae60fa96
This commit is contained in:
Cedric van Putten
2024-03-12 09:58:51 -07:00
committed by Facebook GitHub Bot
parent 5833eb59e3
commit 3f41fb5d5b
6 changed files with 466 additions and 14 deletions
@@ -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;
}
@@ -16,6 +16,10 @@ import type {
CDPResponse,
CDPServerMessage,
} from './cdp-types/messages';
import type {
CreateCustomMessageHandlerFn,
CustomMessageHandler,
} from './CustomMessageHandler';
import type {
MessageFromDevice,
MessageToDevice,
@@ -51,6 +55,11 @@ type DebuggerInfo = {
userAgent: string | null,
};
type DebuggerConnection = {
...DebuggerInfo,
customHandler: ?CustomMessageHandler,
};
const REACT_NATIVE_RELOADABLE_PAGE_ID = '-1';
/**
@@ -74,7 +83,7 @@ export default class Device {
#pages: $ReadOnlyMap<string, Page>;
// Stores information about currently connected debugger (if any).
#debuggerConnection: ?DebuggerInfo = null;
#debuggerConnection: ?DebuggerConnection = null;
// Last known Page ID of the React Native page.
// This is used by debugger connections that don't have PageID specified
@@ -97,6 +106,9 @@ export default class Device {
#pagesPollingIntervalId: ReturnType<typeof setInterval>;
// The device message middleware factory function allowing implementers to handle unsupported CDP messages.
#createCustomMessageHandler: ?CreateCustomMessageHandlerFn;
constructor(
id: string,
name: string,
@@ -104,6 +116,7 @@ export default class Device {
socket: WS,
projectRoot: string,
eventReporter: ?EventReporter,
createMessageMiddleware: ?CreateCustomMessageHandlerFn,
) {
this.#id = id;
this.#name = name;
@@ -118,6 +131,7 @@ export default class Device {
appId: app,
})
: null;
this.#createCustomMessageHandler = createMessageMiddleware;
// $FlowFixMe[incompatible-call]
this.#deviceSocket.on('message', (message: string) => {
@@ -205,6 +219,7 @@ export default class Device {
prependedFilePrefix: false,
pageId,
userAgent: metadata.userAgent,
customHandler: null,
};
// TODO(moti): Handle null case explicitly, e.g. refuse to connect to
@@ -215,6 +230,50 @@ export default class Device {
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: {
@@ -231,6 +290,15 @@ export default class Device {
frontendUserAgent: metadata.userAgent,
});
let processedReq = debuggerRequest;
if (
this.#debuggerConnection?.customHandler?.handleDebuggerMessage(
debuggerRequest,
) === true
) {
return;
}
if (!page || !this.#pageHasCapability(page, 'nativeSourceCodeFetching')) {
processedReq = this.#interceptClientMessageForSourceFetching(
debuggerRequest,
@@ -411,12 +479,21 @@ export default class Device {
});
}
if (this.#debuggerConnection != 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,
this.#debuggerConnection,
debuggerConnection,
pageId,
).then(() => {
const messageToSend = JSON.stringify(parsedPayload);
@@ -499,7 +576,7 @@ export default class Device {
// Allows to make changes in incoming message from device.
async #processMessageFromDeviceLegacy(
payload: CDPServerMessage,
debuggerInfo: DebuggerInfo,
debuggerInfo: DebuggerConnection,
pageId: ?string,
) {
// TODO(moti): Handle null case explicitly, or ideally associate a copy
@@ -616,7 +693,7 @@ export default class Device {
*/
#interceptClientMessageForSourceFetching(
req: CDPClientMessage,
debuggerInfo: DebuggerInfo,
debuggerInfo: DebuggerConnection,
socket: WS,
): CDPClientMessage | null {
switch (req.method) {
@@ -633,7 +710,7 @@ export default class Device {
#processDebuggerSetBreakpointByUrl(
req: CDPRequest<'Debugger.setBreakpointByUrl'>,
debuggerInfo: DebuggerInfo,
debuggerInfo: DebuggerConnection,
): CDPRequest<'Debugger.setBreakpointByUrl'> {
// If we replaced Android emulator's address to localhost we need to change it back.
if (debuggerInfo.originalSourceURLAddress != null) {
@@ -11,6 +11,7 @@
import type {EventReporter} from '../types/EventReporter';
import type {Experiments} from '../types/Experiments';
import type {CreateCustomMessageHandlerFn} from './CustomMessageHandler';
import type {
JsonPagesListResponse,
JsonVersionResponse,
@@ -58,17 +59,22 @@ export default class InspectorProxy implements InspectorProxyQueries {
#experiments: Experiments;
// custom message handler factory allowing implementers to handle unsupported CDP messages.
#customMessageHandler: ?CreateCustomMessageHandlerFn;
constructor(
projectRoot: string,
serverBaseUrl: string,
eventReporter: ?EventReporter,
experiments: Experiments,
customMessageHandler: ?CreateCustomMessageHandlerFn,
) {
this.#projectRoot = projectRoot;
this.#serverBaseUrl = serverBaseUrl;
this.#devices = new Map();
this.#eventReporter = eventReporter;
this.#experiments = experiments;
this.#customMessageHandler = customMessageHandler;
}
getPageDescriptions(): Array<PageDescription> {
@@ -204,6 +210,7 @@ export default class InspectorProxy implements InspectorProxyQueries {
socket,
this.#projectRoot,
this.#eventReporter,
this.#customMessageHandler,
);
if (oldDevice) {