From 7046c24702aac3e606f86935bf2cc4d4b6ee44d6 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 27 Aug 2025 02:50:05 -0700 Subject: [PATCH] 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. Reviewed By: huntie Differential Revision: D78413091 fbshipit-source-id: caf2010edd1bcdd139d37d7849212cd1cbb64f46 --- .../__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 00000000000..cda13942506 --- /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 00000000000..33c2bf073d4 --- /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 00000000000..61cd560c2fb --- /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 00000000000..52b93e082c2 --- /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 00000000000..e481dc6e7ab --- /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 11a1137555e..88cbee5cc1b 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 00000000000..283d284058a --- /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};