diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyCustomMessageHandler-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyCustomMessageHandler-test.js new file mode 100644 index 00000000000000..5f3f8b7798d875 --- /dev/null +++ b/packages/dev-middleware/src/__tests__/InspectorProxyCustomMessageHandler-test.js @@ -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 { + return new Promise(resolve => server.close(() => resolve())); +} diff --git a/packages/dev-middleware/src/createDevMiddleware.js b/packages/dev-middleware/src/createDevMiddleware.js index e6c9dc1f7d93e2..8766811b8885ba 100644 --- a/packages/dev-middleware/src/createDevMiddleware.js +++ b/packages/dev-middleware/src/createDevMiddleware.js @@ -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'; @@ -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, + unstable_customInspectorMessageHandler?: CreateCustomMessageHandlerFn, }>; type DevMiddlewareAPI = $ReadOnly<{ @@ -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() diff --git a/packages/dev-middleware/src/index.flow.js b/packages/dev-middleware/src/index.flow.js index c662838fea10ce..7afbbdbd8894b3 100644 --- a/packages/dev-middleware/src/index.flow.js +++ b/packages/dev-middleware/src/index.flow.js @@ -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'; diff --git a/packages/dev-middleware/src/inspector-proxy/CustomMessageHandler.js b/packages/dev-middleware/src/inspector-proxy/CustomMessageHandler.js new file mode 100644 index 00000000000000..1c3dd473b7c118 --- /dev/null +++ b/packages/dev-middleware/src/inspector-proxy/CustomMessageHandler.js @@ -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; +} diff --git a/packages/dev-middleware/src/inspector-proxy/Device.js b/packages/dev-middleware/src/inspector-proxy/Device.js index 455a89fd09f051..a7c4450c593da3 100644 --- a/packages/dev-middleware/src/inspector-proxy/Device.js +++ b/packages/dev-middleware/src/inspector-proxy/Device.js @@ -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; // 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; + // 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) { diff --git a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js index 532bf06c0b457c..9b9916dc220d98 100644 --- a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js +++ b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js @@ -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 { @@ -204,6 +210,7 @@ export default class InspectorProxy implements InspectorProxyQueries { socket, this.#projectRoot, this.#eventReporter, + this.#customMessageHandler, ); if (oldDevice) {