Skip to content

Commit

Permalink
feature(dev-middleware): add custom message handlers to extend CDP ca…
Browse files Browse the repository at this point in the history
…pabilities (#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: #43291

Test Plan: See added tests and code above

Reviewed By: huntie

Differential Revision: D54804503

Pulled By: motiz88

fbshipit-source-id: ae918dcd5b7e76d3fb31db4c84717567ae60fa96
  • Loading branch information
byCedric authored and facebook-github-bot committed Mar 12, 2024
1 parent 5833eb5 commit 3f41fb5
Show file tree
Hide file tree
Showing 6 changed files with 466 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
/**
* 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 {createAndConnectTarget} from './InspectorProtocolUtils';
import {withAbortSignalForEachTest} from './ResourceUtils';
import {baseUrlForServer, createServer} from './ServerUtils';
import until from 'wait-for-expect';

// WebSocket is unreliable when using fake timers.
jest.useRealTimers();

jest.setTimeout(10000);

describe('inspector proxy device message middleware', () => {
const autoCleanup = withAbortSignalForEachTest();
const page = {
id: 'page1',
app: 'bar-app',
title: 'bar-title',
vm: 'bar-vm',
};

afterEach(() => {
jest.clearAllMocks();
});

test('middleware is created with device, debugger, and page information', async () => {
const createCustomMessageHandler = jest.fn().mockImplementation(() => null);
const {server} = await createServer({
logger: undefined,
projectRoot: '',
unstable_customInspectorMessageHandler: createCustomMessageHandler,
});

let device, debugger_;
try {
({device, debugger_} = await createAndConnectTarget(
serverRefUrls(server),
autoCleanup.signal,
page,
));

// Ensure the middleware was created with the device information
await until(() =>
expect(createCustomMessageHandler).toBeCalledWith(
expect.objectContaining({
page: expect.objectContaining({
...page,
capabilities: expect.any(Object),
}),
device: expect.objectContaining({
appId: expect.any(String),
id: expect.any(String),
name: expect.any(String),
sendMessage: expect.any(Function),
}),
debugger: expect.objectContaining({
userAgent: null,
sendMessage: expect.any(Function),
}),
}),
),
);
} finally {
device?.close();
debugger_?.close();
await closeServer(server);
}
});

test('send message functions are passing messages to sockets', async () => {
const handleDebuggerMessage = jest.fn();
const handleDeviceMessage = jest.fn();
const createCustomMessageHandler = jest.fn().mockImplementation(() => ({
handleDebuggerMessage,
handleDeviceMessage,
}));

const {server} = await createServer({
logger: undefined,
projectRoot: '',
unstable_customInspectorMessageHandler: createCustomMessageHandler,
});

let device, debugger_;
try {
({device, debugger_} = await createAndConnectTarget(
serverRefUrls(server),
autoCleanup.signal,
page,
));

// Ensure the middleware was created with the send message methods
await until(() =>
expect(createCustomMessageHandler).toBeCalledWith(
expect.objectContaining({
device: expect.objectContaining({
sendMessage: expect.any(Function),
}),
debugger: expect.objectContaining({
sendMessage: expect.any(Function),
}),
}),
),
);

// Send a message to the device
createCustomMessageHandler.mock.calls[0][0].device.sendMessage({
id: 1,
});
// Ensure the device received the message
await until(() =>
expect(device.wrappedEvent).toBeCalledWith({
event: 'wrappedEvent',
payload: {
pageId: page.id,
wrappedEvent: JSON.stringify({id: 1}),
},
}),
);

// Send a message to the debugger
createCustomMessageHandler.mock.calls[0][0].debugger.sendMessage({
id: 2,
});
// Ensure the debugger received the message
await until(() =>
expect(debugger_.handle).toBeCalledWith({
id: 2,
}),
);
} finally {
device?.close();
debugger_?.close();
await closeServer(server);
}
});

test('device message is passed to message middleware', async () => {
const handleDeviceMessage = jest.fn();
const {server} = await createServer({
logger: undefined,
projectRoot: '',
unstable_customInspectorMessageHandler: () => ({
handleDeviceMessage,
handleDebuggerMessage() {},
}),
});

let device, debugger_;
try {
({device, debugger_} = await createAndConnectTarget(
serverRefUrls(server),
autoCleanup.signal,
page,
));

// Send a message from the device, and ensure the middleware received it
device.sendWrappedEvent(page.id, {id: 1337});

// Ensure the debugger received the message
await until(() => expect(debugger_.handle).toBeCalledWith({id: 1337}));
// Ensure the middleware received the message
await until(() => expect(handleDeviceMessage).toBeCalled());
} finally {
device?.close();
debugger_?.close();
await closeServer(server);
}
});

test('device message stops propagating when handled by middleware', async () => {
const handleDeviceMessage = jest.fn();
const {server} = await createServer({
logger: undefined,
projectRoot: '',
unstable_customInspectorMessageHandler: () => ({
handleDeviceMessage,
handleDebuggerMessage() {},
}),
});

let device, debugger_;
try {
({device, debugger_} = await createAndConnectTarget(
serverRefUrls(server),
autoCleanup.signal,
page,
));

// Stop the first message from propagating by returning true (once) from middleware
handleDeviceMessage.mockReturnValueOnce(true);

// Send the first message which should NOT be received by the debugger
device.sendWrappedEvent(page.id, {id: -1});
await until(() => expect(handleDeviceMessage).toBeCalled());

// Send the second message which should be received by the debugger
device.sendWrappedEvent(page.id, {id: 1337});

// Ensure only the last message was received by the debugger
await until(() => expect(debugger_.handle).toBeCalledWith({id: 1337}));
// Ensure the first message was not received by the debugger
expect(debugger_.handle).not.toBeCalledWith({id: -1});
} finally {
device?.close();
debugger_?.close();
await closeServer(server);
}
});

test('debugger message is passed to message middleware', async () => {
const handleDebuggerMessage = jest.fn();
const {server} = await createServer({
logger: undefined,
projectRoot: '',
unstable_customInspectorMessageHandler: () => ({
handleDeviceMessage() {},
handleDebuggerMessage,
}),
});

let device, debugger_;
try {
({device, debugger_} = await createAndConnectTarget(
serverRefUrls(server),
autoCleanup.signal,
page,
));

// Send a message from the debugger
const message = {
method: 'Runtime.enable',
id: 1337,
};
debugger_.send(message);

// Ensure the device received the message
await until(() => expect(device.wrappedEvent).toBeCalled());
// Ensure the middleware received the message
await until(() => expect(handleDebuggerMessage).toBeCalledWith(message));
} finally {
device?.close();
debugger_?.close();
await closeServer(server);
}
});

test('debugger message stops propagating when handled by middleware', async () => {
const handleDebuggerMessage = jest.fn();
const {server} = await createServer({
logger: undefined,
projectRoot: '',
unstable_customInspectorMessageHandler: () => ({
handleDeviceMessage() {},
handleDebuggerMessage,
}),
});

let device, debugger_;
try {
({device, debugger_} = await createAndConnectTarget(
serverRefUrls(server),
autoCleanup.signal,
page,
));

// Stop the first message from propagating by returning true (once) from middleware
handleDebuggerMessage.mockReturnValueOnce(true);

// Send the first emssage which should not be received by the device
debugger_.send({id: -1});
// Send the second message which should be received by the device
debugger_.send({id: 1337});

// Ensure only the last message was received by the device
await until(() =>
expect(device.wrappedEvent).toBeCalledWith({
event: 'wrappedEvent',
payload: {pageId: page.id, wrappedEvent: JSON.stringify({id: 1337})},
}),
);
// Ensure the first message was not received by the device
expect(device.wrappedEvent).not.toBeCalledWith({id: -1});
} finally {
device?.close();
debugger_?.close();
await closeServer(server);
}
});
});

function serverRefUrls(server: http$Server | https$Server) {
return {
serverBaseUrl: baseUrlForServer(server, 'http'),
serverBaseWsUrl: baseUrlForServer(server, 'ws'),
};
}

async function closeServer(server: http$Server | https$Server): Promise<void> {
return new Promise(resolve => server.close(() => resolve()));
}
12 changes: 7 additions & 5 deletions packages/dev-middleware/src/createDevMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* @oncall react_native
*/

import type {CreateCustomMessageHandlerFn} from './inspector-proxy/CustomMessageHandler';
import type {BrowserLauncher} from './types/BrowserLauncher';
import type {EventReporter} from './types/EventReporter';
import type {Experiments, ExperimentsConfig} from './types/Experiments';
Expand Down Expand Up @@ -61,11 +62,12 @@ type Options = $ReadOnly<{
unstable_experiments?: ExperimentsConfig,

/**
* An interface for using a modified inspector proxy implementation.
* 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_InspectorProxy?: Class<InspectorProxy>,
unstable_customInspectorMessageHandler?: CreateCustomMessageHandlerFn,
}>;

type DevMiddlewareAPI = $ReadOnly<{
Expand All @@ -80,16 +82,16 @@ export default function createDevMiddleware({
unstable_browserLauncher = DefaultBrowserLauncher,
unstable_eventReporter,
unstable_experiments: experimentConfig = {},
unstable_InspectorProxy,
unstable_customInspectorMessageHandler,
}: Options): DevMiddlewareAPI {
const experiments = getExperiments(experimentConfig);

const InspectorProxyClass = unstable_InspectorProxy ?? InspectorProxy;
const inspectorProxy = new InspectorProxyClass(
const inspectorProxy = new InspectorProxy(
projectRoot,
serverBaseUrl,
unstable_eventReporter,
experiments,
unstable_customInspectorMessageHandler,
);

const middleware = connect()
Expand Down
8 changes: 5 additions & 3 deletions packages/dev-middleware/src/index.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export {default as createDevMiddleware} from './createDevMiddleware';

export type {BrowserLauncher, LaunchedBrowser} from './types/BrowserLauncher';
export type {EventReporter, ReportableEvent} from './types/EventReporter';

export {default as unstable_InspectorProxy} from './inspector-proxy/InspectorProxy';
export {default as unstable_Device} from './inspector-proxy/Device';
export type {
CustomMessageHandler,
CustomMessageHandlerConnection,
CreateCustomMessageHandlerFn,
} from './inspector-proxy/CustomMessageHandler';
Loading

0 comments on commit 3f41fb5

Please sign in to comment.