diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyDeviceMiddleware-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyDeviceMiddleware-test.js index 53fd820ba38164..4a6a4e10465bd9 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyDeviceMiddleware-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyDeviceMiddleware-test.js @@ -9,11 +9,7 @@ * @oncall react_native */ -import type {PageDescription} from '../inspector-proxy/types'; - -import {fetchJson} from './FetchUtils'; -import {createDebuggerMock} from './InspectorDebuggerUtils'; -import {createDeviceMock} from './InspectorDeviceUtils'; +import {createAndConnectTarget} from './InspectorProtocolUtils'; import {withAbortSignalForEachTest} from './ResourceUtils'; import {baseUrlForServer, createServer} from './ServerUtils'; import until from 'wait-for-expect'; @@ -25,12 +21,18 @@ 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 information', async () => { + test('middleware is created with device, debugger, and page information', async () => { const createMiddleware = jest.fn().mockImplementation(() => null); const {server} = await createServer({ logger: undefined, @@ -38,40 +40,38 @@ describe('inspector proxy device message middleware', () => { unstable_deviceMessageMiddleware: createMiddleware, }); - let device_; + let device, debugger_; try { - // Create and connect the device - device_ = await createDeviceMock( - `${baseUrlForServer(server, 'ws')}/inspector/device?device=device1&name=foo&app=bar`, + ({device, debugger_} = await createAndConnectTarget( + serverRefUrls(server), autoCleanup.signal, - ); - const page = { - app: 'bar', - id: 'page1', - // NOTE: 'React' is a magic string used to detect React Native pages. - title: 'React Native (mock)', - vm: 'vm', - }; - device_.getPages.mockImplementation(() => [page]); + page, + )); // Ensure the middleware was created with the device information await until(() => expect(createMiddleware).toBeCalledWith( expect.objectContaining({ - deviceId: 'device1', - deviceName: 'foo', - deviceSocket: expect.anything(), // Websocket - appId: 'bar', - projectRoot: '', page: expect.objectContaining({ ...page, capabilities: expect.any(Object), }), + deviceInfo: expect.objectContaining({ + appId: expect.any(String), + id: expect.any(String), + name: expect.any(String), + socket: expect.anything(), + }), + debuggerInfo: expect.objectContaining({ + socket: expect.anything(), + userAgent: null, + }), }), ), ); } finally { - device_?.close(); + device?.close(); + debugger_?.close(); await closeServer(server); } }); @@ -87,43 +87,23 @@ describe('inspector proxy device message middleware', () => { }), }); - let device_, debugger_; + let device, debugger_; try { - // Create and connect the device - device_ = await createDeviceMock( - `${baseUrlForServer(server, 'ws')}/inspector/device?device=device1&name=foo&app=bar`, - autoCleanup.signal, - ); - device_.getPages.mockImplementation(() => [ - { - app: 'bar', - id: 'page1', - // NOTE: 'React' is a magic string used to detect React Native pages. - title: 'React Native (mock)', - vm: 'vm', - }, - ]); - - // Find the debugger URL - const [{webSocketDebuggerUrl}] = await fetchPageList(server); - expect(webSocketDebuggerUrl).toBeDefined(); - - // Create and connect the debugger - debugger_ = await createDebuggerMock( - webSocketDebuggerUrl, + ({device, debugger_} = await createAndConnectTarget( + serverRefUrls(server), autoCleanup.signal, - ); - await until(() => expect(device_.connect).toBeCalled()); + page, + )); // Send a message from the device, and ensure the middleware received it - device_.sendWrappedEvent('page1', {id: 1337}); + 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(); + device?.close(); debugger_?.close(); await closeServer(server); } @@ -140,49 +120,24 @@ describe('inspector proxy device message middleware', () => { }), }); - let device_, debugger_; + let device, debugger_; try { - // Create and connect the device - device_ = await createDeviceMock( - `${baseUrlForServer(server, 'ws')}/inspector/device?device=device1&name=foo&app=bar`, - autoCleanup.signal, - ); - device_.getPages.mockImplementation(() => [ - { - app: 'bar', - id: 'page1', - // NOTE: 'React' is a magic string used to detect React Native pages. - title: 'React Native (mock)', - vm: 'vm', - }, - ]); - - // Find the debugger URL - const [{webSocketDebuggerUrl}] = await fetchPageList(server); - expect(webSocketDebuggerUrl).toBeDefined(); - - // Create and connect the debugger - debugger_ = await createDebuggerMock( - webSocketDebuggerUrl, + ({device, debugger_} = await createAndConnectTarget( + serverRefUrls(server), autoCleanup.signal, - ); - await until(() => expect(device_.connect).toBeCalled()); + page, + )); // Send a message from the device, and ensure the middleware received it const message = { event: 'disconnect', - payload: {pageId: 'page1'}, + payload: {pageId: page.id}, }; - device_.send(message); + device.send(message); - await until(() => - expect(handleDeviceMessage).toBeCalledWith( - message, // CDP event - expect.any(Object), // Debugger info - ), - ); + await until(() => expect(handleDeviceMessage).toBeCalledWith(message)); } finally { - device_?.close(); + device?.close(); debugger_?.close(); await closeServer(server); } @@ -199,50 +154,30 @@ describe('inspector proxy device message middleware', () => { }), }); - let device_, debugger_; + let device, debugger_; try { - // Create and connect the device - device_ = await createDeviceMock( - `${baseUrlForServer(server, 'ws')}/inspector/device?device=device1&name=foo&app=bar`, + ({device, debugger_} = await createAndConnectTarget( + serverRefUrls(server), autoCleanup.signal, - ); - device_.getPages.mockImplementation(() => [ - { - app: 'bar', - id: 'page1', - // NOTE: 'React' is a magic string used to detect React Native pages. - title: 'React Native (mock)', - vm: 'vm', - }, - ]); - - // Find the debugger URL - const [{webSocketDebuggerUrl}] = await fetchPageList(server); - expect(webSocketDebuggerUrl).toBeDefined(); - - // Create and connect the debugger - debugger_ = await createDebuggerMock( - webSocketDebuggerUrl, - autoCleanup.signal, - ); - await until(() => expect(device_.connect).toBeCalled()); + 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('page1', {id: -1}); + device.sendWrappedEvent(page.id, {id: -1}); await until(() => expect(handleDeviceMessage).toBeCalled()); // Send the second message which should be received by the debugger - device_.sendWrappedEvent('page1', {id: 1337}); + 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(); + device?.close(); debugger_?.close(); await closeServer(server); } @@ -259,33 +194,13 @@ describe('inspector proxy device message middleware', () => { }), }); - let device_, debugger_; + let device, debugger_; try { - // Create and connect the device - device_ = await createDeviceMock( - `${baseUrlForServer(server, 'ws')}/inspector/device?device=device1&name=foo&app=bar`, + ({device, debugger_} = await createAndConnectTarget( + serverRefUrls(server), autoCleanup.signal, - ); - device_.getPages.mockImplementation(() => [ - { - app: 'bar', - id: 'page1', - // NOTE: 'React' is a magic string used to detect React Native pages. - title: 'React Native (mock)', - vm: 'vm', - }, - ]); - - // Find the debugger URL - const [{webSocketDebuggerUrl}] = await fetchPageList(server); - expect(webSocketDebuggerUrl).toBeDefined(); - - // Create and connect the debugger - debugger_ = await createDebuggerMock( - webSocketDebuggerUrl, - autoCleanup.signal, - ); - await until(() => expect(device_.connect).toBeCalled()); + page, + )); // Send a message from the debugger const message = { @@ -295,16 +210,11 @@ describe('inspector proxy device message middleware', () => { debugger_.send(message); // Ensure the device received the message - await until(() => expect(device_.wrappedEvent).toBeCalled()); + await until(() => expect(device.wrappedEvent).toBeCalled()); // Ensure the middleware received the message - await until(() => - expect(handleDebuggerMessage).toBeCalledWith( - message, // CDP event - expect.any(Object), // Debugger info - ), - ); + await until(() => expect(handleDebuggerMessage).toBeCalledWith(message)); } finally { - device_?.close(); + device?.close(); debugger_?.close(); await closeServer(server); } @@ -321,33 +231,13 @@ describe('inspector proxy device message middleware', () => { }), }); - let device_, debugger_; + let device, debugger_; try { - // Create and connect the device - device_ = await createDeviceMock( - `${baseUrlForServer(server, 'ws')}/inspector/device?device=device1&name=foo&app=bar`, - autoCleanup.signal, - ); - device_.getPages.mockImplementation(() => [ - { - app: 'bar', - id: 'page1', - // NOTE: 'React' is a magic string used to detect React Native pages. - title: 'React Native (mock)', - vm: 'vm', - }, - ]); - - // Find the debugger URL - const [{webSocketDebuggerUrl}] = await fetchPageList(server); - expect(webSocketDebuggerUrl).toBeDefined(); - - // Create and connect the debugger - debugger_ = await createDebuggerMock( - webSocketDebuggerUrl, + ({device, debugger_} = await createAndConnectTarget( + serverRefUrls(server), autoCleanup.signal, - ); - await until(() => expect(device_.connect).toBeCalled()); + page, + )); // Stop the first message from propagating by returning true (once) from middleware handleDebuggerMessage.mockReturnValueOnce(true); @@ -359,34 +249,26 @@ describe('inspector proxy device message middleware', () => { // Ensure only the last message was received by the device await until(() => - expect(device_.wrappedEvent).toBeCalledWith({ + expect(device.wrappedEvent).toBeCalledWith({ event: 'wrappedEvent', - payload: {pageId: 'page1', wrappedEvent: JSON.stringify({id: 1337})}, + 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}); + expect(device.wrappedEvent).not.toBeCalledWith({id: -1}); } finally { - device_?.close(); + device?.close(); debugger_?.close(); await closeServer(server); } }); }); -async function fetchPageList( - server: http$Server | https$Server, -): Promise { - let pageList: Array = []; - await until(async () => { - pageList = (await fetchJson( - `${baseUrlForServer(server, 'http')}/json`, - // $FlowIgnore[unclear-type] - ): any); - expect(pageList.length).toBeGreaterThanOrEqual(1); - }); - - return pageList; +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 { diff --git a/packages/dev-middleware/src/inspector-proxy/Device.js b/packages/dev-middleware/src/inspector-proxy/Device.js index 1df49c75a48169..218cd62c3ba488 100644 --- a/packages/dev-middleware/src/inspector-proxy/Device.js +++ b/packages/dev-middleware/src/inspector-proxy/Device.js @@ -16,10 +16,6 @@ import type { CDPResponse, CDPServerMessage, } from './cdp-types/messages'; -import type { - DeviceMessageMiddleware, - createDeviceMessageMiddleware, -} from './DeviceMessageMiddleware'; import type { MessageFromDevice, MessageToDevice, @@ -28,7 +24,11 @@ import type { } from './types'; import DeviceEventReporter from './DeviceEventReporter'; -import {createMiddlewareDebuggerInfo} from './DeviceMessageMiddleware'; +import { + type DeviceMessageMiddleware, + type createDeviceMessageMiddleware, + createMiddlewareDebuggerInfo, +} from './DeviceMessageMiddleware'; import * as fs from 'fs'; import fetch from 'node-fetch'; import * as path from 'path'; @@ -56,8 +56,6 @@ export type DebuggerInfo = { userAgent: string | null, }; -type PageWithMiddleware = {...Page, middleware: ?DeviceMessageMiddleware}; - const REACT_NATIVE_RELOADABLE_PAGE_ID = '-1'; /** @@ -78,7 +76,7 @@ export default class Device { #deviceSocket: WS; // Stores the most recent listing of device's pages, keyed by the `id` field. - #pages: $ReadOnlyMap; + #pages: $ReadOnlyMap; // Stores information about currently connected debugger (if any). #debuggerConnection: ?DebuggerInfo = null; @@ -86,7 +84,7 @@ export default class Device { // 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: ?Page | ?PageWithMiddleware = null; + #lastConnectedLegacyReactNativePage: ?Page = null; // Whether we are in the middle of a reload in the REACT_NATIVE_RELOADABLE_PAGE. #isLegacyPageReloading: boolean = false; @@ -107,6 +105,12 @@ export default class Device { // The device message middleware factory function allowing implementers to handle unsupported CDP messages. #createMessageMiddleware: ?createDeviceMessageMiddleware; + // The device message middleware instances, per debugger, page, and device connection + #messageMiddlewares: WeakMap< + DebuggerInfo, + WeakMap, + >; + constructor( id: string, name: string, @@ -130,6 +134,7 @@ export default class Device { }) : null; this.#createMessageMiddleware = createMessageMiddleware; + this.#messageMiddlewares = new WeakMap(); // $FlowFixMe[incompatible-call] this.#deviceSocket.on('message', (message: string) => { @@ -173,10 +178,6 @@ export default class Device { } getPagesList(): $ReadOnlyArray { - const pages = [...this.#pages.values()].map( - ({middleware, ...page}) => page, - ); - if (this.#lastConnectedLegacyReactNativePage) { const reactNativeReloadablePage = { id: REACT_NATIVE_RELOADABLE_PAGE_ID, @@ -185,9 +186,9 @@ export default class Device { app: this.#app, capabilities: {}, }; - return [...pages, reactNativeReloadablePage]; + return [...this.#pages.values(), reactNativeReloadablePage]; } else { - return [...pages]; + return [...this.#pages.values()]; } } @@ -225,12 +226,34 @@ export default class Device { // TODO(moti): Handle null case explicitly, e.g. refuse to connect to // unknown pages. - const page: ?PageWithMiddleware = this.#pages.get(pageId); + const page: ?Page = this.#pages.get(pageId); this.#debuggerConnection = debuggerInfo; debug(`Got new debugger connection for page ${pageId} of ${this.#name}`); + if (page && this.#createMessageMiddleware) { + const middleware = this.#createMessageMiddleware({ + page, + debuggerInfo: createMiddlewareDebuggerInfo(debuggerInfo), + deviceInfo: { + appId: this.#app, + id: this.#id, + name: this.#name, + socket: this.#deviceSocket, + }, + }); + + if (middleware) { + this.#setMessageMiddleware(debuggerInfo, page, middleware); + debug('Created new message middleware for debugger connection'); + } else { + debug( + 'Skipping new message middleware for debugger connection, factory function returned null', + ); + } + } + this.#sendMessageToDevice({ event: 'connect', payload: { @@ -249,9 +272,8 @@ export default class Device { let processedReq = debuggerRequest; if ( - page?.middleware?.handleDebuggerMessage( + this.#getMessageMiddleware(debuggerInfo, page)?.handleDebuggerMessage( debuggerRequest, - createMiddlewareDebuggerInfo(debuggerInfo), ) === true ) { return; @@ -284,6 +306,7 @@ export default class Device { pageId: this.#mapToDevicePageId(pageId), }, }); + this.#messageMiddlewares.delete(debuggerInfo); this.#debuggerConnection = null; }); @@ -320,6 +343,7 @@ export default class Device { if (oldDebugger) { oldDebugger.socket.removeAllListeners(); this.#deviceSocket.close(); + this.#messageMiddlewares.delete(oldDebugger); newDevice.handleDebuggerConnection( oldDebugger.socket, oldDebugger.pageId, @@ -333,10 +357,7 @@ export default class Device { /** * Returns `true` if a page supports the given target capability flag. */ - #pageHasCapability( - page: Page | PageWithMiddleware, - flag: $Keys, - ): boolean { + #pageHasCapability(page: Page, flag: $Keys): boolean { return page.capabilities[flag] === true; } @@ -350,22 +371,15 @@ export default class Device { #handleMessageFromDevice(message: MessageFromDevice) { if (message.event === 'getPages') { this.#pages = new Map( - message.payload.map(({capabilities, ...pageProps}) => { - const page = {...pageProps, capabilities: capabilities ?? {}}; - const middleware = this.#createMessageMiddleware - ? this.#createMessageMiddleware({ - appId: this.#app, - deviceId: this.#id, - deviceName: this.#name, - deviceSocket: this.#deviceSocket, - page, - projectRoot: this.#projectRoot, - }) - : null; - - return [page.id, {...page, middleware}]; - }), + 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(); @@ -406,16 +420,14 @@ export default class Device { const pageId = message.payload.pageId; // TODO(moti): Handle null case explicitly, e.g. swallow disconnect events // for unknown pages. - const page: ?PageWithMiddleware = this.#pages.get(pageId); + const page: ?Page = this.#pages.get(pageId); // NOTE(bycedric): Notify the device message middleware of the disconnect event, without any further actions. // This can be used to clean up state in the device message middleware. - page?.middleware?.handleDeviceMessage( - message, - this.#debuggerConnection - ? createMiddlewareDebuggerInfo(this.#debuggerConnection) - : null, - ); + this.#getMessageMiddleware( + this.#debuggerConnection, + page, + )?.handleDeviceMessage(message); if (page != null && this.#pageHasCapability(page, 'nativePageReloads')) { return; @@ -457,15 +469,12 @@ export default class Device { }); } - const page: ?PageWithMiddleware = - pageId !== null ? this.#pages.get(pageId) : null; + const page: ?Page = pageId !== null ? this.#pages.get(pageId) : null; if ( - page?.middleware?.handleDeviceMessage( - parsedPayload, - this.#debuggerConnection - ? createMiddlewareDebuggerInfo(this.#debuggerConnection) - : null, - ) === true + this.#getMessageMiddleware( + this.#debuggerConnection, + page, + )?.handleDeviceMessage(parsedPayload) === true ) { return; } @@ -498,7 +507,7 @@ export default class Device { } // We received new React Native Page ID. - #newLegacyReactNativePage(page: Page | PageWithMiddleware) { + #newLegacyReactNativePage(page: Page) { debug(`React Native page updated to ${page.id}`); if ( this.#debuggerConnection == null || @@ -564,8 +573,7 @@ export default class Device { // 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: ?PageWithMiddleware = - pageId != null ? this.#pages.get(pageId) : null; + const page: ?Page = pageId != null ? this.#pages.get(pageId) : null; // Replace Android addresses for scriptParsed event. if ( @@ -856,4 +864,25 @@ export default class Device { ); } } + + #getMessageMiddleware( + debuggerInfo: ?DebuggerInfo, + page: ?Page, + ): ?DeviceMessageMiddleware { + return debuggerInfo && page + ? this.#messageMiddlewares.get(debuggerInfo)?.get(page) + : null; + } + + #setMessageMiddleware( + debuggerInfo: DebuggerInfo, + page: Page, + middleware: DeviceMessageMiddleware, + ) { + if (!this.#messageMiddlewares.has(debuggerInfo)) { + this.#messageMiddlewares.set(debuggerInfo, new WeakMap()); + } + + this.#messageMiddlewares.get(debuggerInfo)?.set(page, middleware); + } } diff --git a/packages/dev-middleware/src/inspector-proxy/DeviceMessageMiddleware.js b/packages/dev-middleware/src/inspector-proxy/DeviceMessageMiddleware.js index 857aaf3f449d18..0a43735121eaf1 100644 --- a/packages/dev-middleware/src/inspector-proxy/DeviceMessageMiddleware.js +++ b/packages/dev-middleware/src/inspector-proxy/DeviceMessageMiddleware.js @@ -14,26 +14,21 @@ import type WS from 'ws'; type ExposedDeviceInfo = $ReadOnly<{ appId: string, - deviceId: string, - deviceName: string, - deviceSocket: WS, - page: Page, - projectRoot: string, + id: string, + name: string, + socket: WS, }>; type ExposedDebuggerInfo = $ReadOnly<{ - debuggerSocket: $ElementType, - debuggerUserAgent: $ElementType, - prependedFilePrefix: $ElementType, - originalSourceURLAddress: $ElementType< - DebuggerInfo, - 'originalSourceURLAddress', - >, + socket: $ElementType, + userAgent: $ElementType, }>; -export type createDeviceMessageMiddleware = ( +export type createDeviceMessageMiddleware = (connection: { + page: Page, deviceInfo: ExposedDeviceInfo, -) => ?DeviceMessageMiddleware; + debuggerInfo: ExposedDebuggerInfo, +}) => ?DeviceMessageMiddleware; /** * The device message middleware allows implementers to handle unsupported CDP messages. @@ -46,29 +41,21 @@ export interface DeviceMessageMiddleware { * 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: MessageFromDevice, - debuggerInfo: ?ExposedDebuggerInfo, - ): true | void; + handleDeviceMessage(message: MessageFromDevice): true | void; /** * Handle a CDP message coming from the debugger. * This is invoked before the message is sent to the device. * When reeturning true, the message is considered handled and will not be sent to the device. */ - handleDebuggerMessage( - message: MessageToDevice, - debuggerInfo: ExposedDebuggerInfo, - ): true | void; + handleDebuggerMessage(message: MessageToDevice): true | void; } export function createMiddlewareDebuggerInfo( debuggerInfo: DebuggerInfo, ): ExposedDebuggerInfo { return { - debuggerSocket: debuggerInfo.socket, - debuggerUserAgent: debuggerInfo.userAgent, - prependedFilePrefix: debuggerInfo.prependedFilePrefix, - originalSourceURLAddress: debuggerInfo.originalSourceURLAddress, + socket: debuggerInfo.socket, + userAgent: debuggerInfo.userAgent, }; }