From 7437c22b27c09e10ab1989f3dd9cdc396e024e6a Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 26 Aug 2025 05:12:30 -0700 Subject: [PATCH 1/6] Provisionally support using prebuilt shell binaries via DotSlash (#53436) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/53436 Changelog: [Internal] Adds a `flavor` option to `unstable_spawnDebuggerShellWithArgs` to select between two modes: 1. `flavor: 'dev'` (current behaviour) - launching a stock Electron binary (from the `electron` package) and pointing it directly at the shell code from the `src/electron` directory. 2. `flavor: 'prebuilt'` (new in this diff) - launching the prebuilt React Native DevTools binary included in the package (built continuously at Meta and committed as a DotSlash file in automated diffs e.g. D79836825). Note that this binary includes Electron *and* a frozen version of the shell code from `src/electron`. Going forward, `'dev'` will only be used when developing the package (e.g. in D78351934 we will move `electron` to `devDependencies`). The published version of the package is only intended to work with `flavor: 'prebuilt'`. Differential Revision: D78351931 Reviewed By: huntie --- packages/debugger-shell/package.json | 6 +- .../src/node/__tests__/debugger-shell-test.js | 48 ++++++++++++++++ .../debugger-shell/src/node/index.flow.js | 56 +++++++++++++------ yarn.lock | 5 ++ 4 files changed, 96 insertions(+), 19 deletions(-) create mode 100644 packages/debugger-shell/src/node/__tests__/debugger-shell-test.js diff --git a/packages/debugger-shell/package.json b/packages/debugger-shell/package.json index 4b0602b08bb2..6c1706dd55d4 100644 --- a/packages/debugger-shell/package.json +++ b/packages/debugger-shell/package.json @@ -26,12 +26,12 @@ }, "license": "MIT", "engines": { - "node": ">= 20.19.4", - "electron": ">=37.2.6" + "node": ">= 20.19.4" }, "dependencies": { "cross-spawn": "^7.0.6", - "electron": "37.2.6" + "electron": "37.2.6", + "fb-dotslash": "0.5.8" }, "devDependencies": { "semver": "^7.1.3" diff --git a/packages/debugger-shell/src/node/__tests__/debugger-shell-test.js b/packages/debugger-shell/src/node/__tests__/debugger-shell-test.js new file mode 100644 index 000000000000..f74281512beb --- /dev/null +++ b/packages/debugger-shell/src/node/__tests__/debugger-shell-test.js @@ -0,0 +1,48 @@ +/** + * 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 + */ + +const {unstable_spawnDebuggerShellWithArgs} = require('../../'); + +describe('debugger-shell Node package', () => { + test('can spawn in detached+prebuilt mode without crashing', async () => { + await expect( + unstable_spawnDebuggerShellWithArgs(['--version'], { + flavor: 'prebuilt', + mode: 'detached', + }), + ).resolves.toBeUndefined(); + }); + + // When running in the internal react-native-oss-js job, Electron isn't + // installed correctly (postinstall scripts don't run) but the internal + // `electron` workspace isn't available either. Detecting this dynamically + // weakens the test somewhat in environments where it *should* pass, but this + // is a dev-only feature anyway so this is fine. + if (isElectronInstalled()) { + test('can spawn in detached+dev mode without crashing', async () => { + await expect( + unstable_spawnDebuggerShellWithArgs(['--version'], { + flavor: 'dev', + mode: 'detached', + }), + ).resolves.toBeUndefined(); + }); + } +}); + +function isElectronInstalled() { + try { + require('electron'); + return true; + } catch { + return false; + } +} diff --git a/packages/debugger-shell/src/node/index.flow.js b/packages/debugger-shell/src/node/index.flow.js index 8f1d2edd32d5..11a1137555ea 100644 --- a/packages/debugger-shell/src/node/index.flow.js +++ b/packages/debugger-shell/src/node/index.flow.js @@ -9,43 +9,43 @@ */ const {spawn} = require('cross-spawn'); +const path = require('path'); + +// The 'prebuilt' flavor will use the prebuilt shell binary (and the JavaScript embedded in it). +// The 'dev' flavor will use a stock Electron binary and run the shell code from the `electron/` directory. +type DebuggerShellFlavor = 'prebuilt' | 'dev'; async function unstable_spawnDebuggerShellWithArgs( args: string[], { mode = 'detached', + flavor = 'dev', }: $ReadOnly<{ // In 'syncAndExit' mode, the current process will block until the spawned process exits, and then it will exit // with the same exit code as the spawned process. // In 'detached' mode, the spawned process will be detached from the current process and the current process will // continue to run normally. mode?: 'syncThenExit' | 'detached', + flavor?: DebuggerShellFlavor, }> = {}, ): Promise { - // NOTE: Internally at Meta, this is aliased to a workspace that is - // API-compatible with the 'electron' package, but contains prebuilt binaries - // that do not need to be downloaded in a postinstall action. - const electronPath = require('electron'); + const [binaryPath, baseArgs] = getShellBinaryAndArgs(flavor); return new Promise((resolve, reject) => { - const child = spawn( - electronPath, - [require.resolve('../electron'), ...args], - { - stdio: 'inherit', - windowsHide: true, - detached: mode === 'detached', - }, - ); + const child = spawn(binaryPath, [...baseArgs, ...args], { + stdio: 'inherit', + windowsHide: true, + detached: mode === 'detached', + }); if (mode === 'detached') { child.on('spawn', () => { resolve(); }); - child.on('close', (code /*: number */) => { + child.on('close', (code: number) => { if (code !== 0) { reject( new Error( - `Failed to open debugger shell: ${electronPath} exited with code ${code}`, + `Failed to open debugger shell: exited with code ${code}`, ), ); } @@ -54,7 +54,7 @@ async function unstable_spawnDebuggerShellWithArgs( } else if (mode === 'syncThenExit') { child.on('close', function (code, signal) { if (code === null) { - console.error(electronPath, 'exited with signal', signal); + console.error('Debugger shell exited with signal', signal); process.exit(1); } process.exit(code); @@ -74,4 +74,28 @@ async function unstable_spawnDebuggerShellWithArgs( }); } +function getShellBinaryAndArgs( + flavor: DebuggerShellFlavor, +): [string, Array] { + switch (flavor) { + case 'prebuilt': + return [ + // $FlowIssue[cannot-resolve-module] fb-dotslash includes Flow types but Flow does not pick them up + require('fb-dotslash'), + [path.join(__dirname, '../../bin/react-native-devtools')], + ]; + case 'dev': + return [ + // NOTE: Internally at Meta, this is aliased to a workspace that is + // API-compatible with the 'electron' package, but contains prebuilt binaries + // that do not need to be downloaded in a postinstall action. + require('electron'), + [require.resolve('../electron')], + ]; + default: + flavor as empty; + throw new Error(`Unknown flavor: ${flavor}`); + } +} + export {unstable_spawnDebuggerShellWithArgs}; diff --git a/yarn.lock b/yarn.lock index 25056c18f0bd..af3410295325 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4507,6 +4507,11 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +fb-dotslash@0.5.8: + version "0.5.8" + resolved "https://registry.yarnpkg.com/fb-dotslash/-/fb-dotslash-0.5.8.tgz#c5ef3dacd75e1ddb2197c367052464ddde0115f5" + integrity sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA== + fb-watchman@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" From 2c4fd533150fad4d0218f29a94f3f445d7c63619 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 26 Aug 2025 08:24:45 -0700 Subject: [PATCH 2/6] Expose DotSlash prefetching as unstable_prepareDebuggerShell (#53434) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/53434 Changelog: [Internal] The React Native DevTools standalone shell is distributed as a DotSlash file that downloads the required binaries lazily. This diff gives integrations a mechanism for kicking off the download early (but without slowing down `npm install react-native`). This will be integrated into dev-middleware in an upcoming diff. Differential Revision: D78413091 Reviewed By: huntie --- .../__snapshots__/dotslash-test.js.snap | 33 +++++ ...lash-file-simulating-data-corruption.jsonc | 59 ++++++++ ...tslash-file-simulating-network-error.jsonc | 59 ++++++++ ...dotslash-file-with-missing-platforms.jsonc | 6 + .../debugger-shell/__tests__/dotslash-test.js | 139 ++++++++++++++++++ .../debugger-shell/src/node/index.flow.js | 76 +++++++++- .../src/node/private/LaunchUtils.js | 98 ++++++++++++ 7 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 packages/debugger-shell/__tests__/__snapshots__/dotslash-test.js.snap create mode 100755 packages/debugger-shell/__tests__/dotslash-file-simulating-data-corruption.jsonc create mode 100755 packages/debugger-shell/__tests__/dotslash-file-simulating-network-error.jsonc create mode 100755 packages/debugger-shell/__tests__/dotslash-file-with-missing-platforms.jsonc create mode 100644 packages/debugger-shell/__tests__/dotslash-test.js create mode 100644 packages/debugger-shell/src/node/private/LaunchUtils.js diff --git a/packages/debugger-shell/__tests__/__snapshots__/dotslash-test.js.snap b/packages/debugger-shell/__tests__/__snapshots__/dotslash-test.js.snap new file mode 100644 index 000000000000..cda139425067 --- /dev/null +++ b/packages/debugger-shell/__tests__/__snapshots__/dotslash-test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`prepareDebuggerShellFromDotSlashFile fails with the expected error message for a missing dotslash file 1`] = ` +Object { + "code": "unexpected_error", + "humanReadableMessage": "An unexpected error occured while installing the latest version of React Native DevTools. Using a fallback version instead.", + "verboseInfo": Any, +} +`; + +exports[`prepareDebuggerShellFromDotSlashFile fails with the expected error message for missing platforms 1`] = ` +Object { + "code": "platform_not_supported", + "humanReadableMessage": "The latest version of React Native DevTools is not supported on this platform. Using a fallback version instead.", + "verboseInfo": Any, +} +`; + +exports[`prepareDebuggerShellFromDotSlashFile scenarios requiring a local HTTP server fails with the expected error message for a corrupted tarball 1`] = ` +Object { + "code": "possible_corruption", + "humanReadableMessage": "Failed to verify the latest version of React Native DevTools. Using a fallback version instead. ", + "verboseInfo": Any, +} +`; + +exports[`prepareDebuggerShellFromDotSlashFile scenarios requiring a local HTTP server fails with the expected error message for a network error 1`] = ` +Object { + "code": "likely_offline", + "humanReadableMessage": "Failed to download the latest version of React Native DevTools. Using a fallback version instead. Connect to the internet or check your network settings.", + "verboseInfo": Any, +} +`; diff --git a/packages/debugger-shell/__tests__/dotslash-file-simulating-data-corruption.jsonc b/packages/debugger-shell/__tests__/dotslash-file-simulating-data-corruption.jsonc new file mode 100755 index 000000000000..33c2bf073d4b --- /dev/null +++ b/packages/debugger-shell/__tests__/dotslash-file-simulating-data-corruption.jsonc @@ -0,0 +1,59 @@ +#!/usr/bin/env dotslash + +{ + "name": "React Native DevTools", + "platforms": { + "linux-aarch64": { + "size": 113510892, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "http://$HOST:$PORT/corrupted.tar.gz" + } + ], + "format": "tar.gz", + "path": "React Native DevTools-linux-arm64/React Native DevTools" + }, + "linux-x86_64": { + "size": 113243910, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "http://$HOST:$PORT/corrupted.tar.gz" + } + ], + "format": "tar.gz", + "path": "React Native DevTools-linux-x64/React Native DevTools" + }, + "macos-aarch64": { + "size": 108810433, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "http://$HOST:$PORT/corrupted.tar.gz" + } + ], + "format": "tar.gz", + "path": "React Native DevTools.app/Contents/MacOS/React Native DevTools" + }, + "macos-x86_64": { + "size": 113769989, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "http://$HOST:$PORT/corrupted.tar.gz" + } + ], + "format": "tar.gz", + "path": "React Native DevTools.app/Contents/MacOS/React Native DevTools" + } + } +} diff --git a/packages/debugger-shell/__tests__/dotslash-file-simulating-network-error.jsonc b/packages/debugger-shell/__tests__/dotslash-file-simulating-network-error.jsonc new file mode 100755 index 000000000000..61cd560c2fba --- /dev/null +++ b/packages/debugger-shell/__tests__/dotslash-file-simulating-network-error.jsonc @@ -0,0 +1,59 @@ +#!/usr/bin/env dotslash + +{ + "name": "React Native DevTools", + "platforms": { + "linux-aarch64": { + "size": 113510892, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "https://$HOST:$PORT/does-not-exist" + } + ], + "format": "tar.gz", + "path": "React Native DevTools-linux-arm64/React Native DevTools" + }, + "linux-x86_64": { + "size": 113243910, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "https://$HOST:$PORT/does-not-exist" + } + ], + "format": "tar.gz", + "path": "React Native DevTools-linux-x64/React Native DevTools" + }, + "macos-aarch64": { + "size": 108810433, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "https://$HOST:$PORT/does-not-exist" + } + ], + "format": "tar.gz", + "path": "React Native DevTools.app/Contents/MacOS/React Native DevTools" + }, + "macos-x86_64": { + "size": 113769989, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + { + "type": "http", + "url": "https://$HOST:$PORT/does-not-exist" + } + ], + "format": "tar.gz", + "path": "React Native DevTools.app/Contents/MacOS/React Native DevTools" + } + } +} diff --git a/packages/debugger-shell/__tests__/dotslash-file-with-missing-platforms.jsonc b/packages/debugger-shell/__tests__/dotslash-file-with-missing-platforms.jsonc new file mode 100755 index 000000000000..52b93e082c27 --- /dev/null +++ b/packages/debugger-shell/__tests__/dotslash-file-with-missing-platforms.jsonc @@ -0,0 +1,6 @@ +#!/usr/bin/env dotslash + +{ + "name": "React Native DevTools", + "platforms": {} +} diff --git a/packages/debugger-shell/__tests__/dotslash-test.js b/packages/debugger-shell/__tests__/dotslash-test.js new file mode 100644 index 000000000000..e481dc6e7abc --- /dev/null +++ b/packages/debugger-shell/__tests__/dotslash-test.js @@ -0,0 +1,139 @@ +/** + * 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 + */ + +const { + prepareDebuggerShellFromDotSlashFile, +} = require('../src/node/private/LaunchUtils'); +const fs = require('fs').promises; +const http = require('http'); +const os = require('os'); +const path = require('path'); + +// The implementation of prepareDebuggerShellFromDotSlashFile relies on +// details of DotSlash that are not guaranteed to be stable (support for +// `dotslash -- fetch `, certain strings being printed to stderr). +// This (admittedly elaborate) test suite ensures we'll fail loudly if we +// try to upgrade DotSlash to a version that breaks our assumptions. +describe('prepareDebuggerShellFromDotSlashFile', () => { + test('fails with the expected error message for missing platforms', async () => { + const result = await prepareDebuggerShellFromDotSlashFile( + path.join(__dirname, 'dotslash-file-with-missing-platforms.jsonc'), + ); + expect(result).toMatchSnapshot({ + verboseInfo: expect.any(String), + }); + }); + + test('fails with the expected error message for a missing dotslash file', async () => { + const result = await prepareDebuggerShellFromDotSlashFile( + path.join(__dirname, 'dotslash-file-that-does-not-exist.jsonc'), + ); + expect(result).toMatchSnapshot({ + verboseInfo: expect.any(String), + }); + }); + + describe('scenarios requiring a local HTTP server', () => { + let server, scratchDir; + + beforeEach(async () => { + scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dotslash-test-')); + server = http.createServer((request, response) => { + if (request.url === '/corrupted.tar.gz') { + response.writeHead(200, {'Content-Type': 'application/gzip'}); + response.end( + 'Hello, world!\n' + 'This simulated a corrupted tarball.', + ); + } else { + response.writeHead(404); + response.end(); + } + }); + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, 'localhost', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + }); + + afterEach(async () => { + await fs.rm(scratchDir, {recursive: true, force: true}); + if (server.listening) { + await new Promise((resolve, reject) => { + server.close(error => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + }); + + test('fails with the expected error message for a corrupted tarball', async () => { + const dotslashFileContents = injectHostPort( + await fs.readFile( + path.join( + __dirname, + 'dotslash-file-simulating-data-corruption.jsonc', + ), + 'utf8', + ), + server.address(), + ); + + await fs.writeFile( + path.join(scratchDir, 'dotslash-file.jsonc'), + dotslashFileContents, + ); + const result = await prepareDebuggerShellFromDotSlashFile( + path.join(scratchDir, 'dotslash-file.jsonc'), + ); + expect(result).toMatchSnapshot({ + verboseInfo: expect.any(String), + }); + }); + + test('fails with the expected error message for a network error', async () => { + const dotslashFileContents = injectHostPort( + await fs.readFile( + path.join(__dirname, 'dotslash-file-simulating-network-error.jsonc'), + 'utf8', + ), + server.address(), + ); + + await fs.writeFile( + path.join(scratchDir, 'dotslash-file.jsonc'), + dotslashFileContents, + ); + const result = await prepareDebuggerShellFromDotSlashFile( + path.join(scratchDir, 'dotslash-file.jsonc'), + ); + expect(result).toMatchSnapshot({ + verboseInfo: expect.any(String), + }); + }); + }); +}); + +function injectHostPort( + dotslashFileContents: string, + address: net$Socket$address, +) { + const host = + address.family === 'IPv6' ? `[${address.address}]` : address.address; + return dotslashFileContents + .replaceAll('$HOST', host) + .replaceAll('$PORT', address.port.toString()); +} diff --git a/packages/debugger-shell/src/node/index.flow.js b/packages/debugger-shell/src/node/index.flow.js index 11a1137555ea..88cbee5cc1b8 100644 --- a/packages/debugger-shell/src/node/index.flow.js +++ b/packages/debugger-shell/src/node/index.flow.js @@ -8,6 +8,11 @@ * @format */ +import { + prepareDebuggerShellFromDotSlashFile, + spawnAndGetStderr, +} from './private/LaunchUtils'; + const {spawn} = require('cross-spawn'); const path = require('path'); @@ -15,6 +20,11 @@ const path = require('path'); // The 'dev' flavor will use a stock Electron binary and run the shell code from the `electron/` directory. type DebuggerShellFlavor = 'prebuilt' | 'dev'; +const DEVTOOLS_BINARY_DOTSLASH_FILE = path.join( + __dirname, + '../../bin/react-native-devtools', +); + async function unstable_spawnDebuggerShellWithArgs( args: string[], { @@ -74,6 +84,68 @@ async function unstable_spawnDebuggerShellWithArgs( }); } +export type DebuggerShellPreparationResult = $ReadOnly<{ + code: + | 'success' + | 'likely_offline' + | 'platform_not_supported' + | 'possible_corruption' + | 'unexpected_error', + humanReadableMessage?: string, + verboseInfo?: string, +}>; + +/** + * Attempts to prepare the debugger shell for use and returns a coded result + * that can be used to advise the user on how to proceed in case of failure. + * In particular, this function will attempt to download and extract an + * appropriate binary for the "prebuilt" flavor. + * + * This function should be called early during dev server startup, in parallel + * with other initialization steps, so that the debugger shell is ready to use + * instantly when the user tries to open it (and conversely, the user is + * informed ASAP if it is not ready to use). + */ +async function unstable_prepareDebuggerShell( + flavor: DebuggerShellFlavor, +): Promise { + const [binaryPath, baseArgs] = getShellBinaryAndArgs(flavor); + + try { + switch (flavor) { + case 'prebuilt': + const prebuiltResult = await prepareDebuggerShellFromDotSlashFile( + DEVTOOLS_BINARY_DOTSLASH_FILE, + ); + if (prebuiltResult.code !== 'success') { + return prebuiltResult; + } + break; + case 'dev': + break; + default: + flavor as empty; + throw new Error(`Unknown flavor: ${flavor}`); + } + const {code, stderr} = await spawnAndGetStderr(binaryPath, [ + ...baseArgs, + '--version', + ]); + if (code !== 0) { + return { + code: 'unexpected_error', + verboseInfo: stderr, + }; + } + return {code: 'success'}; + } catch (e) { + return { + code: 'unexpected_error', + verboseInfo: e.message, + }; + } +} + function getShellBinaryAndArgs( flavor: DebuggerShellFlavor, ): [string, Array] { @@ -82,7 +154,7 @@ function getShellBinaryAndArgs( return [ // $FlowIssue[cannot-resolve-module] fb-dotslash includes Flow types but Flow does not pick them up require('fb-dotslash'), - [path.join(__dirname, '../../bin/react-native-devtools')], + [DEVTOOLS_BINARY_DOTSLASH_FILE], ]; case 'dev': return [ @@ -98,4 +170,4 @@ function getShellBinaryAndArgs( } } -export {unstable_spawnDebuggerShellWithArgs}; +export {unstable_spawnDebuggerShellWithArgs, unstable_prepareDebuggerShell}; diff --git a/packages/debugger-shell/src/node/private/LaunchUtils.js b/packages/debugger-shell/src/node/private/LaunchUtils.js new file mode 100644 index 000000000000..283d284058aa --- /dev/null +++ b/packages/debugger-shell/src/node/private/LaunchUtils.js @@ -0,0 +1,98 @@ +/** + * 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 {DebuggerShellPreparationResult} from '../'; + +const {spawn} = require('cross-spawn'); + +async function spawnAndGetStderr( + command: string, + args: string[], +): Promise<{ + code: number, + stderr: string, +}> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'ignore', 'pipe'], + encoding: 'utf8', + windowsHide: true, + }); + let stderr = ''; + child.stderr.on('data', data => { + stderr += data; + }); + child.on('error', error => { + reject(error); + }); + child.on('close', (code, signal) => { + resolve({ + code, + stderr, + }); + }); + }); +} + +async function prepareDebuggerShellFromDotSlashFile( + filePath: string, +): Promise { + const {code, stderr} = await spawnAndGetStderr( + // $FlowIssue[cannot-resolve-module] fb-dotslash includes Flow types but Flow does not pick them up + require('fb-dotslash'), + ['--', 'fetch', filePath], + ); + if (code === 0) { + return {code: 'success'}; + } + if ( + stderr.includes('dotslash error') && + stderr.includes('no providers succeeded') + ) { + if (stderr.includes('failed to verify artifact')) { + return { + code: 'possible_corruption', + humanReadableMessage: + 'Failed to verify the latest version of React Native DevTools. ' + + 'Using a fallback version instead. ', + verboseInfo: stderr, + }; + } + return { + code: 'likely_offline', + humanReadableMessage: + 'Failed to download the latest version of React Native DevTools. ' + + 'Using a fallback version instead. ' + + 'Connect to the internet or check your network settings.', + verboseInfo: stderr, + }; + } + if ( + stderr.includes('dotslash error') && + stderr.includes('platform not supported') + ) { + return { + code: 'platform_not_supported', + humanReadableMessage: + 'The latest version of React Native DevTools is not supported on this platform. ' + + 'Using a fallback version instead.', + verboseInfo: stderr, + }; + } + return { + code: 'unexpected_error', + humanReadableMessage: + 'An unexpected error occured while installing the latest version of React Native DevTools. ' + + 'Using a fallback version instead.', + verboseInfo: stderr, + }; +} + +export {spawnAndGetStderr, prepareDebuggerShellFromDotSlashFile}; From 895ca37888ba8a06b9a004ed853a626fbe3bccb3 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 26 Aug 2025 08:24:45 -0700 Subject: [PATCH 3/6] Support preparing debugger shell ahead of "open DevTools" (#53437) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/53437 Changelog: [Internal] The React Native DevTools standalone shell is distributed as a DotSlash file that downloads the required binaries lazily. This diff adds support in dev-middleware for a new `BrowserLauncher.unstable_prepareFuseboxShell` method that integrations can use to kick off the download early. Integrations are expected to implement this by calling the `unstable_prepareDebuggerShell` function (added to the `debugger-shell` package in D78413091). If `BrowserLauncher.unstable_prepareFuseboxShell` returns an error, dev-middleware will fall back to the browser-based launch flow, even for users opted into the `enableStandaloneFuseboxShell` experiment. Differential Revision: D78413092 Reviewed By: huntie --- .../debugger-shell/src/node/index.flow.js | 1 + packages/dev-middleware/package.json | 1 + .../dev-middleware/src/createDevMiddleware.js | 29 +++++++++++++ packages/dev-middleware/src/index.flow.js | 5 ++- .../src/middleware/openDebuggerMiddleware.js | 41 ++++++++++++++++--- .../src/types/BrowserLauncher.js | 17 ++++++++ .../dev-middleware/src/types/EventReporter.js | 6 +++ 7 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/debugger-shell/src/node/index.flow.js b/packages/debugger-shell/src/node/index.flow.js index 88cbee5cc1b8..3b7e0dbcca6c 100644 --- a/packages/debugger-shell/src/node/index.flow.js +++ b/packages/debugger-shell/src/node/index.flow.js @@ -87,6 +87,7 @@ async function unstable_spawnDebuggerShellWithArgs( export type DebuggerShellPreparationResult = $ReadOnly<{ code: | 'success' + | 'not_implemented' | 'likely_offline' | 'platform_not_supported' | 'possible_corruption' diff --git a/packages/dev-middleware/package.json b/packages/dev-middleware/package.json index 2183969a8509..cb420fc81328 100644 --- a/packages/dev-middleware/package.json +++ b/packages/dev-middleware/package.json @@ -38,6 +38,7 @@ "node": ">= 20.19.4" }, "devDependencies": { + "@react-native/debugger-shell": "0.82.0-main", "selfsigned": "^2.4.1", "undici": "^5.29.0", "wait-for-expect": "^3.0.2" diff --git a/packages/dev-middleware/src/createDevMiddleware.js b/packages/dev-middleware/src/createDevMiddleware.js index 67928d7bb068..1263fec7e5ca 100644 --- a/packages/dev-middleware/src/createDevMiddleware.js +++ b/packages/dev-middleware/src/createDevMiddleware.js @@ -170,6 +170,35 @@ function createWrappedEventReporter( '\u001B[27m', ); break; + case 'fusebox_shell_preparation_attempt': + switch (event.result.code) { + case 'success': + case 'not_implemented': + break; + case 'unexpected_error': { + let message = + event.result.humanReadableMessage ?? + 'An unknown error occurred while installing React Native DevTools.'; + if (event.result.verboseInfo != null) { + message += ` Details:\n\n${event.result.verboseInfo}`; + } else { + message += '.'; + } + logger?.error(message); + break; + } + case 'possible_corruption': + case 'platform_not_supported': + case 'likely_offline': + logger?.warn( + event.result.humanReadableMessage ?? + `An error of type ${event.result.code} occurred while installing React Native DevTools.`, + ); + break; + default: + (event.result.code: empty); + break; + } } reporter?.logEvent(event); diff --git a/packages/dev-middleware/src/index.flow.js b/packages/dev-middleware/src/index.flow.js index ef1027e39103..868b45f002cd 100644 --- a/packages/dev-middleware/src/index.flow.js +++ b/packages/dev-middleware/src/index.flow.js @@ -10,7 +10,10 @@ export {default as createDevMiddleware} from './createDevMiddleware'; -export type {BrowserLauncher} from './types/BrowserLauncher'; +export type { + BrowserLauncher, + DebuggerShellPreparationResult, +} from './types/BrowserLauncher'; export type {EventReporter, ReportableEvent} from './types/EventReporter'; export type { CustomMessageHandler, diff --git a/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js b/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js index fb60f116a4d7..a6bc5a5ea291 100644 --- a/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js +++ b/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js @@ -10,7 +10,10 @@ import type {InspectorProxyQueries} from '../inspector-proxy/InspectorProxy'; import type {PageDescription} from '../inspector-proxy/types'; -import type {BrowserLauncher} from '../types/BrowserLauncher'; +import type { + BrowserLauncher, + DebuggerShellPreparationResult, +} from '../types/BrowserLauncher'; import type {EventReporter} from '../types/EventReporter'; import type {Experiments} from '../types/Experiments'; import type {Logger} from '../types/Logger'; @@ -48,6 +51,19 @@ export default function openDebuggerMiddleware({ experiments, inspectorProxy, }: Options): NextHandleFunction { + let shellPreparationPromise: Promise; + if (experiments.enableStandaloneFuseboxShell) { + shellPreparationPromise = + browserLauncher?.unstable_prepareFuseboxShell?.() ?? + Promise.resolve({code: 'not_implemented'}); + shellPreparationPromise = shellPreparationPromise.then(result => { + eventReporter?.logEvent({ + type: 'fusebox_shell_preparation_attempt', + result, + }); + return result; + }); + } return async ( req: IncomingMessage, res: ServerResponse, @@ -155,10 +171,25 @@ export default function openDebuggerMiddleware({ panel: query.panel, }, ); - if ( - useFuseboxEntryPoint && - experiments.enableStandaloneFuseboxShell - ) { + let shouldUseStandaloneFuseboxShell = + useFuseboxEntryPoint && experiments.enableStandaloneFuseboxShell; + if (shouldUseStandaloneFuseboxShell) { + const shellPreparationResult = await shellPreparationPromise; + switch (shellPreparationResult.code) { + case 'success': + case 'not_implemented': + break; + case 'platform_not_supported': + case 'possible_corruption': + case 'likely_offline': + case 'unexpected_error': + shouldUseStandaloneFuseboxShell = false; + break; + default: + (shellPreparationResult.code: empty); + } + } + if (shouldUseStandaloneFuseboxShell) { const windowKey = [ serverBaseUrl, target.webSocketDebuggerUrl, diff --git a/packages/dev-middleware/src/types/BrowserLauncher.js b/packages/dev-middleware/src/types/BrowserLauncher.js index 8e3b9169e6fc..36a5ac1555a6 100644 --- a/packages/dev-middleware/src/types/BrowserLauncher.js +++ b/packages/dev-middleware/src/types/BrowserLauncher.js @@ -8,6 +8,10 @@ * @format */ +import type {DebuggerShellPreparationResult} from '@react-native/debugger-shell'; + +export type {DebuggerShellPreparationResult}; + /** * An interface for integrators to provide a custom implementation for * opening URLs in a web browser. @@ -43,4 +47,17 @@ export interface BrowserLauncher { * this as necessary where the server is remote. */ unstable_showFuseboxShell?: (url: string, windowKey: string) => Promise; + + /** + * Attempt to prepare the debugger shell for use and returns a coded result + * that can be used to advise the user on how to proceed in case of failure. + * + * This function MAY be called multiple times or not at all. Implementers + * SHOULD use the opportunity to prefetch and cache any expensive resources (e.g + * platform-specific binaries needed in order to show the Fusebox shell). After a + * successful call, subsequent calls SHOULD complete quickly. The implementation + * SHOULD NOT return a rejecting promise in any case, and instead SHOULD report + * errors via the returned result object. + */ + unstable_prepareFuseboxShell?: () => Promise; } diff --git a/packages/dev-middleware/src/types/EventReporter.js b/packages/dev-middleware/src/types/EventReporter.js index 1f7e89b8d4bd..f26ec260b46f 100644 --- a/packages/dev-middleware/src/types/EventReporter.js +++ b/packages/dev-middleware/src/types/EventReporter.js @@ -8,6 +8,8 @@ * @format */ +import type {DebuggerShellPreparationResult} from './BrowserLauncher'; + type SuccessResult = { status: 'success', ...Props, @@ -132,6 +134,10 @@ export type ReportableEvent = duration: number, ...ConnectionUptime, ...DebuggerSessionIDs, + } + | { + type: 'fusebox_shell_preparation_attempt', + result: DebuggerShellPreparationResult, }; /** From 1046babb150352d98546921fb7fed303ada1aed7 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 26 Aug 2025 08:24:45 -0700 Subject: [PATCH 4/6] Demote `electron` to devDependency (#53438) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/53438 Changelog: [Internal] Makes `flavor: 'prebuilt'` the default mode of launching the RNDT standalone shell, and the *only* mode supported in the published version of the package. See D78351931 for more context. With this, we can demote `electron` from `dependencies` to `devDependencies`. This makes it possible to make `debugger-shell` a dependency of `dev-middleware` (and thus of all major frameworks) without significantly impacting `npm install` times. We'll add this dependency on `debugger-shell` in an upcoming diff (D78351937). We also stop publishing the `dist/electron` subdirectory (and `src/electron` for good measure) since the corresponding code will always be bundled into the prebuilt binary instead. Differential Revision: D78351934 Reviewed By: huntie --- .../__tests__/electron-dependency-test.js | 2 +- packages/debugger-shell/package.json | 10 ++++++++-- packages/debugger-shell/src/node/index.flow.js | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/debugger-shell/__tests__/electron-dependency-test.js b/packages/debugger-shell/__tests__/electron-dependency-test.js index d0be1077d5ba..9b785bd77010 100644 --- a/packages/debugger-shell/__tests__/electron-dependency-test.js +++ b/packages/debugger-shell/__tests__/electron-dependency-test.js @@ -22,7 +22,7 @@ describe('Electron dependency', () => { // $FlowFixMe[untyped-import] - package.json is not typed const ourPackageJson = require('../package.json'); - const declaredElectronVersion = ourPackageJson.dependencies.electron; + const declaredElectronVersion = ourPackageJson.devDependencies.electron; expect(declaredElectronVersion).toBeTruthy(); // $FlowFixMe[untyped-import] - package.json is not typed diff --git a/packages/debugger-shell/package.json b/packages/debugger-shell/package.json index 6c1706dd55d4..3d268cbfbfd8 100644 --- a/packages/debugger-shell/package.json +++ b/packages/debugger-shell/package.json @@ -30,10 +30,16 @@ }, "dependencies": { "cross-spawn": "^7.0.6", - "electron": "37.2.6", "fb-dotslash": "0.5.8" }, "devDependencies": { + "electron": "37.2.6", "semver": "^7.1.3" - } + }, + "files": [ + "!**/__tests__/**", + "bin", + "dist", + "!src/electron" + ] } diff --git a/packages/debugger-shell/src/node/index.flow.js b/packages/debugger-shell/src/node/index.flow.js index 3b7e0dbcca6c..8490b8f42ed4 100644 --- a/packages/debugger-shell/src/node/index.flow.js +++ b/packages/debugger-shell/src/node/index.flow.js @@ -29,7 +29,7 @@ async function unstable_spawnDebuggerShellWithArgs( args: string[], { mode = 'detached', - flavor = 'dev', + flavor = 'prebuilt', }: $ReadOnly<{ // In 'syncAndExit' mode, the current process will block until the spawned process exits, and then it will exit // with the same exit code as the spawned process. From 35f9bcffe3ed21c6b9c031b6177b1c460adbe148 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 27 Aug 2025 01:56:55 -0700 Subject: [PATCH 5/6] Support Fusebox shell experiment in OSS without a custom BrowserLauncher (#53435) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/53435 Changelog: [Internal] Support setting `enableStandaloneFuseboxShell: true` in OSS with no custom `BrowserLauncher` Makes it possible for frameworks to enable the React Native DevTools standalone shell in open source by passing `unstable_experiments: {enableStandaloneFuseboxShell: true}` to `createDevMiddleware()`. When this experiment is enabled: * The RNDT shell binary will be prefetched in the background as soon as the dev server starts (into a local cache managed by [DotSlash](https://dotslash-cli.com/)). * If prefetching is successful, then "Open DevTools" actions will be handled by launching the RNDT frontend in the standalone shell, instead of in Chrome/Edge. * If prefetching is not successful, then we'll notify the user about the error, and "Open DevTools" will continue to be handled by Chrome/Edge, as before. * If the user attempts to open DevTools more than once for the same app, the standalone shell will reuse the existing window (as opposed to the current behaviour of always creating a new Chrome/Edge window). * The appropriate DevTools window will automatically foreground itself upon pausing on a breakpoint. Differential Revision: D78351937 Reviewed By: huntie --- packages/dev-middleware/package.json | 1 + .../__tests__/StandaloneFuseboxShell-test.js | 5 ++++ packages/dev-middleware/src/index.flow.js | 2 ++ .../src/types/BrowserLauncher.js | 7 ++++-- .../dev-middleware/src/types/Experiments.js | 4 +-- .../src/utils/DefaultBrowserLauncher.js | 25 +++++++++++++++++++ 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/dev-middleware/package.json b/packages/dev-middleware/package.json index cb420fc81328..ce6912ae8262 100644 --- a/packages/dev-middleware/package.json +++ b/packages/dev-middleware/package.json @@ -24,6 +24,7 @@ "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.82.0-main", + "@react-native/debugger-shell": "0.82.0-main", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", diff --git a/packages/dev-middleware/src/__tests__/StandaloneFuseboxShell-test.js b/packages/dev-middleware/src/__tests__/StandaloneFuseboxShell-test.js index ca5bb9434b11..e8a133bfd4fc 100644 --- a/packages/dev-middleware/src/__tests__/StandaloneFuseboxShell-test.js +++ b/packages/dev-middleware/src/__tests__/StandaloneFuseboxShell-test.js @@ -28,6 +28,9 @@ describe('enableStandaloneFuseboxShell experiment', () => { unstable_showFuseboxShell: () => { throw new Error('Not implemented'); }, + unstable_prepareFuseboxShell: async () => { + return {code: 'not_implemented'}; + }, }; const serverRef = withServerForEachTest({ logger: undefined, @@ -127,5 +130,7 @@ describe('enableStandaloneFuseboxShell experiment', () => { device.close(); } }); + + // TODO(moti): Add tests around unstable_prepareFuseboxShell }); }); diff --git a/packages/dev-middleware/src/index.flow.js b/packages/dev-middleware/src/index.flow.js index 868b45f002cd..bb19fa093e23 100644 --- a/packages/dev-middleware/src/index.flow.js +++ b/packages/dev-middleware/src/index.flow.js @@ -20,3 +20,5 @@ export type { CustomMessageHandlerConnection, CreateCustomMessageHandlerFn, } from './inspector-proxy/CustomMessageHandler'; + +export {default as unstable_DefaultBrowserLauncher} from './utils/DefaultBrowserLauncher'; diff --git a/packages/dev-middleware/src/types/BrowserLauncher.js b/packages/dev-middleware/src/types/BrowserLauncher.js index 36a5ac1555a6..93a22362c7c3 100644 --- a/packages/dev-middleware/src/types/BrowserLauncher.js +++ b/packages/dev-middleware/src/types/BrowserLauncher.js @@ -46,7 +46,10 @@ export interface BrowserLauncher { * 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; + +unstable_showFuseboxShell?: ( + url: string, + windowKey: string, + ) => Promise; /** * Attempt to prepare the debugger shell for use and returns a coded result @@ -59,5 +62,5 @@ export interface BrowserLauncher { * SHOULD NOT return a rejecting promise in any case, and instead SHOULD report * errors via the returned result object. */ - unstable_prepareFuseboxShell?: () => Promise; + +unstable_prepareFuseboxShell?: () => Promise; } diff --git a/packages/dev-middleware/src/types/Experiments.js b/packages/dev-middleware/src/types/Experiments.js index 332af8fbaf8f..753dcc6459ff 100644 --- a/packages/dev-middleware/src/types/Experiments.js +++ b/packages/dev-middleware/src/types/Experiments.js @@ -26,9 +26,7 @@ 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 framework-provided BrowserLauncher, or throw an error if the - * method is missing. Note that the default BrowserLauncher does *not* - * implement unstable_showFuseboxShell. + * method on the BrowserLauncher, or throw an error if the method is missing. */ enableStandaloneFuseboxShell: boolean, }>; diff --git a/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js b/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js index bfcf0cc75cc5..1cfe3a1a2afe 100644 --- a/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js +++ b/packages/dev-middleware/src/utils/DefaultBrowserLauncher.js @@ -8,6 +8,12 @@ * @format */ +import type {DebuggerShellPreparationResult} from '../'; + +const { + unstable_prepareDebuggerShell, + unstable_spawnDebuggerShellWithArgs, +} = require('@react-native/debugger-shell'); const {spawn} = require('child_process'); const ChromeLauncher = require('chrome-launcher'); const {Launcher: EdgeLauncher} = require('chromium-edge-launcher'); @@ -62,6 +68,25 @@ const DefaultBrowserLauncher = { }); }); }, + + async unstable_showFuseboxShell( + url: string, + windowKey: string, + ): Promise { + return await unstable_spawnDebuggerShellWithArgs( + ['--frontendUrl', url, '--windowKey', windowKey], + { + mode: 'detached', + flavor: process.env.RNDT_DEV === '1' ? 'dev' : 'prebuilt', + }, + ); + }, + + async unstable_prepareFuseboxShell(): Promise { + return await unstable_prepareDebuggerShell( + process.env.RNDT_DEV === '1' ? 'dev' : 'prebuilt', + ); + }, }; export default DefaultBrowserLauncher; From efb55edc6a7d0acd7cac8249dc307f5e1b5d710a Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 27 Aug 2025 02:21:15 -0700 Subject: [PATCH 6/6] Drop mention of Chrome/Edge if standalone shell enabled (#53464) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/53464 Changelog: [Internal] Minor followup from D78351937 - the Fusebox console notice still mentions that RNDT requires Chrome or Edge. Let's remove this mention for users opted into the standalone shell experiment. Reviewed By: huntie Differential Revision: D81040965 --- packages/dev-middleware/src/createDevMiddleware.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/dev-middleware/src/createDevMiddleware.js b/packages/dev-middleware/src/createDevMiddleware.js index 1263fec7e5ca..50ae78bdc863 100644 --- a/packages/dev-middleware/src/createDevMiddleware.js +++ b/packages/dev-middleware/src/createDevMiddleware.js @@ -92,6 +92,7 @@ export default function createDevMiddleware({ const eventReporter = createWrappedEventReporter( unstable_eventReporter, logger, + experiments, ); const inspectorProxy = new InspectorProxy( @@ -152,6 +153,7 @@ function getExperiments(config: ExperimentsConfig): Experiments { function createWrappedEventReporter( reporter: ?EventReporter, logger: ?Logger, + experiments: Experiments, ): EventReporter { return { logEvent(event: ReportableEvent) { @@ -166,8 +168,11 @@ function createWrappedEventReporter( logger?.info( '\u001B[1m\u001B[7m💡 JavaScript logs have moved!\u001B[22m They can now be ' + 'viewed in React Native DevTools. Tip: Type \u001B[1mj\u001B[22m in ' + - 'the terminal to open (requires Google Chrome or Microsoft Edge).' + - '\u001B[27m', + 'the terminal to open' + + (experiments.enableStandaloneFuseboxShell + ? '' + : ' (requires Google Chrome or Microsoft Edge)') + + '.\u001B[27m', ); break; case 'fusebox_shell_preparation_attempt':