diff --git a/README.md b/README.md index 8f03c532..2f77cf1d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ agent-device react-devtools profile slow --limit 5 `react-devtools` dynamically runs pinned `agent-react-devtools@0.4.0` commands 1:1, so `agent-device` covers both the device/app runtime layer and React component internals without making React DevTools part of the daemon. +When an Android session is connected through a remote bridge profile, `react-devtools` automatically opens a lease-scoped companion tunnel for the local DevTools daemon on port 8097 and cleans it up when the command exits. + +Remote Android React DevTools assumes the React Native-bundled DevTools behavior in React Native 0.83+. Older browser/Chromium DevTools workflows are not assumed to exist inside remote sandboxes. Expo projects should be verified against the SDK's bundled React Native version before relying on this path; this release does not claim a separately verified Expo SDK version. + ## Command Flow The canonical loop is: diff --git a/package.json b/package.json index a9c5db0d..b878dc31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-device", - "version": "0.12.9", + "version": "0.13.0", "description": "Agent-driven CLI for mobile UI automation, network inspection, and performance diagnostics across iOS, Android, tvOS, and macOS.", "license": "MIT", "author": "Callstack", diff --git a/skills/react-devtools/SKILL.md b/skills/react-devtools/SKILL.md index d9cbfb8b..52199235 100644 --- a/skills/react-devtools/SKILL.md +++ b/skills/react-devtools/SKILL.md @@ -44,6 +44,8 @@ agent-device react-devtools profile rerenders --limit 5 - Labels like `@c5` reset when the app reloads or components remount. After reload, run `wait --connected` and inspect again. - Profiling only captures renders between `profile start` and `profile stop`. - On Android, set `adb reverse tcp:8097 tcp:8097` for React DevTools. If Metro is local, also set `adb reverse tcp:8081 tcp:8081`. +- For Android sessions connected through `agent-device connect --remote-config`, run `agent-device react-devtools ...` normally. The CLI registers a bridge companion tunnel to the local DevTools daemon on `127.0.0.1:8097` and unregisters it when the command exits. +- Remote Android React DevTools assumes the React Native-bundled DevTools behavior in React Native 0.83+. Do not assume older browser/Chromium DevTools workflows exist in remote sandboxes. For Expo apps, verify the SDK's bundled React Native version and runtime behavior first; no Expo SDK version is separately verified by this skill. ## References diff --git a/src/__tests__/cli-react-devtools-session.test.ts b/src/__tests__/cli-react-devtools-session.test.ts new file mode 100644 index 00000000..1aefbc32 --- /dev/null +++ b/src/__tests__/cli-react-devtools-session.test.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; + +vi.mock('../cli/commands/react-devtools.ts', () => ({ + runReactDevtoolsCommand: vi.fn(async () => 0), +})); + +import { runCli } from '../cli.ts'; +import { runReactDevtoolsCommand } from '../cli/commands/react-devtools.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; +import { hashRemoteConfigFile, writeRemoteConnectionState } from '../remote-connection-state.ts'; +import type { DaemonResponse } from '../daemon-client.ts'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +test('react-devtools uses active remote connection session after defaults are merged', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-react-devtools-session-')); + const stateDir = path.join(tempRoot, 'state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test', + platform: 'android', + metroProxyBaseUrl: 'https://bridge.example.test', + metroBearerToken: 'token', + }), + ); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example.test', transport: 'http' }, + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + const originalExit = process.exit; + let exitCode: number | undefined; + const restoreEnv = installIsolatedCliTestEnv(); + (process as any).exit = ((code?: number) => { + exitCode = code ?? 0; + }) as typeof process.exit; + + const sendToDaemon = async (): Promise => ({ ok: true, data: {} }); + + try { + await runCli(['react-devtools', 'status', '--state-dir', stateDir], { sendToDaemon }); + } finally { + restoreEnv(); + process.exit = originalExit; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + assert.equal(exitCode, 0); + assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1); + assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]?.session, 'adc-android'); +}); diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index 6cfadf57..83308c41 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -1,11 +1,28 @@ import fs from 'node:fs'; -import { test } from 'vitest'; +import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; + +vi.mock('../utils/exec.ts', () => ({ + runCmdStreaming: vi.fn(), +})); + +vi.mock('../client-metro-companion.ts', () => ({ + ensureMetroCompanion: vi.fn(), + stopMetroCompanion: vi.fn(), +})); + +import { runCmdStreaming } from '../utils/exec.ts'; +import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts'; import { AGENT_REACT_DEVTOOLS_PACKAGE, buildReactDevtoolsNpmExecArgs, + runReactDevtoolsCommand, } from '../cli/commands/react-devtools.ts'; +afterEach(() => { + vi.clearAllMocks(); +}); + test('react-devtools passthrough pins agent-react-devtools package version', () => { assert.equal(AGENT_REACT_DEVTOOLS_PACKAGE, 'agent-react-devtools@0.4.0'); assert.deepEqual(buildReactDevtoolsNpmExecArgs(['get', 'tree', '--depth', '3']), [ @@ -29,3 +46,120 @@ test('react-devtools docs mention the pinned package version', () => { assert.match(fs.readFileSync(file, 'utf8'), new RegExp(AGENT_REACT_DEVTOOLS_PACKAGE)); } }); + +test('react-devtools starts remote Android companion around passthrough command', async () => { + const env = { ...process.env }; + vi.mocked(runCmdStreaming).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + vi.mocked(ensureMetroCompanion).mockResolvedValueOnce({ + pid: 123, + spawned: true, + statePath: '/tmp/state.json', + logPath: '/tmp/companion.log', + }); + vi.mocked(stopMetroCompanion).mockResolvedValueOnce({ + stopped: true, + statePath: '/tmp/state.json', + }); + + const exitCode = await runReactDevtoolsCommand(['status'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + cwd: '/tmp/project', + env, + flags: { + platform: 'android', + leaseBackend: 'android-instance', + metroProxyBaseUrl: 'https://bridge.example.test', + metroBearerToken: 'token', + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + remoteConfig: '/tmp/remote.json', + session: 'default', + }, + }); + + assert.equal(exitCode, 0); + assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 1); + assert.deepEqual(vi.mocked(ensureMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + stateDir: '/tmp/agent-device-state', + kind: 'react-devtools', + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8097', + bridgeScope: { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, + registerPath: '/api/react-devtools/companion/register', + unregisterPath: '/api/react-devtools/companion/unregister', + devicePort: 8097, + session: 'default', + profileKey: '/tmp/remote.json', + consumerKey: 'default', + env, + }); + assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[0], 'npm'); + assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.cwd, '/tmp/project'); + assert.equal(vi.mocked(runCmdStreaming).mock.calls[0]?.[2]?.env, env); + assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 1); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + stateDir: '/tmp/agent-device-state', + kind: 'react-devtools', + profileKey: '/tmp/remote.json', + consumerKey: 'default', + }); +}); + +test('react-devtools skips companion for non-Android remote sessions', async () => { + vi.mocked(runCmdStreaming).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + await runReactDevtoolsCommand(['status'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + flags: { + platform: 'ios', + metroProxyBaseUrl: 'https://bridge.example.test', + metroBearerToken: 'token', + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, + }); + + assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); + assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 0); +}); + +test('react-devtools fails clearly when remote Android bridge scope is incomplete', async () => { + await assert.rejects( + () => + runReactDevtoolsCommand(['status'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + flags: { + platform: 'android', + leaseBackend: 'android-instance', + metroProxyBaseUrl: 'https://bridge.example.test', + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, + }), + /react-devtools remote Android bridge requires metroBearerToken/, + ); + + assert.equal(vi.mocked(runCmdStreaming).mock.calls.length, 0); + assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); +}); diff --git a/src/__tests__/client-metro-companion-worker.test.ts b/src/__tests__/client-metro-companion-worker.test.ts index 7de9257a..98198700 100644 --- a/src/__tests__/client-metro-companion-worker.test.ts +++ b/src/__tests__/client-metro-companion-worker.test.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import type { Duplex } from 'node:stream'; import { setTimeout as delay } from 'node:timers/promises'; import { afterEach, test } from 'vitest'; +import { buildCompanionPayload } from '../client-metro-companion-worker.ts'; type Deferred = { promise: Promise; @@ -174,6 +175,33 @@ afterEach(async () => { } }); +test('companion payload includes React DevTools session and device port', () => { + assert.deepEqual( + buildCompanionPayload({ + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8097/', + bridgeScope: { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, + session: 'default', + devicePort: 8097, + registerPath: '/api/react-devtools/companion/register', + unregisterPath: '/api/react-devtools/companion/unregister', + }), + { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + session: 'default', + local_base_url: 'http://127.0.0.1:8097', + device_port: 8097, + }, + ); +}); + test('metro companion worker proxies websocket frames to the local upstream server', async () => { const upstreamMessage = createDeferred(); const bridgePong = createDeferred(); diff --git a/src/cli.ts b/src/cli.ts index 07380b4f..8e3f59e0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -145,25 +145,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } const { command, positionals } = parsed; - if (command === 'react-devtools') { - try { - const exitCode = await runReactDevtoolsCommand(positionals); - process.exit(exitCode); - return; - } catch (error) { - const normalized = normalizeError(error, { - diagnosticId: getDiagnosticsMeta().diagnosticId, - logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined, - }); - if (parsed.flags.json) { - printJson({ success: false, error: normalized }); - } else { - printHumanError(normalized, { showDetails: parsed.flags.verbose }); - } - process.exit(1); - return; - } - } let binding: ReturnType; let flags: typeof parsed.flags; let daemonPaths: ReturnType; @@ -212,6 +193,17 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } let logTailStopper: (() => void) | null = null; try { + if (command === 'react-devtools') { + const exitCode = await runReactDevtoolsCommand(positionals, { + flags: effectiveFlags, + stateDir: daemonPaths.baseDir, + session: effectiveFlags.session ?? sessionName, + cwd: process.cwd(), + env: process.env, + }); + process.exit(exitCode); + return; + } maybeRunUpgradeNotifier({ command, currentVersion: version, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index d098a338..9a57461c 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,4 +1,5 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; +import { stopMetroCompanion } from '../../client-metro-companion.ts'; import { stopMetroTunnel } from '../../metro.ts'; import { resolveRemoteConfigProfile } from '../../remote-config.ts'; import type { MetroBridgeScope } from '../../client-metro-companion-contract.ts'; @@ -236,6 +237,23 @@ export async function stopMetroCleanup( } } +export async function stopReactDevtoolsCleanup(options: { + stateDir: string; + state: Pick; +}): Promise { + try { + await stopMetroCompanion({ + projectRoot: process.cwd(), + stateDir: options.stateDir, + kind: 'react-devtools', + profileKey: options.state.remoteConfigPath, + consumerKey: options.state.session, + }); + } catch { + // Connection lifecycle cleanup must stay best-effort. + } +} + export async function releasePreviousLease( client: AgentDeviceClient, previous: RemoteConnectionState, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index d6da9c67..ce4538a2 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -17,6 +17,7 @@ import { releasePreviousLease, resolveRequestedLeaseBackend, stopMetroCleanup, + stopReactDevtoolsCleanup, } from './connection-runtime.ts'; import { writeCommandOutput } from './shared.ts'; import type { LeaseBackend } from '../../contracts.ts'; @@ -104,6 +105,7 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => writeRemoteConnectionState({ stateDir, state }); if (previous && flags.force) { await stopMetroCleanup(previous.metro); + await stopReactDevtoolsCleanup({ stateDir, state: previous }); await releasePreviousLease(client, previous); } const runtimePreparation = buildRuntimePreparationNotice(flags, state); @@ -143,6 +145,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) // Disconnect is idempotent; the session may already be closed. } await stopMetroCleanup(state.metro); + await stopReactDevtoolsCleanup({ stateDir, state }); let released = false; if (state.leaseId) { try { diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 0f39ae48..28bf5e03 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -1,8 +1,42 @@ import { runCmdStreaming } from '../../utils/exec.ts'; +import { ensureMetroCompanion, stopMetroCompanion } from '../../client-metro-companion.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; export const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; const AGENT_REACT_DEVTOOLS_BIN = 'agent-react-devtools'; +const REACT_DEVTOOLS_LOCAL_BASE_URL = 'http://127.0.0.1:8097'; +const REACT_DEVTOOLS_DEVICE_PORT = 8097; +const REACT_DEVTOOLS_REGISTER_PATH = '/api/react-devtools/companion/register'; +const REACT_DEVTOOLS_UNREGISTER_PATH = '/api/react-devtools/companion/unregister'; + +type ReactDevtoolsCommandOptions = { + flags?: Pick< + CliFlags, + | 'platform' + | 'leaseBackend' + | 'metroProxyBaseUrl' + | 'metroBearerToken' + | 'tenant' + | 'runId' + | 'leaseId' + | 'remoteConfig' + | 'session' + >; + stateDir?: string; + session?: string; + cwd?: string; + env?: NodeJS.ProcessEnv; +}; + +type RemoteAndroidBridgeConfig = { + serverBaseUrl: string; + bearerToken: string; + tenantId: string; + runId: string; + leaseId: string; +}; export function buildReactDevtoolsNpmExecArgs(args: string[]): string[] { return [ @@ -16,17 +50,108 @@ export function buildReactDevtoolsNpmExecArgs(args: string[]): string[] { ]; } -export async function runReactDevtoolsCommand(args: string[]): Promise { - const result = await runCmdStreaming('npm', buildReactDevtoolsNpmExecArgs(args), { - cwd: process.cwd(), - env: process.env, - allowFailure: true, - onStdoutChunk: (chunk) => { - process.stdout.write(chunk); - }, - onStderrChunk: (chunk) => { - process.stderr.write(chunk); +function isAndroidRemoteBridge(flags: ReactDevtoolsCommandOptions['flags']): boolean { + if (!flags?.metroProxyBaseUrl) return false; + return flags.platform === 'android' || flags.leaseBackend === 'android-instance'; +} + +function resolveRemoteAndroidBridgeConfig( + flags: ReactDevtoolsCommandOptions['flags'], +): RemoteAndroidBridgeConfig | null { + if (!isAndroidRemoteBridge(flags)) return null; + const serverBaseUrl = flags?.metroProxyBaseUrl; + const bearerToken = flags?.metroBearerToken; + const tenantId = flags?.tenant; + const runId = flags?.runId; + const leaseId = flags?.leaseId; + const missing: string[] = []; + if (!serverBaseUrl) missing.push('metroProxyBaseUrl'); + if (!bearerToken) missing.push('metroBearerToken'); + if (!tenantId) missing.push('tenant'); + if (!runId) missing.push('runId'); + if (!leaseId) missing.push('leaseId'); + if (missing.length > 0) { + throw new AppError( + 'INVALID_ARGS', + `react-devtools remote Android bridge requires ${missing.join(', ')}.`, + { missing }, + ); + } + if (!serverBaseUrl || !bearerToken || !tenantId || !runId || !leaseId) { + throw new AppError('INVALID_ARGS', 'react-devtools remote Android bridge is incomplete.'); + } + return { + serverBaseUrl, + bearerToken, + tenantId, + runId, + leaseId, + }; +} + +async function withRemoteAndroidDevtoolsCompanion( + options: ReactDevtoolsCommandOptions, + action: () => Promise, +): Promise { + const { flags } = options; + const bridgeConfig = resolveRemoteAndroidBridgeConfig(flags); + if (!bridgeConfig) return action(); + + const stateDir = options.stateDir ?? process.cwd(); + const session = options.session ?? flags?.session ?? 'default'; + const profileKey = + flags?.remoteConfig ?? `${bridgeConfig.tenantId}:${bridgeConfig.runId}:${bridgeConfig.leaseId}`; + await ensureMetroCompanion({ + projectRoot: options.cwd ?? process.cwd(), + stateDir, + kind: 'react-devtools', + serverBaseUrl: bridgeConfig.serverBaseUrl, + bearerToken: bridgeConfig.bearerToken, + localBaseUrl: REACT_DEVTOOLS_LOCAL_BASE_URL, + bridgeScope: { + tenantId: bridgeConfig.tenantId, + runId: bridgeConfig.runId, + leaseId: bridgeConfig.leaseId, }, + registerPath: REACT_DEVTOOLS_REGISTER_PATH, + unregisterPath: REACT_DEVTOOLS_UNREGISTER_PATH, + devicePort: REACT_DEVTOOLS_DEVICE_PORT, + session, + profileKey, + consumerKey: session, + env: options.env ?? process.env, + }); + try { + return await action(); + } finally { + await stopMetroCompanion({ + projectRoot: options.cwd ?? process.cwd(), + stateDir, + kind: 'react-devtools', + profileKey, + consumerKey: session, + }); + } +} + +export async function runReactDevtoolsCommand( + args: string[], + options: ReactDevtoolsCommandOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + return await withRemoteAndroidDevtoolsCompanion(options, async () => { + const result = await runCmdStreaming('npm', buildReactDevtoolsNpmExecArgs(args), { + cwd, + env, + allowFailure: true, + onStdoutChunk: (chunk) => { + process.stdout.write(chunk); + }, + onStderrChunk: (chunk) => { + process.stderr.write(chunk); + }, + }); + return result.exitCode; }); - return result.exitCode; } diff --git a/src/client-metro-companion-contract.ts b/src/client-metro-companion-contract.ts index 8d18f03f..e778a145 100644 --- a/src/client-metro-companion-contract.ts +++ b/src/client-metro-companion-contract.ts @@ -1,4 +1,5 @@ export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion'; +export const REACT_DEVTOOLS_COMPANION_RUN_ARG = '--agent-device-run-react-devtools-companion'; export const METRO_COMPANION_RECONNECT_DELAY_MS = 1_000; export const METRO_COMPANION_LEASE_CHECK_INTERVAL_MS = 250; export const WS_READY_STATE_OPEN = 1; @@ -11,6 +12,10 @@ export const ENV_STATE_PATH = 'AGENT_DEVICE_METRO_COMPANION_STATE_PATH'; export const ENV_SCOPE_TENANT_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID'; export const ENV_SCOPE_RUN_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID'; export const ENV_SCOPE_LEASE_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID'; +export const ENV_REGISTER_PATH = 'AGENT_DEVICE_METRO_COMPANION_REGISTER_PATH'; +export const ENV_UNREGISTER_PATH = 'AGENT_DEVICE_METRO_COMPANION_UNREGISTER_PATH'; +export const ENV_DEVICE_PORT = 'AGENT_DEVICE_METRO_COMPANION_DEVICE_PORT'; +export const ENV_SESSION = 'AGENT_DEVICE_METRO_COMPANION_SESSION'; export type MetroBridgeScope = { tenantId: string; @@ -25,4 +30,8 @@ export type CompanionOptions = { bridgeScope: MetroBridgeScope; launchUrl?: string; statePath?: string; + registerPath?: string; + unregisterPath?: string; + devicePort?: number; + session?: string; }; diff --git a/src/client-metro-companion-worker.ts b/src/client-metro-companion-worker.ts index f97710cf..89866f3d 100644 --- a/src/client-metro-companion-worker.ts +++ b/src/client-metro-companion-worker.ts @@ -4,14 +4,19 @@ import { ENV_BEARER_TOKEN, ENV_LAUNCH_URL, ENV_LOCAL_BASE_URL, + ENV_DEVICE_PORT, + ENV_REGISTER_PATH, ENV_SERVER_BASE_URL, + ENV_SESSION, ENV_SCOPE_LEASE_ID, ENV_SCOPE_RUN_ID, ENV_SCOPE_TENANT_ID, ENV_STATE_PATH, + ENV_UNREGISTER_PATH, METRO_COMPANION_LEASE_CHECK_INTERVAL_MS, METRO_COMPANION_RECONNECT_DELAY_MS, METRO_COMPANION_RUN_ARG, + REACT_DEVTOOLS_COMPANION_RUN_ARG, WS_READY_STATE_OPEN, } from './client-metro-companion-contract.ts'; import type { CompanionOptions } from './client-metro-companion-contract.ts'; @@ -21,6 +26,8 @@ import type { } from './metro.ts'; import { normalizeBaseUrl } from './utils/url.ts'; +const COMPANION_REGISTER_TIMEOUT_MS = 5_000; + function createHeaders(serverBaseUrl: string, token: string): Record { return { authorization: `Bearer ${token}`, @@ -29,29 +36,86 @@ function createHeaders(serverBaseUrl: string, token: string): Record 300 ? `${normalized.slice(0, 300)}...` : normalized; +} + +function resolveRegisterPath(options: CompanionOptions): string { + return options.registerPath ?? '/api/metro/companion/register'; +} + +function resolveUnregisterPath(options: CompanionOptions): string | null { + return options.unregisterPath ?? null; +} + +export function buildCompanionPayload(options: CompanionOptions): Record { + return { + ...options.bridgeScope, + ...(options.session ? { session: options.session } : {}), + local_base_url: normalizeBaseUrl(options.localBaseUrl), + ...(options.devicePort ? { device_port: options.devicePort } : {}), + ...(options.launchUrl ? { launch_url: options.launchUrl } : {}), + }; +} + async function registerCompanion(options: CompanionOptions): Promise<{ wsUrl: string }> { - const response = await fetch( - `${normalizeBaseUrl(options.serverBaseUrl)}/api/metro/companion/register`, - { + const registerPath = resolveRegisterPath(options); + let response: Response; + try { + response = await fetch(`${normalizeBaseUrl(options.serverBaseUrl)}${registerPath}`, { method: 'POST', headers: createHeaders(options.serverBaseUrl, options.bearerToken), - body: JSON.stringify({ - ...options.bridgeScope, - local_base_url: normalizeBaseUrl(options.localBaseUrl), - ...(options.launchUrl ? { launch_url: options.launchUrl } : {}), - }), - }, - ); - const payload = (await response.json()) as { + body: JSON.stringify(buildCompanionPayload(options)), + signal: AbortSignal.timeout(COMPANION_REGISTER_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new Error( + `${registerPath} timed out after ${COMPANION_REGISTER_TIMEOUT_MS}ms calling ${normalizeBaseUrl( + options.serverBaseUrl, + )}${registerPath}`, + ); + } + throw error; + } + const responseText = await response.text(); + let payload: { ok?: boolean; data?: { ws_url?: string }; }; + try { + payload = responseText ? (JSON.parse(responseText) as typeof payload) : {}; + } catch { + throw new Error( + `Failed to register companion (${response.status}): invalid JSON response: ${formatResponseSnippet( + responseText, + )}`, + ); + } if (!response.ok || payload.ok !== true || typeof payload.data?.ws_url !== 'string') { - throw new Error(`Failed to register Metro companion: ${JSON.stringify(payload)}`); + throw new Error( + `Failed to register companion (${response.status}): ${JSON.stringify(payload)}`, + ); } return { wsUrl: payload.data.ws_url }; } +async function unregisterCompanion(options: CompanionOptions): Promise { + const unregisterPath = resolveUnregisterPath(options); + if (!unregisterPath) return; + try { + await fetch(`${normalizeBaseUrl(options.serverBaseUrl)}${unregisterPath}`, { + method: 'POST', + headers: createHeaders(options.serverBaseUrl, options.bearerToken), + body: JSON.stringify(buildCompanionPayload(options)), + signal: AbortSignal.timeout(2_000), + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + } +} + async function bufferFromWebSocketData(data: unknown): Promise { if (typeof data === 'string') return Buffer.from(data, 'utf8'); if (data instanceof ArrayBuffer) return Buffer.from(data); @@ -275,6 +339,22 @@ async function handleBridgeMessage( export async function runMetroCompanionWorker(options: CompanionOptions): Promise { const upstreamSockets = new Map(); + let shutdownRequested = false; + let activeBridgeSocket: WebSocket | null = null; + let activeRegistrationComplete = false; + const requestShutdown = () => { + if (shutdownRequested) return; + shutdownRequested = true; + if (activeRegistrationComplete) { + void unregisterCompanion(options).finally(() => process.exit(0)); + } + if (activeBridgeSocket) { + closeSocketQuietly(activeBridgeSocket, 1000, 'companion stopping'); + } + setTimeout(() => process.exit(0), 900).unref(); + }; + process.once('SIGTERM', requestShutdown); + process.once('SIGINT', requestShutdown); const lifetimeHandle = setInterval(() => { if (!shouldKeepWorkerRunning(options)) { // Node's built-in WebSocket client does not expose a force-close API. If the peer never @@ -284,30 +364,58 @@ export async function runMetroCompanionWorker(options: CompanionOptions): Promis } }, METRO_COMPANION_LEASE_CHECK_INTERVAL_MS); lifetimeHandle.unref(); - while (shouldKeepWorkerRunning(options)) { + while (!shutdownRequested && shouldKeepWorkerRunning(options)) { + let registered = false; try { + activeRegistrationComplete = false; const registration = await registerCompanion(options); + registered = true; + activeRegistrationComplete = true; + if (shutdownRequested || !shouldKeepWorkerRunning(options)) { + await unregisterCompanion(options); + registered = false; + activeRegistrationComplete = false; + break; + } const bridgeSocket = new WebSocket(registration.wsUrl); + activeBridgeSocket = bridgeSocket; bridgeSocket.binaryType = 'arraybuffer'; - await waitForSocketOpen(bridgeSocket, 'Bridge'); - bridgeSocket.addEventListener('message', (event) => { - void (async () => { - const message = await parseBridgeMessage(event); - await handleBridgeMessage(bridgeSocket, message, options, upstreamSockets); - })().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); + try { + await waitForSocketOpen(bridgeSocket, 'Bridge'); + bridgeSocket.addEventListener('message', (event) => { + void (async () => { + const message = await parseBridgeMessage(event); + await handleBridgeMessage(bridgeSocket, message, options, upstreamSockets); + })().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + }); }); - }); - await waitForSocketShutdown(bridgeSocket); - upstreamSockets.forEach((socket) => closeSocketQuietly(socket, 1012, 'bridge disconnected')); - upstreamSockets.clear(); + await waitForSocketShutdown(bridgeSocket); + } finally { + activeBridgeSocket = null; + activeRegistrationComplete = false; + upstreamSockets.forEach((socket) => + closeSocketQuietly(socket, 1012, 'bridge disconnected'), + ); + upstreamSockets.clear(); + if (registered) { + await unregisterCompanion(options); + registered = false; + } + } } catch (error) { - if (!shouldKeepWorkerRunning(options)) { + activeBridgeSocket = null; + activeRegistrationComplete = false; + if (registered) { + await unregisterCompanion(options); + registered = false; + } + if (shutdownRequested || !shouldKeepWorkerRunning(options)) { break; } console.error(error instanceof Error ? error.message : String(error)); } - if (!shouldKeepWorkerRunning(options)) { + if (shutdownRequested || !shouldKeepWorkerRunning(options)) { break; } await delay(METRO_COMPANION_RECONNECT_DELAY_MS); @@ -315,8 +423,20 @@ export async function runMetroCompanionWorker(options: CompanionOptions): Promis clearInterval(lifetimeHandle); } +function parseDevicePort(value: string | undefined): number | undefined { + if (!value?.trim()) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + throw new Error('Companion worker received invalid device port configuration.'); + } + return parsed; +} + function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOptions | null { - if (argv[0] !== METRO_COMPANION_RUN_ARG) return null; + const commandArg = argv[0]; + if (commandArg !== METRO_COMPANION_RUN_ARG && commandArg !== REACT_DEVTOOLS_COMPANION_RUN_ARG) { + return null; + } const serverBaseUrl = env[ENV_SERVER_BASE_URL]?.trim(); const bearerToken = env[ENV_BEARER_TOKEN]?.trim(); const localBaseUrl = env[ENV_LOCAL_BASE_URL]?.trim(); @@ -340,6 +460,10 @@ function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOpt }, launchUrl: env[ENV_LAUNCH_URL]?.trim() || undefined, statePath: env[ENV_STATE_PATH]?.trim() || undefined, + registerPath: env[ENV_REGISTER_PATH]?.trim() || undefined, + unregisterPath: env[ENV_UNREGISTER_PATH]?.trim() || undefined, + devicePort: parseDevicePort(env[ENV_DEVICE_PORT]), + session: env[ENV_SESSION]?.trim() || undefined, }; } diff --git a/src/client-metro-companion.ts b/src/client-metro-companion.ts index d415aa77..f551c83e 100644 --- a/src/client-metro-companion.ts +++ b/src/client-metro-companion.ts @@ -4,14 +4,19 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ENV_BEARER_TOKEN, + ENV_DEVICE_PORT, ENV_LAUNCH_URL, ENV_LOCAL_BASE_URL, + ENV_REGISTER_PATH, ENV_SERVER_BASE_URL, + ENV_SESSION, ENV_SCOPE_LEASE_ID, ENV_SCOPE_RUN_ID, ENV_SCOPE_TENANT_ID, ENV_STATE_PATH, + ENV_UNREGISTER_PATH, METRO_COMPANION_RUN_ARG, + REACT_DEVTOOLS_COMPANION_RUN_ARG, } from './client-metro-companion-contract.ts'; import type { MetroBridgeScope } from './client-metro-companion-contract.ts'; import { normalizeBaseUrl } from './utils/url.ts'; @@ -28,6 +33,11 @@ const METRO_COMPANION_KILL_TIMEOUT_MS = 1_000; const METRO_COMPANION_STATE_FILE = 'metro-companion.json'; const METRO_COMPANION_LOG_FILE = 'metro-companion.log'; const METRO_COMPANION_STATE_DIR = 'metro-companion'; +const REACT_DEVTOOLS_COMPANION_STATE_FILE = 'react-devtools-companion.json'; +const REACT_DEVTOOLS_COMPANION_LOG_FILE = 'react-devtools-companion.log'; +const REACT_DEVTOOLS_COMPANION_STATE_DIR = 'react-devtools-companion'; + +type CompanionKind = 'metro' | 'react-devtools'; type CompanionState = { pid: number; @@ -36,6 +46,10 @@ type CompanionState = { serverBaseUrl: string; localBaseUrl: string; launchUrl?: string; + registerPath?: string; + unregisterPath?: string; + devicePort?: number; + session?: string; bridgeScope?: MetroBridgeScope; tokenHash: string; consumers: string[]; @@ -47,9 +61,15 @@ export type EnsureMetroCompanionOptions = { bearerToken: string; localBaseUrl: string; bridgeScope: MetroBridgeScope; + kind?: CompanionKind; launchUrl?: string; + registerPath?: string; + unregisterPath?: string; + devicePort?: number; + session?: string; profileKey?: string; consumerKey?: string; + stateDir?: string; env?: NodeJS.ProcessEnv; }; @@ -62,6 +82,8 @@ export type EnsureMetroCompanionResult = { export type StopMetroCompanionOptions = { projectRoot: string; + kind?: CompanionKind; + stateDir?: string; profileKey?: string; consumerKey?: string; }; @@ -95,22 +117,44 @@ function areCompanionScopesEqual(a: MetroBridgeScope, b: MetroBridgeScope): bool return a.tenantId === b.tenantId && a.runId === b.runId && a.leaseId === b.leaseId; } +function companionStateNames(kind: CompanionKind): { + stateFile: string; + logFile: string; + stateDir: string; +} { + if (kind === 'react-devtools') { + return { + stateFile: REACT_DEVTOOLS_COMPANION_STATE_FILE, + logFile: REACT_DEVTOOLS_COMPANION_LOG_FILE, + stateDir: REACT_DEVTOOLS_COMPANION_STATE_DIR, + }; + } + return { + stateFile: METRO_COMPANION_STATE_FILE, + logFile: METRO_COMPANION_LOG_FILE, + stateDir: METRO_COMPANION_STATE_DIR, + }; +} + function resolveCompanionPaths( projectRoot: string, profileKey?: string, + kind: CompanionKind = 'metro', + stateDir?: string, ): { statePath: string; logPath: string } { - const dir = path.join(projectRoot, '.agent-device'); + const names = companionStateNames(kind); + const dir = stateDir ?? path.join(projectRoot, '.agent-device'); if (!profileKey) { return { - statePath: path.join(dir, METRO_COMPANION_STATE_FILE), - logPath: path.join(dir, METRO_COMPANION_LOG_FILE), + statePath: path.join(dir, names.stateFile), + logPath: path.join(dir, names.logFile), }; } const profileHash = hashString(profileKey).slice(0, 12); - const profileDir = path.join(dir, METRO_COMPANION_STATE_DIR); + const profileDir = path.join(dir, names.stateDir); return { - statePath: path.join(profileDir, `metro-companion-${profileHash}.json`), - logPath: path.join(profileDir, `metro-companion-${profileHash}.log`), + statePath: path.join(profileDir, `${names.stateDir}-${profileHash}.json`), + logPath: path.join(profileDir, `${names.stateDir}-${profileHash}.log`), }; } @@ -136,6 +180,16 @@ function readCompanionState(statePath: string): CompanionState | null { launchUrl: normalizeOptionalString( typeof parsed.launchUrl === 'string' ? parsed.launchUrl : undefined, ), + registerPath: normalizeOptionalString( + typeof parsed.registerPath === 'string' ? parsed.registerPath : undefined, + ), + unregisterPath: normalizeOptionalString( + typeof parsed.unregisterPath === 'string' ? parsed.unregisterPath : undefined, + ), + devicePort: Number.isInteger(parsed.devicePort) ? Number(parsed.devicePort) : undefined, + session: normalizeOptionalString( + typeof parsed.session === 'string' ? parsed.session : undefined, + ), bridgeScope: readCompanionScope(parsed.bridgeScope), tokenHash: parsed.tokenHash, consumers, @@ -192,7 +246,9 @@ function clearCompanionArtifacts(paths: { statePath: string; logPath: string }): } function isMetroCompanionCommand(command: string): boolean { - return command.includes(METRO_COMPANION_RUN_ARG); + return ( + command.includes(METRO_COMPANION_RUN_ARG) || command.includes(REACT_DEVTOOLS_COMPANION_RUN_ARG) + ); } function shouldReuseCompanion( @@ -211,6 +267,10 @@ function shouldReuseCompanion( state.serverBaseUrl === normalizeBaseUrl(options.serverBaseUrl) && state.localBaseUrl === normalizeBaseUrl(options.localBaseUrl) && state.launchUrl === normalizeOptionalString(options.launchUrl) && + state.registerPath === normalizeOptionalString(options.registerPath) && + state.unregisterPath === normalizeOptionalString(options.unregisterPath) && + state.devicePort === options.devicePort && + state.session === normalizeOptionalString(options.session) && areCompanionScopesEqual(state.bridgeScope, options.bridgeScope) && state.tokenHash === hashString(options.bearerToken) ); @@ -278,7 +338,12 @@ function buildCompanionEnv( [ENV_SERVER_BASE_URL]: normalizeBaseUrl(options.serverBaseUrl), [ENV_BEARER_TOKEN]: options.bearerToken, [ENV_LOCAL_BASE_URL]: normalizeBaseUrl(options.localBaseUrl), - [ENV_STATE_PATH]: resolveCompanionPaths(options.projectRoot, options.profileKey).statePath, + [ENV_STATE_PATH]: resolveCompanionPaths( + options.projectRoot, + options.profileKey, + options.kind, + options.stateDir, + ).statePath, }; nextEnv[ENV_SCOPE_TENANT_ID] = options.bridgeScope.tenantId; nextEnv[ENV_SCOPE_RUN_ID] = options.bridgeScope.runId; @@ -288,6 +353,26 @@ function buildCompanionEnv( } else { delete nextEnv[ENV_LAUNCH_URL]; } + if (options.registerPath?.trim()) { + nextEnv[ENV_REGISTER_PATH] = options.registerPath.trim(); + } else { + delete nextEnv[ENV_REGISTER_PATH]; + } + if (options.unregisterPath?.trim()) { + nextEnv[ENV_UNREGISTER_PATH] = options.unregisterPath.trim(); + } else { + delete nextEnv[ENV_UNREGISTER_PATH]; + } + if (options.devicePort !== undefined) { + nextEnv[ENV_DEVICE_PORT] = String(options.devicePort); + } else { + delete nextEnv[ENV_DEVICE_PORT]; + } + if (options.session?.trim()) { + nextEnv[ENV_SESSION] = options.session.trim(); + } else { + delete nextEnv[ENV_SESSION]; + } return nextEnv; } @@ -309,11 +394,13 @@ function spawnCompanionProcess( ): CompanionState { const modulePath = resolveCompanionEntryModulePath(); const execArgs = modulePath.endsWith('.ts') ? ['--experimental-strip-types'] : []; + const runArg = + options.kind === 'react-devtools' ? REACT_DEVTOOLS_COMPANION_RUN_ARG : METRO_COMPANION_RUN_ARG; fs.mkdirSync(path.dirname(logPath), { recursive: true }); const logFd = fs.openSync(logPath, 'a'); let pid = 0; try { - pid = runCmdDetached(process.execPath, [...execArgs, modulePath, METRO_COMPANION_RUN_ARG], { + pid = runCmdDetached(process.execPath, [...execArgs, modulePath, runArg], { env: buildCompanionEnv(options, options.env ?? process.env), stdio: ['ignore', logFd, logFd], }); @@ -330,6 +417,10 @@ function spawnCompanionProcess( serverBaseUrl: normalizeBaseUrl(options.serverBaseUrl), localBaseUrl: normalizeBaseUrl(options.localBaseUrl), launchUrl: normalizeOptionalString(options.launchUrl), + registerPath: normalizeOptionalString(options.registerPath), + unregisterPath: normalizeOptionalString(options.unregisterPath), + devicePort: options.devicePort, + session: normalizeOptionalString(options.session), bridgeScope: options.bridgeScope, tokenHash: hashString(options.bearerToken), consumers: [], @@ -340,7 +431,12 @@ export async function ensureMetroCompanion( options: EnsureMetroCompanionOptions, ): Promise { const consumerKey = resolveConsumerKey(options); - const paths = resolveCompanionPaths(options.projectRoot, options.profileKey); + const paths = resolveCompanionPaths( + options.projectRoot, + options.profileKey, + options.kind, + options.stateDir, + ); const existing = readCompanionState(paths.statePath); if (existing && shouldReuseCompanion(existing, options)) { const nextState = withConsumer(existing, consumerKey); @@ -374,7 +470,12 @@ export async function stopMetroCompanion( options: StopMetroCompanionOptions, ): Promise<{ stopped: boolean; statePath: string }> { const consumerKey = resolveConsumerKey(options); - const paths = resolveCompanionPaths(options.projectRoot, options.profileKey); + const paths = resolveCompanionPaths( + options.projectRoot, + options.profileKey, + options.kind, + options.stateDir, + ); const existing = readCompanionState(paths.statePath); if (!existing) { clearCompanionArtifacts(paths); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 0c82c023..4c2bc1ce 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -560,6 +560,8 @@ agent-device react-devtools profile rerenders --limit 5 - Use it when a React Native workflow needs component hierarchy, props, state, hooks, render causes, slow components, or re-render counts. - Keep using `snapshot`, `press`, `fill`, `logs`, `network`, and `perf` for device/app runtime evidence. Use `react-devtools` for React internals. - React Native development builds can connect to the DevTools daemon on port 8097. For Android emulators or physical devices, run `adb reverse tcp:8097 tcp:8097` if the app cannot reach the host. If Metro is local, also run `adb reverse tcp:8081 tcp:8081`. +- For Android sessions connected through a remote bridge profile, `react-devtools` registers a lease-scoped companion tunnel to the sandbox-local DevTools daemon at `127.0.0.1:8097`. The bridge owns the remote `adb reverse` mapping and the CLI unregisters the companion when the command exits. +- Remote Android React DevTools assumes the React Native-bundled DevTools behavior in React Native 0.83+. Older browser/Chromium DevTools workflows are not assumed to exist inside remote sandboxes. Expo projects should be verified against the SDK's bundled React Native version before relying on this path; this release does not claim a separately verified Expo SDK version. - For cross-platform validation with explicit target selectors, prefer an isolated `--state-dir` over separate named sessions. Named sessions enable bound-session locks during setup. Restart `react-devtools` between iOS and Android runs. ## Media and logs