From 584a00808aaa9b9f7e562e9ce7ae3d962969e6c1 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Fri, 23 Jan 2026 07:46:04 -0800 Subject: [PATCH] Add multi-session support to Device.js (#55237) Summary: Adds multi-session support to the Node side of the inspector-proxy protocol implementation. The protocol now has an explicit `sessionId` property in all relevant messages. Multi-session support is fully backwards compatible: * An app that does not report itself as multi-session capable will get the old proxy behaviour. * If the `enableStandaloneFuseboxShell` experiment flag is disabled by the integrator/framework, we automatically disable multi-session support, too. This is to guarantee that we continue to have at most one active RNDT window/tab open per app. (The standalone shell guarantees this independently of the proxy, while the old browser-based flow requires the proxy to keep enforcing the single-session UX.) Changelog: [General][Added] Support multiple CDP connections to one React Native Host (diff 2 of 2) Reviewed By: robhogan Differential Revision: D90174643 --- .../src/__tests__/InspectorDeviceUtils.js | 11 +- .../src/__tests__/InspectorProtocolUtils.js | 17 +- .../InspectorProxyCdpRewritingHacks-test.js | 31 +- .../InspectorProxyCdpTransport-test.js | 280 +++++++++++++ .../InspectorProxyConcurrentSessions-test.js | 109 +++++ ...InspectorProxyCustomMessageHandler-test.js | 7 +- .../InspectorProxyDeviceHandoff-test.js | 109 +++++ .../InspectorProxyReactNativeReloads-test.js | 8 +- .../src/inspector-proxy/Device.js | 377 +++++++++++------- .../src/inspector-proxy/InspectorProxy.js | 3 +- .../src/inspector-proxy/types.js | 33 +- .../dev-middleware/src/types/Experiments.js | 4 + 12 files changed, 824 insertions(+), 165 deletions(-) create mode 100644 packages/dev-middleware/src/__tests__/InspectorProxyConcurrentSessions-test.js diff --git a/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js b/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js index 1c8c1a55be4d18..0a0245cf614c25 100644 --- a/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js +++ b/packages/dev-middleware/src/__tests__/InspectorDeviceUtils.js @@ -16,7 +16,7 @@ import type { JSONSerializable, MessageFromDevice, MessageToDevice, - WrappedEvent, + WrappedEventToDevice, } from '../inspector-proxy/types'; import nullthrows from 'nullthrows'; @@ -106,9 +106,14 @@ export class DeviceMock extends DeviceAgent { | Promise | void, > = jest.fn(); - +wrappedEvent: JestMockFn<[message: WrappedEvent], void> = jest.fn(); + +wrappedEvent: JestMockFn<[message: WrappedEventToDevice], void> = jest.fn(); +wrappedEventParsed: JestMockFn< - [payload: {...WrappedEvent['payload'], wrappedEvent: JSONSerializable}], + [ + payload: { + ...WrappedEventToDevice['payload'], + wrappedEvent: JSONSerializable, + }, + ], void, > = jest.fn(); diff --git a/packages/dev-middleware/src/__tests__/InspectorProtocolUtils.js b/packages/dev-middleware/src/__tests__/InspectorProtocolUtils.js index 3b7470bbc02f2f..c3793887818b34 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProtocolUtils.js +++ b/packages/dev-middleware/src/__tests__/InspectorProtocolUtils.js @@ -81,6 +81,7 @@ export async function sendFromDebuggerToTarget( device: DeviceMock, pageId: string, message: Message, + {sessionId}: {sessionId?: string} = {}, ): Promise { const originalEventCallsArray = device.wrappedEventParsed.mock.calls; const originalEventCallCount = originalEventCallsArray.length; @@ -88,6 +89,7 @@ export async function sendFromDebuggerToTarget( await until(() => expect(device.wrappedEventParsed).toBeCalledWith({ pageId, + sessionId: sessionId ?? expect.any(String), wrappedEvent: expect.objectContaining({id: message.id}), }), ); @@ -125,9 +127,10 @@ export async function createAndConnectTarget( deviceId?: ?string, deviceHostHeader?: ?string, }> = {}, -): Promise<{device: DeviceMock, debugger_: DebuggerMock}> { +): Promise<{device: DeviceMock, debugger_: DebuggerMock, sessionId: string}> { let device; let debugger_; + let sessionId; try { device = await createDeviceMock( `${serverRef.serverBaseWsUrl}/inspector/device?device=${ @@ -149,16 +152,26 @@ export async function createAndConnectTarget( const [{webSocketDebuggerUrl}] = pageList; expect(webSocketDebuggerUrl).toBeDefined(); + const originalConnectCallsArray = device.connect.mock.calls; + const originalConnectCallCount = originalConnectCallsArray.length; debugger_ = await createDebuggerMock( webSocketDebuggerUrl, signal, debuggerHeaders, ); await until(() => expect(device.connect).toBeCalled()); + // Find the first connect call that wasn't already in the mock calls array + // before we connected the debugger. + const newConnectCalls = + originalConnectCallsArray === device.connect.mock.calls + ? device.connect.mock.calls.slice(originalConnectCallCount) + : device.connect.mock.calls; + const [[{payload}]] = newConnectCalls; + sessionId = payload.sessionId; } catch (e) { device?.close(); debugger_?.close(); throw e; } - return {device, debugger_}; + return {device, debugger_, sessionId}; } diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js index 7be509389c4bcb..46b9c2806da762 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js @@ -566,7 +566,7 @@ describe.each(['HTTP', 'HTTPS'])( describe('Debugger.getScriptSource', () => { test('should forward request directly to device (does not read source from disk in proxy)', async () => { - const {device, debugger_} = await createAndConnectTarget( + const {device, debugger_, sessionId} = await createAndConnectTarget( serverRef, autoCleanup.signal, pageDescription, @@ -579,10 +579,17 @@ describe.each(['HTTP', 'HTTPS'])( scriptId: 'script1', }, }; - await sendFromDebuggerToTarget(debugger_, device, 'page1', message); + await sendFromDebuggerToTarget( + debugger_, + device, + 'page1', + message, + {sessionId}, + ); expect(device.wrappedEventParsed).toBeCalledWith({ pageId: 'page1', + sessionId, wrappedEvent: message, }); } finally { @@ -594,7 +601,7 @@ describe.each(['HTTP', 'HTTPS'])( describe('Network.loadNetworkResource', () => { test('should forward event directly to client (does not rewrite url host)', async () => { - const {device, debugger_} = await createAndConnectTarget( + const {device, debugger_, sessionId} = await createAndConnectTarget( serverRef, autoCleanup.signal, pageDescription, @@ -607,11 +614,19 @@ describe.each(['HTTP', 'HTTPS'])( url: `${protocol.toLowerCase()}://10.0.2.2:${serverRef.port}`, }, }; - await sendFromDebuggerToTarget(debugger_, device, 'page1', message); - expect(device.wrappedEventParsed).toBeCalledWith({ - pageId: 'page1', - wrappedEvent: message, - }); + await sendFromDebuggerToTarget( + debugger_, + device, + 'page1', + message, + {sessionId}, + ); + expect(device.wrappedEventParsed).toBeCalledWith( + expect.objectContaining({ + pageId: 'page1', + wrappedEvent: message, + }), + ); } finally { device.close(); debugger_.close(); diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js index f7f1ad9fdccc7a..53e893dfc25d5f 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyCdpTransport-test.js @@ -21,6 +21,7 @@ import {createDeviceMock} from './InspectorDeviceUtils'; import {sendFromDebuggerToTarget} from './InspectorProtocolUtils'; import {withAbortSignalForEachTest} from './ResourceUtils'; import {withServerForEachTest} from './ServerUtils'; +import nullthrows from 'nullthrows'; import until from 'wait-for-expect'; // WebSocket is unreliable when using fake timers. @@ -82,6 +83,7 @@ describe.each(['HTTP', 'HTTPS'])( expect(device1.wrappedEventParsed).toBeCalledWith({ pageId: 'page1', + sessionId: expect.any(String), wrappedEvent: { method: 'Runtime.enable', id: 0, @@ -239,6 +241,7 @@ describe.each(['HTTP', 'HTTPS'])( event: 'connect', payload: { pageId: 'page1', + sessionId: expect.any(String), }, }, { @@ -250,6 +253,7 @@ describe.each(['HTTP', 'HTTPS'])( event: 'disconnect', payload: { pageId: 'page1', + sessionId: expect.any(String), }, }, { @@ -257,9 +261,23 @@ describe.each(['HTTP', 'HTTPS'])( event: 'connect', payload: { pageId: 'page1', + sessionId: expect.any(String), }, }, ]); + // Verify that disconnect and second connect use different session IDs + // (disconnect is for first debugger, connect is for second debugger) + const disconnectEvent = nullthrows( + events.find(e => e.event === 'disconnect'), + ); + const connectEvents = events.filter(e => e.event === 'connect'); + expect(connectEvents).toHaveLength(2); + expect(disconnectEvent.payload.sessionId).toBe( + connectEvents[0].payload.sessionId, + ); + expect(disconnectEvent?.payload.sessionId).not.toBe( + connectEvents[1].payload.sessionId, + ); } finally { device?.close(); debugger1?.close(); @@ -375,6 +393,7 @@ describe.each(['HTTP', 'HTTPS'])( event: 'connect', payload: { pageId: 'page1', + sessionId: expect.any(String), }, }); expect(device.disconnect).not.toHaveBeenCalled(); @@ -474,5 +493,266 @@ describe.each(['HTTP', 'HTTPS'])( } }, ); + + describe('session ID handling', () => { + test('each debugger connection receives a unique session ID', async () => { + let device, debugger1, debugger2; + try { + device = await createDeviceMock( + `${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + device.getPages.mockImplementation(() => [ + { + app: 'bar-app', + id: 'page1', + title: 'bar-title', + vm: 'bar-vm', + }, + ]); + let pageList: Array = []; + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowFixMe[unclear-type] + ): any); + expect(pageList).toHaveLength(1); + }); + const [{webSocketDebuggerUrl}] = pageList; + + // Collect all connect events + const connectEvents: Array< + $ReadOnly<{ + event: string, + payload: $ReadOnly<{pageId: string, sessionId?: string}>, + }>, + > = []; + device.connect.mockImplementation(message => { + connectEvents.push(message); + }); + + // Connect first debugger + debugger1 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device.connect).toBeCalled()); + + // Connect second debugger to the same page + debugger2 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(connectEvents).toHaveLength(2)); + + // Verify session IDs are unique + const sessionId1 = connectEvents[0].payload.sessionId; + const sessionId2 = connectEvents[1].payload.sessionId; + expect(sessionId1).toEqual(expect.any(String)); + expect(sessionId2).toEqual(expect.any(String)); + expect(sessionId1).not.toEqual(sessionId2); + } finally { + device?.close(); + debugger1?.close(); + debugger2?.close(); + } + }); + + test('session ID is included in all wrappedEvent messages from debugger to device', async () => { + let device, debugger_; + try { + device = await createDeviceMock( + `${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + device.getPages.mockImplementation(() => [ + { + app: 'bar-app', + id: 'page1', + title: 'bar-title', + vm: 'bar-vm', + }, + ]); + let pageList: Array = []; + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowFixMe[unclear-type] + ): any); + expect(pageList).toHaveLength(1); + }); + const [{webSocketDebuggerUrl}] = pageList; + + debugger_ = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device.connect).toBeCalled()); + + // Get the session ID from the connect event + const connectPayload = device.connect.mock.calls[0][0].payload; + const sessionId = connectPayload.sessionId; + + // Send multiple messages and verify they all have the same sessionId + debugger_.send({method: 'Runtime.enable', id: 1}); + debugger_.send({method: 'Debugger.enable', id: 2}); + debugger_.send({method: 'Console.enable', id: 3}); + + await until(() => + expect(device.wrappedEvent).toHaveBeenCalledTimes(3), + ); + + // All messages should have the same sessionId + device.wrappedEventParsed.mock.calls.forEach(call => { + expect(call[0].sessionId).toBe(sessionId); + }); + } finally { + device?.close(); + debugger_?.close(); + } + }); + + test('disconnect event includes the session ID', async () => { + let device, debugger_; + try { + device = await createDeviceMock( + `${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + device.getPages.mockImplementation(() => [ + { + app: 'bar-app', + id: 'page1', + title: 'bar-title', + vm: 'bar-vm', + }, + ]); + let pageList: Array = []; + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowFixMe[unclear-type] + ): any); + expect(pageList).toHaveLength(1); + }); + const [{webSocketDebuggerUrl}] = pageList; + + debugger_ = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device.connect).toBeCalled()); + + // Get the session ID from the connect event + const connectPayload = device.connect.mock.calls[0][0].payload; + const sessionId = connectPayload.sessionId; + + // Close the debugger + debugger_.close(); + + // Verify disconnect includes the sessionId + await until(() => expect(device.disconnect).toBeCalled()); + expect(device.disconnect).toHaveBeenCalledWith({ + event: 'disconnect', + payload: { + pageId: 'page1', + sessionId, + }, + }); + } finally { + device?.close(); + debugger_?.close(); + } + }); + + test('page with supportsMultipleDebuggers allows concurrent debugger connections', async () => { + let device, debugger1, debugger2; + try { + device = await createDeviceMock( + `${serverRef.serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + // Page with supportsMultipleDebuggers capability + device.getPages.mockImplementation(() => [ + { + app: 'bar-app', + id: 'page1', + title: 'bar-title', + vm: 'bar-vm', + capabilities: { + supportsMultipleDebuggers: true, + }, + }, + ]); + let pageList: Array = []; + await until(async () => { + pageList = (await fetchJson( + `${serverRef.serverBaseUrl}/json`, + // $FlowFixMe[unclear-type] + ): any); + expect(pageList).toHaveLength(1); + }); + const [{webSocketDebuggerUrl}] = pageList; + + // Collect all connect and disconnect events + const events: Array = []; + device.connect.mockImplementation(message => { + events.push(message); + }); + device.disconnect.mockImplementation(message => { + events.push(message); + }); + + // Connect first debugger + debugger1 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device.connect).toBeCalledTimes(1)); + + // Connect second debugger - should NOT kick out first + debugger2 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device.connect).toBeCalledTimes(2)); + + // Verify first debugger is still open (not kicked out) + expect(debugger1.socket.readyState).toBe(1); // OPEN + + // Verify no disconnect events were sent + expect(device.disconnect).not.toHaveBeenCalled(); + + // Verify we have two connect events with unique session IDs + expect(events).toHaveLength(2); + expect(events[0].event).toBe('connect'); + expect(events[1].event).toBe('connect'); + expect(events[0].payload.sessionId).not.toEqual( + events[1].payload.sessionId, + ); + + // Both debuggers can send messages independently + debugger1.send({method: 'Runtime.enable', id: 1}); + debugger2.send({method: 'Debugger.enable', id: 2}); + + await until(() => + expect(device.wrappedEvent).toHaveBeenCalledTimes(2), + ); + + // Verify messages came from different sessions + const wrappedCalls = device.wrappedEventParsed.mock.calls; + expect(wrappedCalls[0][0].sessionId).toBe( + events[0].payload.sessionId, + ); + expect(wrappedCalls[1][0].sessionId).toBe( + events[1].payload.sessionId, + ); + } finally { + device?.close(); + debugger1?.close(); + debugger2?.close(); + } + }); + }); }, ); diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyConcurrentSessions-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyConcurrentSessions-test.js new file mode 100644 index 00000000000000..c8b29d962c83f4 --- /dev/null +++ b/packages/dev-middleware/src/__tests__/InspectorProxyConcurrentSessions-test.js @@ -0,0 +1,109 @@ +/** + * 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 {JsonPagesListResponse} from '../inspector-proxy/types'; + +import {fetchJson} from './FetchUtils'; +import {createDebuggerMock} from './InspectorDebuggerUtils'; +import {createDeviceMock} from './InspectorDeviceUtils'; +import {withAbortSignalForEachTest} from './ResourceUtils'; +import {createServer} from './ServerUtils'; +import until from 'wait-for-expect'; + +// WebSocket is unreliable when using fake timers. +jest.useRealTimers(); + +jest.setTimeout(10000); + +describe('inspector proxy concurrent sessions', () => { + const autoCleanup = withAbortSignalForEachTest(); + + describe('enableStandaloneFuseboxShell experiment disabled', () => { + test('page reporting supportsMultipleDebuggers:true is treated as false', async () => { + // Create a server with the experiment disabled + const {server} = await createServer({ + logger: undefined, + unstable_experiments: { + enableStandaloneFuseboxShell: false, + }, + }); + const serverBaseUrl = `http://localhost:${server.address().port}`; + const serverBaseWsUrl = `ws://localhost:${server.address().port}`; + + let device, debugger1, debugger2; + try { + // Connect a device with a page that explicitly reports supportsMultipleDebuggers: true + device = await createDeviceMock( + `${serverBaseWsUrl}/inspector/device?device=device1&name=foo&app=bar`, + autoCleanup.signal, + ); + device.getPages.mockImplementation(() => [ + { + id: 'page1', + app: 'bar-app', + title: 'bar-title', + vm: 'bar-vm', + capabilities: { + supportsMultipleDebuggers: true, + }, + }, + ]); + + // Wait for page to be listed + let pageList: JsonPagesListResponse = []; + await until(async () => { + pageList = (await fetchJson( + `${serverBaseUrl}/json`, + // $FlowFixMe[unclear-type] + ): any); + expect(pageList).toHaveLength(1); + }); + + // Verify the capability is reported as false in the page list + expect( + pageList[0].reactNative?.capabilities?.supportsMultipleDebuggers, + ).toBe(false); + + const webSocketDebuggerUrl = pageList[0].webSocketDebuggerUrl; + + // Connect first debugger + debugger1 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device.connect).toHaveBeenCalledTimes(1)); + + // Connect second debugger - this should disconnect the first one + // because multi-session is disabled + debugger2 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + + // First debugger should be disconnected + await until(() => + expect([3, 4]).toContain(debugger1.socket.readyState), + ); + + // Second debugger should be open + expect(debugger2.socket.readyState).toBe(1); // OPEN + + // Device should have received disconnect for first session and connect for second + await until(() => expect(device.disconnect).toHaveBeenCalledTimes(1)); + await until(() => expect(device.connect).toHaveBeenCalledTimes(2)); + } finally { + device?.close(); + debugger1?.close(); + debugger2?.close(); + server.close(); + } + }); + }); +}); diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyCustomMessageHandler-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyCustomMessageHandler-test.js index 4ad3bd2238d673..12a338dad11009 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyCustomMessageHandler-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyCustomMessageHandler-test.js @@ -202,6 +202,7 @@ describe('inspector proxy device message middleware', () => { event: 'wrappedEvent', payload: { pageId: page.id, + sessionId: expect.any(String), wrappedEvent: JSON.stringify({id: 1}), }, }), @@ -361,7 +362,11 @@ describe('inspector proxy device message middleware', () => { await until(() => expect(device.wrappedEvent).toBeCalledWith({ event: 'wrappedEvent', - payload: {pageId: page.id, wrappedEvent: JSON.stringify({id: 1337})}, + payload: { + pageId: page.id, + sessionId: expect.any(String), + wrappedEvent: JSON.stringify({id: 1337}), + }, }), ); // Ensure the first message was not received by the device diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyDeviceHandoff-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyDeviceHandoff-test.js index 7138be25f83ab5..1ceb76368bd3e5 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyDeviceHandoff-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyDeviceHandoff-test.js @@ -145,6 +145,115 @@ describe('inspector-proxy device socket handoff', () => { } }); + test('device ID collision with multiple debuggers connected restores all connections', async () => { + let device1, device2, debugger1, debugger2, webSocketDebuggerUrl; + try { + // Connect device with a page that supports multiple debuggers + ({ + device: device1, + pageList: [{webSocketDebuggerUrl}], + } = await connectDevice( + '/inspector/device?device=device&name=foo&app=bar', + [ + { + ...PAGE_DEFAULTS, + vm: 'bar-vm', + capabilities: { + supportsMultipleDebuggers: true, + }, + }, + ], + )); + + // Connect two debuggers to the same page + debugger1 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device1.connect).toBeCalledTimes(1)); + + debugger2 = await createDebuggerMock( + webSocketDebuggerUrl, + autoCleanup.signal, + ); + await until(() => expect(device1.connect).toBeCalledTimes(2)); + + // Both debuggers should be open + expect(debugger1.socket.readyState).toBe(1); // OPEN + expect(debugger2.socket.readyState).toBe(1); // OPEN + + // Now simulate device ID collision (device reconnects with same ID) + ({device: device2} = await connectDevice( + '/inspector/device?device=device&name=foo&app=bar', + [ + { + ...PAGE_DEFAULTS, + vm: 'bar-vm-updated', + capabilities: { + supportsMultipleDebuggers: true, + }, + }, + ], + )); + + // Device1 socket should be closed + expect([3, 4]).toContain(device1.socket.readyState); + + // Both debugger sockets should still be open (handed off to device2) + expect(debugger1.socket.readyState).toBe(1); // OPEN + expect(debugger2.socket.readyState).toBe(1); // OPEN + + // Device2 should have received connect events for both debuggers + await until(() => expect(device2.connect).toBeCalledTimes(2)); + + // Both debuggers should be able to send messages to device2 + device1.wrappedEventParsed.mockClear(); + + const receivedByDevice2FromDebugger1 = await sendFromDebuggerToTarget( + debugger1, + device2, + 'page1', + { + method: 'Runtime.enable', + id: 1, + }, + ); + expect(receivedByDevice2FromDebugger1).toEqual({ + method: 'Runtime.enable', + id: 1, + }); + + const receivedByDevice2FromDebugger2 = await sendFromDebuggerToTarget( + debugger2, + device2, + 'page1', + { + method: 'Debugger.enable', + id: 2, + }, + ); + expect(receivedByDevice2FromDebugger2).toEqual({ + method: 'Debugger.enable', + id: 2, + }); + + // Messages should not have been received by device1 + expect(device1.wrappedEventParsed).not.toBeCalled(); + + // Verify the two messages to device2 have different session IDs + const wrappedCalls = device2.wrappedEventParsed.mock.calls; + expect(wrappedCalls).toHaveLength(2); + expect(wrappedCalls[0][0].sessionId).not.toEqual( + wrappedCalls[1][0].sessionId, + ); + } finally { + device1?.close(); + device2?.close(); + debugger1?.close(); + debugger2?.close(); + } + }); + test.each([ ['app', 'name'], ['name', 'app'], diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js index ae27e5906fe3db..97fece865f2d57 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyReactNativeReloads-test.js @@ -90,6 +90,7 @@ describe('inspector proxy React Native reloads', () => { await until(() => expect(device1.wrappedEventParsed).toBeCalledWith({ pageId: 'originalPage-initial', + sessionId: expect.any(String), wrappedEvent: { method: 'Console.enable', id: 0, @@ -133,6 +134,7 @@ describe('inspector proxy React Native reloads', () => { await until(() => expect(device1.wrappedEventParsed).toBeCalledWith({ pageId: 'originalPage-updated', + sessionId: expect.any(String), wrappedEvent: { method: 'Console.disable', id: 1, @@ -311,6 +313,7 @@ describe('inspector proxy React Native reloads', () => { await until(async () => { expect(device1.wrappedEventParsed).toBeCalledWith({ pageId: 'originalPage-updated', + sessionId: expect.any(String), wrappedEvent: { method: 'Runtime.enable', id: expect.any(Number), @@ -318,6 +321,7 @@ describe('inspector proxy React Native reloads', () => { }); expect(device1.wrappedEventParsed).toBeCalledWith({ pageId: 'originalPage-updated', + sessionId: expect.any(String), wrappedEvent: { method: 'Debugger.enable', id: expect.any(Number), @@ -354,6 +358,7 @@ describe('inspector proxy React Native reloads', () => { await until(() => { expect(device1.wrappedEventParsed).toBeCalledWith({ pageId: 'originalPage-updated', + sessionId: expect.any(String), wrappedEvent: expect.objectContaining({ method: 'Debugger.resume', id: expect.any(Number), @@ -367,7 +372,7 @@ describe('inspector proxy React Native reloads', () => { }); test('device disconnect event results in a nonstandard "reload" message to the debugger', async () => { - const {device, debugger_} = await createAndConnectTarget( + const {device, debugger_, sessionId} = await createAndConnectTarget( serverRef, autoCleanup.signal, { @@ -383,6 +388,7 @@ describe('inspector proxy React Native reloads', () => { event: 'disconnect', payload: { pageId: 'page1', + sessionId, }, }); await until(() => diff --git a/packages/dev-middleware/src/inspector-proxy/Device.js b/packages/dev-middleware/src/inspector-proxy/Device.js index 6bdab2cd891e04..f41152e1919266 100644 --- a/packages/dev-middleware/src/inspector-proxy/Device.js +++ b/packages/dev-middleware/src/inspector-proxy/Device.js @@ -9,6 +9,7 @@ */ import type {EventReporter} from '../types/EventReporter'; +import type {Experiments} from '../types/Experiments'; import type { CDPClientMessage, CDPRequest, @@ -28,6 +29,7 @@ import type { import CdpDebugLogging from './CdpDebugLogging'; import DeviceEventReporter from './DeviceEventReporter'; +import crypto from 'crypto'; import invariant from 'invariant'; import WS from 'ws'; @@ -62,6 +64,8 @@ type DebuggerConnection = { userAgent: string | null, customHandler: ?CustomMessageHandler, debuggerRelativeBaseUrl: URL, + // Session ID assigned by the proxy for multi-debugger support + sessionId: string, }; const REACT_NATIVE_RELOADABLE_PAGE_ID = '-1'; @@ -76,6 +80,7 @@ export type DeviceOptions = Readonly<{ deviceRelativeBaseUrl: URL, serverRelativeBaseUrl: URL, isProfilingBuild: boolean, + experiments: Experiments, }>; /** @@ -98,8 +103,8 @@ export default class Device { // Stores the most recent listing of device's pages, keyed by the `id` field. #pages: ReadonlyMap = new Map(); - // Stores information about currently connected debugger (if any). - #debuggerConnection: ?DebuggerConnection = null; + // Stores information about currently connected debuggers, keyed by sessionId. + #debuggerConnections: Map = new Map(); // Last known Page ID of the React Native page. // This is used by debugger connections that don't have PageID specified @@ -122,8 +127,6 @@ export default class Device { // The device message middleware factory function allowing implementers to handle unsupported CDP messages. #createCustomMessageHandler: ?CreateCustomMessageHandlerFn; - #connectedPageIds: Set = new Set(); - // A base HTTP(S) URL to this server, reachable from the device. Derived from // the http request that created the connection. #deviceRelativeBaseUrl: URL; @@ -134,7 +137,10 @@ export default class Device { // Logging reporting batches of cdp messages #cdpDebugLogging: CdpDebugLogging; + +#experiments: Experiments; + constructor(deviceOptions: DeviceOptions) { + this.#experiments = deviceOptions.experiments; this.#dangerouslyConstruct(deviceOptions); } @@ -218,14 +224,40 @@ export default class Device { }); } - #terminateDebuggerConnection(code?: number, reason?: string) { - const debuggerConnection = this.#debuggerConnection; - if (debuggerConnection) { - this.#sendDisconnectEventToDevice( - this.#mapToDevicePageId(debuggerConnection.pageId), - ); - debuggerConnection.socket.close(code, reason); - this.#debuggerConnection = null; + /** + * Terminates debugger connection(s). + * If sessionId is provided, terminates only that session. + * If sessionId is not provided, terminates all sessions. + */ + #terminateDebuggerConnection( + code?: number, + reason?: string, + sessionId?: string, + ) { + if (sessionId != null) { + // Terminate specific session + const debuggerConnection = this.#debuggerConnections.get(sessionId); + if (debuggerConnection) { + // Delete from map first so #sendDisconnectEventToDevice can correctly + // check if there are other debuggers connected to this page + this.#debuggerConnections.delete(sessionId); + this.#sendDisconnectEventToDevice( + this.#mapToDevicePageId(debuggerConnection.pageId), + sessionId, + ); + debuggerConnection.socket.close(code, reason); + } + } else { + // Terminate all sessions - collect connections first, then process + const connections = Array.from(this.#debuggerConnections.entries()); + this.#debuggerConnections.clear(); + for (const [sid, debuggerConnection] of connections) { + this.#sendDisconnectEventToDevice( + this.#mapToDevicePageId(debuggerConnection.pageId), + sid, + ); + debuggerConnection.socket.close(code, reason); + } } } @@ -244,7 +276,8 @@ export default class Device { 'dangerouslyRecreateDevice() can only be used for the same device ID', ); - const oldDebugger = this.#debuggerConnection; + // Store existing debugger connections for potential reuse + const oldDebuggerConnections = new Map(this.#debuggerConnections); if (this.#app !== deviceOptions.app || this.#name !== deviceOptions.name) { this.#deviceSocket.close( @@ -257,21 +290,26 @@ export default class Device { ); } - this.#debuggerConnection = null; + this.#debuggerConnections.clear(); - if (oldDebugger) { - oldDebugger.socket.removeAllListeners(); + // Close the old device socket before reconstructing + if (oldDebuggerConnections.size > 0) { this.#deviceSocket.close( WS_CLOSURE_CODE.NORMAL, WS_CLOSE_REASON.RECREATING_DEVICE, ); + } + + this.#dangerouslyConstruct(deviceOptions); + + // Restore all debugger connections, not just the first one + for (const oldDebugger of oldDebuggerConnections.values()) { + oldDebugger.socket.removeAllListeners(); this.handleDebuggerConnection(oldDebugger.socket, oldDebugger.pageId, { debuggerRelativeBaseUrl: oldDebugger.debuggerRelativeBaseUrl, userAgent: oldDebugger.userAgent, }); } - - this.#dangerouslyConstruct(deviceOptions); } getName(): string { @@ -325,69 +363,82 @@ export default class Device { // Clear any commands we were waiting on. this.#deviceEventReporter?.logDisconnection('debugger'); - // Disconnect current debugger if we already have debugger connected. - this.#terminateDebuggerConnection( - WS_CLOSURE_CODE.NORMAL, - WS_CLOSE_REASON.NEW_DEBUGGER_OPENED, - ); + // Check if this specific page supports multiple debuggers. + // If not (legacy mode), disconnect existing debuggers for THIS PAGE only + // before connecting the new one. + if (!this.#pageHasCapability(page, 'supportsMultipleDebuggers')) { + for (const [sid, conn] of this.#debuggerConnections) { + if (conn.pageId === pageId) { + this.#terminateDebuggerConnection( + WS_CLOSURE_CODE.NORMAL, + WS_CLOSE_REASON.NEW_DEBUGGER_OPENED, + sid, + ); + } + } + } + + // Generate a unique session ID for this debugger connection using UUID + // to minimize collision likelihood across device reconnections + const sessionId = crypto.randomUUID(); this.#deviceEventReporter?.logConnection('debugger', { pageId, frontendUserAgent: userAgent, }); - const debuggerInfo: ?DebuggerConnection & DebuggerConnection = { + const debuggerInfo: DebuggerConnection = { socket, prependedFilePrefix: false, pageId, userAgent: userAgent, customHandler: null, debuggerRelativeBaseUrl, + sessionId, }; - this.#debuggerConnection = debuggerInfo; + this.#debuggerConnections.set(sessionId, debuggerInfo); debug( `Got new debugger connection via ${debuggerRelativeBaseUrl.href} for ` + - `page ${pageId} of ${this.#name}`, + `page ${pageId} of ${this.#name} with sessionId ${sessionId}`, ); - if (this.#debuggerConnection && this.#createCustomMessageHandler) { - this.#debuggerConnection.customHandler = this.#createCustomMessageHandler( - { - page, - debugger: { - userAgent: debuggerInfo.userAgent, - sendMessage: message => { - try { - const payload = JSON.stringify(message); - this.#cdpDebugLogging.log('ProxyToDebugger', payload); - socket.send(payload); - } catch {} - }, + if (this.#createCustomMessageHandler) { + debuggerInfo.customHandler = this.#createCustomMessageHandler({ + page, + debugger: { + userAgent: debuggerInfo.userAgent, + sendMessage: message => { + try { + const payload = JSON.stringify(message); + this.#cdpDebugLogging.log('ProxyToDebugger', 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), - }, - }); - this.#cdpDebugLogging.log('DebuggerToProxy', payload); - this.#deviceSocket.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), + sessionId, + }, + }); + this.#cdpDebugLogging.log('DebuggerToProxy', payload); + this.#deviceSocket.send(payload); + } catch {} }, }, - ); + }); - if (this.#debuggerConnection.customHandler) { + if (debuggerInfo.customHandler) { debug('Created new custom message handler for debugger connection'); } else { debug( @@ -396,25 +447,24 @@ export default class Device { } } - this.#sendConnectEventToDevice(this.#mapToDevicePageId(pageId)); + this.#sendConnectEventToDevice(this.#mapToDevicePageId(pageId), sessionId); // $FlowFixMe[incompatible-type] socket.on('message', (message: string) => { this.#cdpDebugLogging.log('DebuggerToProxy', message); const debuggerRequest = JSON.parse(message); this.#deviceEventReporter?.logRequest(debuggerRequest, 'debugger', { - pageId: this.#debuggerConnection?.pageId ?? null, + pageId: debuggerInfo.pageId, frontendUserAgent: userAgent, prefersFuseboxFrontend: this.#isPageFuseboxFrontend( - this.#debuggerConnection?.pageId, + debuggerInfo.pageId, ), }); let processedReq = debuggerRequest; if ( - this.#debuggerConnection?.customHandler?.handleDebuggerMessage( - debuggerRequest, - ) === true + debuggerInfo.customHandler?.handleDebuggerMessage(debuggerRequest) === + true ) { return; } @@ -433,16 +483,17 @@ export default class Device { payload: { pageId: this.#mapToDevicePageId(pageId), wrappedEvent: JSON.stringify(processedReq), + sessionId, }, }); } }); socket.on('close', () => { - debug(`Debugger for page ${pageId} and ${this.#name} disconnected.`); + debug( + `Debugger for page ${pageId} and ${this.#name} disconnected (sessionId: ${sessionId}).`, + ); this.#deviceEventReporter?.logDisconnection('debugger'); - if (this.#debuggerConnection?.socket === socket) { - this.#terminateDebuggerConnection(); - } + this.#terminateDebuggerConnection(undefined, undefined, sessionId); }); const cdpDebugLogging = this.#cdpDebugLogging; @@ -455,25 +506,17 @@ export default class Device { }; } - #sendConnectEventToDevice(devicePageId: string) { - if (this.#connectedPageIds.has(devicePageId)) { - return; - } - this.#connectedPageIds.add(devicePageId); + #sendConnectEventToDevice(devicePageId: string, sessionId: string) { this.#sendMessageToDevice({ event: 'connect', - payload: {pageId: devicePageId}, + payload: {pageId: devicePageId, sessionId}, }); } - #sendDisconnectEventToDevice(devicePageId: string) { - if (!this.#connectedPageIds.has(devicePageId)) { - return; - } - this.#connectedPageIds.delete(devicePageId); + #sendDisconnectEventToDevice(devicePageId: string, sessionId: string) { this.#sendMessageToDevice({ event: 'disconnect', - payload: {pageId: devicePageId}, + payload: {pageId: devicePageId, sessionId}, }); } @@ -507,15 +550,24 @@ export default class Device { #handleMessageFromDevice(message: MessageFromDevice) { if (message.event === 'getPages') { // Preserve ordering - getPages guarantees addition order. + const shouldDisableMultipleDebuggers = + !this.#experiments.enableStandaloneFuseboxShell; this.#pages = new Map( - message.payload.map(({capabilities, ...page}) => [ - page.id, - { - ...page, - capabilities: capabilities ?? {}, - }, - ]), + message.payload.map(({capabilities: rawCapabilities, ...page}) => { + const capabilities: TargetCapabilityFlags = + shouldDisableMultipleDebuggers + ? {...(rawCapabilities ?? {}), supportsMultipleDebuggers: false} + : (rawCapabilities ?? {}); + return [ + page.id, + { + ...page, + capabilities, + }, + ]; + }), ); + if (message.payload.length !== this.#pages.size) { const duplicateIds = new Set(); const idsSeen = new Set(); @@ -554,6 +606,8 @@ export default class Device { // Device sends disconnect events only when page is reloaded or // if debugger socket was disconnected. const pageId = message.payload.pageId; + const sessionId = message.payload.sessionId; + // TODO(moti): Handle null case explicitly, e.g. swallow disconnect events // for unknown pages. const page: ?Page = this.#pages.get(pageId); @@ -562,63 +616,87 @@ export default class Device { return; } - const debuggerSocket = this.#debuggerConnection - ? this.#debuggerConnection.socket - : null; - if (debuggerSocket && debuggerSocket.readyState === WS.OPEN) { + // Find the debugger connection(s) for this page + if (sessionId != null) { + // Disconnect specific session + const debuggerConnection = this.#debuggerConnections.get(sessionId); if ( - this.#debuggerConnection != null && - this.#debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID + debuggerConnection && + debuggerConnection.socket.readyState === WS.OPEN ) { - debug(`Legacy page ${pageId} is reloading.`); - debuggerSocket.send(JSON.stringify({method: 'reload'})); + if (debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID) { + debug( + `Legacy page ${pageId} is reloading (sessionId: ${sessionId}).`, + ); + debuggerConnection.socket.send(JSON.stringify({method: 'reload'})); + } + } + } else { + // Legacy mode: send to all connected debuggers for this page + for (const debuggerConnection of this.#debuggerConnections.values()) { + if ( + debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID && + debuggerConnection.socket.readyState === WS.OPEN + ) { + debug(`Legacy page ${pageId} is reloading.`); + debuggerConnection.socket.send(JSON.stringify({method: 'reload'})); + } } } } else if (message.event === 'wrappedEvent') { - if (this.#debuggerConnection == null) { - return; + const sessionId = message.payload.sessionId; + + // Route message to the correct debugger connection by sessionId + let debuggerConnection: ?DebuggerConnection = null; + if (sessionId != null) { + debuggerConnection = this.#debuggerConnections.get(sessionId); + } else { + // Legacy mode: route to the first (and only) debugger connection + // ASSERT: In legacy mode, there should be at most one debugger connected. + if (this.#debuggerConnections.size > 1) { + debug( + 'WARNING: Device sent message without sessionId but multiple debuggers are connected. ' + + 'This indicates a device/proxy version mismatch.', + ); + } + debuggerConnection = + this.#debuggerConnections.values().next().value ?? null; } - // 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 + if (debuggerConnection == null) { + return; + } - const debuggerSocket = this.#debuggerConnection.socket; + const debuggerSocket = debuggerConnection.socket; if (debuggerSocket == null || debuggerSocket.readyState !== WS.OPEN) { // TODO(hypuk): Send error back to device? return; } const parsedPayload = JSON.parse(message.payload.wrappedEvent); - const pageId = this.#debuggerConnection?.pageId ?? null; + const pageId = debuggerConnection.pageId; if ('id' in parsedPayload) { this.#deviceEventReporter?.logResponse(parsedPayload, 'device', { pageId, - frontendUserAgent: this.#debuggerConnection?.userAgent ?? null, + frontendUserAgent: debuggerConnection.userAgent ?? null, prefersFuseboxFrontend: this.#isPageFuseboxFrontend(pageId), }); } - const debuggerConnection = this.#debuggerConnection; - if (debuggerConnection != null) { - if ( - debuggerConnection.customHandler?.handleDeviceMessage( - parsedPayload, - ) === true - ) { - return; - } - - this.#processMessageFromDeviceLegacy( - parsedPayload, - debuggerConnection, - pageId, - ); - const messageToSend = JSON.stringify(parsedPayload); - debuggerSocket.send(messageToSend); - } else { - debuggerSocket.send(message.payload.wrappedEvent); + if ( + debuggerConnection.customHandler?.handleDeviceMessage(parsedPayload) === + true + ) { + return; } + + this.#processMessageFromDeviceLegacy( + parsedPayload, + debuggerConnection, + pageId, + ); + const messageToSend = JSON.stringify(parsedPayload); + debuggerSocket.send(messageToSend); } } @@ -636,10 +714,17 @@ export default class Device { // We received new React Native Page ID. #newLegacyReactNativePage(page: Page) { debug(`React Native page updated to ${page.id}`); - if ( - this.#debuggerConnection == null || - this.#debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID - ) { + + // Find the debugger connection that's connected to the reloadable page + let reloadablePageDebugger: ?DebuggerConnection = null; + for (const debuggerConnection of this.#debuggerConnections.values()) { + if (debuggerConnection.pageId === REACT_NATIVE_RELOADABLE_PAGE_ID) { + reloadablePageDebugger = debuggerConnection; + break; + } + } + + if (reloadablePageDebugger == null) { // 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. @@ -656,10 +741,13 @@ export default class Device { // page. if (oldPageId != null) { - this.#sendDisconnectEventToDevice(oldPageId); + this.#sendDisconnectEventToDevice( + oldPageId, + reloadablePageDebugger.sessionId, + ); } - this.#sendConnectEventToDevice(page.id); + this.#sendConnectEventToDevice(page.id, reloadablePageDebugger.sessionId); const toSend = [ {method: 'Runtime.enable', id: 1e9}, @@ -667,10 +755,10 @@ export default class Device { ]; for (const message of toSend) { - const pageId = this.#debuggerConnection?.pageId ?? null; + const pageId = reloadablePageDebugger.pageId; this.#deviceEventReporter?.logRequest(message, 'proxy', { pageId, - frontendUserAgent: this.#debuggerConnection?.userAgent ?? null, + frontendUserAgent: reloadablePageDebugger.userAgent ?? null, prefersFuseboxFrontend: this.#isPageFuseboxFrontend(pageId), }); this.#sendMessageToDevice({ @@ -678,6 +766,7 @@ export default class Device { payload: { pageId: this.#mapToDevicePageId(page.id), wrappedEvent: JSON.stringify(message), + sessionId: reloadablePageDebugger.sessionId, }, }); } @@ -819,10 +908,10 @@ export default class Device { // 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, + pageId: debuggerInfo.pageId, + frontendUserAgent: debuggerInfo.userAgent ?? null, prefersFuseboxFrontend: this.#isPageFuseboxFrontend( - this.#debuggerConnection?.pageId, + debuggerInfo.pageId, ), }); this.#sendMessageToDevice({ @@ -830,6 +919,7 @@ export default class Device { payload: { pageId: this.#mapToDevicePageId(debuggerInfo.pageId), wrappedEvent: JSON.stringify(resumeMessage), + sessionId: debuggerInfo.sessionId, }, }); @@ -869,7 +959,7 @@ export default class Device { return this.#processDebuggerSetBreakpointByUrl(req, debuggerInfo); case 'Debugger.getScriptSource': // Sends response to debugger via side-effect - void this.#processDebuggerGetScriptSource(req, socket); + void this.#processDebuggerGetScriptSource(req, socket, debuggerInfo); return null; case 'Network.loadNetworkResource': // If we're rewriting URLs (to frontend-relative), we don't want to @@ -889,10 +979,10 @@ export default class Device { }; const response = {id: req.id, result}; socket.send(JSON.stringify(response)); - const pageId = this.#debuggerConnection?.pageId ?? null; + const pageId = debuggerInfo.pageId; this.#deviceEventReporter?.logResponse(response, 'proxy', { pageId, - frontendUserAgent: this.#debuggerConnection?.userAgent ?? null, + frontendUserAgent: debuggerInfo.userAgent ?? null, prefersFuseboxFrontend: this.#isPageFuseboxFrontend(pageId), }); return null; @@ -957,6 +1047,7 @@ export default class Device { async #processDebuggerGetScriptSource( req: CDPRequest<'Debugger.getScriptSource'>, socket: WS, + debuggerInfo: DebuggerConnection, ): Promise { const sendSuccessResponse = (scriptSource: string) => { const result: { @@ -968,10 +1059,10 @@ export default class Device { result, }; socket.send(JSON.stringify(response)); - const pageId = this.#debuggerConnection?.pageId ?? null; + const pageId = debuggerInfo.pageId; this.#deviceEventReporter?.logResponse(response, 'proxy', { pageId, - frontendUserAgent: this.#debuggerConnection?.userAgent ?? null, + frontendUserAgent: debuggerInfo.userAgent ?? null, prefersFuseboxFrontend: this.#isPageFuseboxFrontend(pageId), }); }; @@ -982,11 +1073,11 @@ export default class Device { socket.send(JSON.stringify(response)); // Send to the console as well, so the user can see it - this.#sendErrorToDebugger(error); - const pageId = this.#debuggerConnection?.pageId ?? null; + this.#sendErrorToDebugger(error, debuggerInfo); + const pageId = debuggerInfo.pageId; this.#deviceEventReporter?.logResponse(response, 'proxy', { pageId, - frontendUserAgent: this.#debuggerConnection?.userAgent ?? null, + frontendUserAgent: debuggerInfo.userAgent ?? null, prefersFuseboxFrontend: this.#isPageFuseboxFrontend(pageId), }); }; @@ -1056,8 +1147,8 @@ export default class Device { return text; } - #sendErrorToDebugger(message: string) { - const debuggerSocket = this.#debuggerConnection?.socket; + #sendErrorToDebugger(message: string, debuggerInfo?: DebuggerConnection) { + const debuggerSocket = debuggerInfo?.socket; if (debuggerSocket && debuggerSocket.readyState === WS.OPEN) { debuggerSocket.send( JSON.stringify({ diff --git a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js index 1e6de9bc586c38..61f86f6fd0ab87 100644 --- a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js +++ b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js @@ -92,7 +92,7 @@ export default class InspectorProxy implements InspectorProxyQueries { #eventReporter: ?EventReporter; - #experiments: Experiments; + +#experiments: Experiments; // custom message handler factory allowing implementers to handle unsupported CDP messages. #customMessageHandler: ?CreateCustomMessageHandlerFn; @@ -360,6 +360,7 @@ export default class InspectorProxy implements InspectorProxyQueries { deviceRelativeBaseUrl, serverRelativeBaseUrl: this.#serverBaseUrl, isProfilingBuild, + experiments: this.#experiments, }; if (oldDevice) { diff --git a/packages/dev-middleware/src/inspector-proxy/types.js b/packages/dev-middleware/src/inspector-proxy/types.js index 16abf599495142..51484b85fc5e13 100644 --- a/packages/dev-middleware/src/inspector-proxy/types.js +++ b/packages/dev-middleware/src/inspector-proxy/types.js @@ -37,6 +37,16 @@ export type TargetCapabilityFlags = Readonly<{ * In the launch flow, this controls the Chrome DevTools entrypoint that is used. */ prefersFuseboxFrontend?: boolean, + + /** + * The target supports multiple concurrent debugger connections. + * + * When true, the proxy allows multiple debuggers to connect to the same + * page simultaneously, each identified by a unique session ID. + * When false (default/legacy), connecting a new debugger disconnects + * any existing debugger connection to that page. + */ + supportsMultipleDebuggers?: boolean, }>; // Page information received from the device. New page is created for @@ -59,12 +69,23 @@ export type Page = Readonly<{ capabilities: NonNullable, }>; -// Chrome Debugger Protocol message/event passed between device and debugger. -export type WrappedEvent = Readonly<{ +// Chrome Debugger Protocol message/event passed from device to proxy. +export type WrappedEventFromDevice = Readonly<{ + event: 'wrappedEvent', + payload: Readonly<{ + pageId: string, + wrappedEvent: string, + sessionId?: string, + }>, +}>; + +// Chrome Debugger Protocol message/event passed from proxy to device. +export type WrappedEventToDevice = Readonly<{ event: 'wrappedEvent', payload: Readonly<{ pageId: string, wrappedEvent: string, + sessionId: string, }>, }>; @@ -72,14 +93,14 @@ export type WrappedEvent = Readonly<{ // to particular page. export type ConnectRequest = Readonly<{ event: 'connect', - payload: Readonly<{pageId: string}>, + payload: Readonly<{pageId: string, sessionId: string}>, }>; // Request sent from Inspector Proxy to Device to notify that debugger is // disconnected. export type DisconnectRequest = Readonly<{ event: 'disconnect', - payload: Readonly<{pageId: string}>, + payload: Readonly<{pageId: string, sessionId: string}>, }>; // Request sent from Inspector Proxy to Device to get a list of pages. @@ -94,13 +115,13 @@ export type GetPagesResponse = { // Union type for all possible messages sent from device to Inspector Proxy. export type MessageFromDevice = | GetPagesResponse - | WrappedEvent + | WrappedEventFromDevice | DisconnectRequest; // Union type for all possible messages sent from Inspector Proxy to device. export type MessageToDevice = | GetPagesRequest - | WrappedEvent + | WrappedEventToDevice | ConnectRequest | DisconnectRequest; diff --git a/packages/dev-middleware/src/types/Experiments.js b/packages/dev-middleware/src/types/Experiments.js index 5219f3e7bfc8c4..4b04be13fa1958 100644 --- a/packages/dev-middleware/src/types/Experiments.js +++ b/packages/dev-middleware/src/types/Experiments.js @@ -27,6 +27,10 @@ export type Experiments = Readonly<{ * Launch the Fusebox frontend in a standalone shell instead of a browser. * When this is enabled, we will use the optional unstable_showFuseboxShell * method on the BrowserLauncher, or throw an error if the method is missing. + * + * NOTE: Disabling this also disables support for concurrent sessions in the + * inspector proxy. Without the standalone shell, the proxy remains responsible + * for keeping only one debugger frontend active at a time per page. */ enableStandaloneFuseboxShell: boolean, }>;