dev-middleware: Scaffold standalone Fusebox shell experiment (#51687)

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

Changelog: [Internal]

# Context

This is the first of several commits that aim to implement a **standalone shell for React Native DevTools**. This will be a lightweight desktop app designed to host the debugger frontend, in much the same way as we currently use Chrome or Edge. The launch flow will otherwise remain **very similar** to the one that exists today.

## What's changing for users?

1. With this commit, nothing; we're merely setting up an experiment flag (for stage 1 - Meta-internal testing) and will make separate plans for open source rollout, coordinated with our framework maintainer partners.
2. If the experiment is successful, we aim to *eventually* phase out the use of Chrome/Edge in React Native DevTools and ship the standalone shell as standard to all React Native developers. This is to enable further improvements that will rely on the standalone shell to work.
3. The first iteration of the standalone shell aims to solve some concrete pain points such as the "dead window problem" - the fact that opening DevTools multiple times for the same target will leave behind a now-dead window (that would ideally have been reused).

## This diff

We amend the `unstable_experiments` and `unstable_browserLauncher` APIs in `dev-middleware` to add basic support for launching a standalone shell based on a frontend URL and a *window key* - the latter being an opaque string that the shell process can match against previous launches in order to reuse and foreground existing windows.

We leave it up to `BrowserLauncher` implementers ( = frameworks) to provide a working implementation of `unstable_showFuseboxShell`, and do not provide one with `DefaultBrowserLauncher`. This will effectively allow us to dependency-inject the actual shell implementation at stage 1 so we don't increase the download size of React Native unnecessarily.

Reviewed By: rickhanlonii, robhogan

Differential Revision: D74904547

fbshipit-source-id: fbc6eac97923062bda8892bc130b39051845ea82
This commit is contained in:
Moti Zilberman
2025-05-30 02:23:12 -07:00
committed by Facebook GitHub Bot
parent 3e49d17f58
commit 55ff50fba5
7 changed files with 202 additions and 21 deletions
@@ -0,0 +1,130 @@
/**
* 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 DefaultBrowserLauncher from '../utils/DefaultBrowserLauncher';
import {fetchJson, requestLocal} from './FetchUtils';
import {createDeviceMock} from './InspectorDeviceUtils';
import {withAbortSignalForEachTest} from './ResourceUtils';
import {withServerForEachTest} from './ServerUtils';
// Must be greater than PAGES_POLLING_INTERVAL in `Device.js`
const PAGES_POLLING_DELAY = 2100;
jest.useFakeTimers();
describe('enableStandaloneFuseboxShell experiment', () => {
const BrowserLauncherWithFuseboxShell = {
...DefaultBrowserLauncher,
unstable_showFuseboxShell: () => {
throw new Error('Not implemented');
},
};
const serverRef = withServerForEachTest({
logger: undefined,
projectRoot: '',
unstable_browserLauncher: BrowserLauncherWithFuseboxShell,
unstable_experiments: {
enableStandaloneFuseboxShell: true,
},
});
const autoCleanup = withAbortSignalForEachTest();
afterEach(() => {
jest.clearAllMocks();
});
describe('/open-debugger endpoint', () => {
test('launches the shell with a frontend URL and stable window key', async () => {
// Connect a device to use when opening the debugger
const 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',
capabilities: {
// Ensure the device target can be found when launching the debugger
nativePageReloads: true,
// Mark as Fusebox
prefersFuseboxFrontend: true,
},
},
]);
jest.advanceTimersByTime(PAGES_POLLING_DELAY);
const launchDebuggerAppWindowSpy = jest
.spyOn(BrowserLauncherWithFuseboxShell, 'launchDebuggerAppWindow')
.mockResolvedValue();
const showFuseboxShellSpy = jest
.spyOn(BrowserLauncherWithFuseboxShell, 'unstable_showFuseboxShell')
.mockResolvedValue();
try {
// Fetch the target information for the device
const pageListResponse = await fetchJson<JsonPagesListResponse>(
`${serverRef.serverBaseUrl}/json`,
);
// Select the first target from the page list response
expect(pageListResponse.length).toBeGreaterThanOrEqual(1);
const firstPage = pageListResponse[0];
// Build the URL for the debugger
const openUrl = new URL('/open-debugger', serverRef.serverBaseUrl);
openUrl.searchParams.set('launchId', 'launch1');
openUrl.searchParams.set(
'device',
firstPage.reactNative.logicalDeviceId,
);
openUrl.searchParams.set('target', firstPage.id);
// Request to open the debugger for the first device
{
const response = await requestLocal(openUrl.toString(), {
method: 'POST',
});
// Ensure the request was handled properly
expect(response.statusCode).toBe(200);
}
openUrl.searchParams.set('launchId', 'launch1');
// Ensure the debugger was launched using the standalone shell API
expect(showFuseboxShellSpy).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
);
const firstWindowKey = showFuseboxShellSpy.mock.calls[0][1];
showFuseboxShellSpy.mockClear();
openUrl.searchParams.set('launchId', 'launch2');
{
const response = await requestLocal(openUrl.toString(), {
method: 'POST',
});
// Ensure the request was handled properly
expect(response.statusCode).toBe(200);
}
// Ensure the debugger was launched using the standalone shell API and the same window key
expect(showFuseboxShellSpy).toHaveBeenCalledWith(
expect.any(String),
firstWindowKey,
);
expect(launchDebuggerAppWindowSpy).not.toHaveBeenCalled();
} finally {
device.close();
}
});
});
});
@@ -18,6 +18,7 @@ describe('getDevToolsFrontendUrl', () => {
const experiments = {
enableNetworkInspector: false,
enableOpenDebuggerRedirect: false,
enableStandaloneFuseboxShell: false,
};
describe('relative: false, launchId: undefined, telemetryInfo: undefined, (default)', () => {
@@ -140,6 +140,7 @@ function getExperiments(config: ExperimentsConfig): Experiments {
return {
enableOpenDebuggerRedirect: config.enableOpenDebuggerRedirect ?? false,
enableNetworkInspector: config.enableNetworkInspector ?? false,
enableStandaloneFuseboxShell: config.enableStandaloneFuseboxShell ?? false,
};
}
@@ -136,27 +136,47 @@ export default function openDebuggerMiddleware({
}
const useFuseboxEntryPoint =
target.reactNative.capabilities?.prefersFuseboxFrontend;
target.reactNative.capabilities?.prefersFuseboxFrontend ?? false;
try {
switch (launchType) {
case 'launch':
await browserLauncher.launchDebuggerAppWindow(
getDevToolsFrontendUrl(
experiments,
target.webSocketDebuggerUrl,
serverBaseUrl,
{
launchId: query.launchId,
telemetryInfo: query.telemetryInfo,
appId: target.appId,
useFuseboxEntryPoint,
},
),
case 'launch': {
const frontendUrl = getDevToolsFrontendUrl(
experiments,
target.webSocketDebuggerUrl,
serverBaseUrl,
{
launchId: query.launchId,
telemetryInfo: query.telemetryInfo,
appId: target.appId,
useFuseboxEntryPoint,
},
);
if (
useFuseboxEntryPoint &&
experiments.enableStandaloneFuseboxShell
) {
const windowKey = [
serverBaseUrl,
target.webSocketDebuggerUrl,
target.appId,
].join('-');
if (!browserLauncher.unstable_showFuseboxShell) {
throw new Error(
'Fusebox shell is not supported by the current browser launcher',
);
}
await browserLauncher.unstable_showFuseboxShell(
frontendUrl,
windowKey,
);
} else {
await browserLauncher.launchDebuggerAppWindow(frontendUrl);
}
res.writeHead(200);
res.end();
break;
}
case 'redirect':
res.writeHead(302, {
Location: getDevToolsFrontendUrl(
@@ -186,7 +206,7 @@ export default function openDebuggerMiddleware({
pageId: target.id,
deviceName: target.deviceName,
targetDescription: target.description,
prefersFuseboxFrontend: useFuseboxEntryPoint ?? false,
prefersFuseboxFrontend: useFuseboxEntryPoint,
});
return;
} catch (e) {
@@ -200,7 +220,7 @@ export default function openDebuggerMiddleware({
launchType,
status: 'error',
error: e,
prefersFuseboxFrontend: useFuseboxEntryPoint ?? false,
prefersFuseboxFrontend: useFuseboxEntryPoint,
});
return;
}
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @flow strict-local
* @format
*/
@@ -18,9 +18,29 @@ export interface BrowserLauncher {
* optionally returning an object to control the launched browser instance.
* The browser used should be capable of running Chrome DevTools.
*
* The provided url is based on serverBaseUrl, and therefore reachable from
* The provided URL is based on serverBaseUrl, and therefore reachable from
* the host of dev-middleware. Implementations are responsible for rewriting
* this as necessary where the server is remote.
*/
launchDebuggerAppWindow: (url: string) => Promise<void>;
/**
* Attempt to open a debugger frontend URL in a standalone shell window
* designed specifically for React Native DevTools. The provided windowKey
* should be used to identify an existing window that can be reused instead
* of opening a new one.
*
* Implementations SHOULD treat an existing session with the same windowKey
* (as long as it's still connected and healthy) as equaivalent to a new
* session with the new URL, even if the launch URLs for the two sessions are
* not identical. Implementations SHOULD NOT unnecessarily close and reopen
* the connection when reusing a session. Implementations SHOULD process any
* changed/new parameters in the URL and update the session accordingly (e.g.
* to preserve telemetry data that may have changed).
*
* The provided URL is based on serverBaseUrl, and therefore reachable from
* the host of dev-middleware. Implementations are responsible for rewriting
* this as necessary where the server is remote.
*/
unstable_showFuseboxShell?: (url: string, windowKey: string) => Promise<void>;
}
@@ -22,6 +22,15 @@ export type Experiments = $ReadOnly<{
*/
// NOTE: Used by Expo, exposing a tab labelled "Network (Expo, unstable)"
enableNetworkInspector: boolean,
/**
* 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 framework-provided BrowserLauncher, or throw an error if the
* method is missing. Note that the default BrowserLauncher does *not*
* implement unstable_showFuseboxShell.
*/
enableStandaloneFuseboxShell: boolean,
}>;
export type ExperimentsConfig = Partial<Experiments>;
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @flow strict-local
* @format
*/
@@ -19,12 +19,12 @@ const open = require('open');
* Default `BrowserLauncher` implementation which opens URLs on the host
* machine.
*/
const DefaultBrowserLauncher: BrowserLauncher = {
const DefaultBrowserLauncher = {
/**
* Attempt to open the debugger frontend in a Google Chrome or Microsoft Edge
* app window.
*/
launchDebuggerAppWindow: async url => {
launchDebuggerAppWindow: async (url: string): Promise<void> => {
let chromePath;
try {