Created
February 14, 2026 20:09
-
-
Save tyrauber/fd85d13c2b4a46a446164ae63cd16dea to your computer and use it in GitHub Desktop.
Metro DevTools Connection
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Metro DevTools Connection | |
| * | |
| * Connects directly to Metro's debugger proxy to access React DevTools data. | |
| * No client SDK required - works with any React Native app in dev mode. | |
| * | |
| * Architecture: | |
| * 1. GET http://localhost:8081/json/list → list debuggable apps | |
| * 2. WebSocket ws://localhost:8081/inspector/debug?device=<id>&page=1 → CDP connection | |
| * 3. Runtime.evaluate → execute JS in the running app | |
| * 4. Access __FUSEBOX_REACT_DEVTOOLS_DISPATCHER__ → React DevTools data | |
| */ | |
| import { EventEmitter } from 'events'; | |
| import WebSocket from 'ws'; | |
| // ============================================================================= | |
| // Types | |
| // ============================================================================= | |
| export interface MetroApp { | |
| id: string; | |
| title: string; | |
| appId: string; | |
| description: string; | |
| type: 'node'; | |
| devtoolsFrontendUrl: string; | |
| webSocketDebuggerUrl: string; | |
| deviceName: string; | |
| reactNative?: { | |
| logicalDeviceId: string; | |
| capabilities: unknown; | |
| }; | |
| } | |
| export interface CDPCommand { | |
| id: number; | |
| method: string; | |
| params?: Record<string, unknown>; | |
| } | |
| export interface CDPResponse { | |
| id: number; | |
| result?: { | |
| result?: { | |
| type: string; | |
| subtype?: string; | |
| value?: unknown; | |
| description?: string; | |
| }; | |
| exceptionDetails?: { | |
| text: string; | |
| exception?: { | |
| description?: string; | |
| value?: unknown; | |
| }; | |
| }; | |
| }; | |
| error?: { | |
| code: number; | |
| message: string; | |
| }; | |
| } | |
| export interface ReactComponentNode { | |
| id: number; | |
| type: string; | |
| displayName: string; | |
| key: string | null; | |
| props: Record<string, unknown>; | |
| state: Record<string, unknown> | null; | |
| children: ReactComponentNode[]; | |
| bounds?: { | |
| x: number; | |
| y: number; | |
| width: number; | |
| height: number; | |
| }; | |
| } | |
| // ============================================================================= | |
| // Metro DevTools Client | |
| // ============================================================================= | |
| let commandId = 0; | |
| const getCommandId = () => ++commandId; | |
| export class MetroDevToolsClient extends EventEmitter { | |
| private ws: WebSocket | null = null; | |
| private pendingCommands = new Map< | |
| number, | |
| { | |
| resolve: (response: CDPResponse) => void; | |
| reject: (error: Error) => void; | |
| } | |
| >(); | |
| private metroUrl: string; | |
| constructor(metroUrl: string = 'http://localhost:8081') { | |
| super(); | |
| this.metroUrl = metroUrl; | |
| } | |
| /** | |
| * List all debuggable React Native apps | |
| */ | |
| async listApps(): Promise<MetroApp[]> { | |
| const response = await fetch(`${this.metroUrl}/json/list?user-agent=scalable-mcp`); | |
| if (!response.ok) { | |
| throw new Error(`Failed to list apps: ${response.status}`); | |
| } | |
| const apps = await response.json(); | |
| return apps.reverse(); // Most recent first | |
| } | |
| /** | |
| * Connect to a React Native app's debugger | |
| */ | |
| async connect(deviceId?: string, pageId: string = '1'): Promise<void> { | |
| // If no deviceId provided, get the first available app | |
| if (!deviceId) { | |
| const apps = await this.listApps(); | |
| if (apps.length === 0) { | |
| throw new Error('No debuggable React Native apps found. Is Metro running?'); | |
| } | |
| deviceId = apps[0].reactNative?.logicalDeviceId || apps[0].id; | |
| } | |
| const wsUrl = `${this.metroUrl.replace('http', 'ws')}/inspector/debug?device=${deviceId}&page=${pageId}&user-agent=scalable-mcp`; | |
| return new Promise((resolve, reject) => { | |
| this.ws = new WebSocket(wsUrl); | |
| this.ws.onopen = async () => { | |
| try { | |
| // Wait for Fusebox dispatcher to be initialized | |
| await this.waitForFuseboxDispatcher(); | |
| // Initialize React DevTools domain | |
| await this.sendCommand('Runtime.evaluate', { | |
| expression: `__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__.initializeDomain('react-devtools')`, | |
| }); | |
| // Enable console for logging | |
| await this.sendCommand('Console.enable', {}); | |
| resolve(); | |
| } catch (error) { | |
| reject(error); | |
| } | |
| }; | |
| this.ws.onerror = (event) => { | |
| reject(new Error(`WebSocket error: ${event}`)); | |
| }; | |
| this.ws.onclose = () => { | |
| this.emit('close'); | |
| }; | |
| this.ws.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data as string); | |
| // Handle command responses | |
| if (data.id && this.pendingCommands.has(data.id)) { | |
| const { resolve } = this.pendingCommands.get(data.id)!; | |
| this.pendingCommands.delete(data.id); | |
| resolve(data); | |
| } | |
| // Handle events | |
| if (data.method && !data.id) { | |
| this.emit('event', data); | |
| // Special handling for Runtime.bindingCalled (DevTools messages) | |
| if (data.method === 'Runtime.bindingCalled') { | |
| try { | |
| const payload = JSON.parse(data.params.payload); | |
| if (payload.domain === 'react-devtools') { | |
| this.emit('devtools-message', payload.message); | |
| } | |
| } catch { | |
| // Ignore parse errors | |
| } | |
| } | |
| } | |
| } catch { | |
| // Ignore parse errors | |
| } | |
| }; | |
| }); | |
| } | |
| /** | |
| * Disconnect from the debugger | |
| */ | |
| disconnect(): void { | |
| if (this.ws) { | |
| this.ws.close(); | |
| this.ws = null; | |
| } | |
| this.pendingCommands.clear(); | |
| } | |
| /** | |
| * Send a CDP command and wait for response | |
| */ | |
| async sendCommand(method: string, params: Record<string, unknown> = {}): Promise<CDPResponse> { | |
| if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { | |
| throw new Error('Not connected to Metro debugger'); | |
| } | |
| const id = getCommandId(); | |
| const command: CDPCommand = { id, method, params }; | |
| return new Promise((resolve, reject) => { | |
| this.pendingCommands.set(id, { resolve, reject }); | |
| const timeout = setTimeout(() => { | |
| this.pendingCommands.delete(id); | |
| reject(new Error(`Command ${method} timed out`)); | |
| }, 30000); | |
| this.ws!.send(JSON.stringify(command)); | |
| // Clear timeout when resolved | |
| const originalResolve = this.pendingCommands.get(id)!.resolve; | |
| this.pendingCommands.set(id, { | |
| resolve: (response) => { | |
| clearTimeout(timeout); | |
| originalResolve(response); | |
| }, | |
| reject, | |
| }); | |
| }); | |
| } | |
| /** | |
| * Evaluate JavaScript in the app context | |
| */ | |
| async evaluate(expression: string, returnByValue: boolean = true): Promise<unknown> { | |
| const response = await this.sendCommand('Runtime.evaluate', { | |
| expression, | |
| returnByValue, | |
| }); | |
| if (response.error) { | |
| throw new Error(`Evaluation failed: ${response.error.message}`); | |
| } | |
| if (response.result?.exceptionDetails) { | |
| throw new Error( | |
| `Evaluation exception: ${response.result.exceptionDetails.text} - ${response.result.exceptionDetails.exception?.description || ''}` | |
| ); | |
| } | |
| return response.result?.result?.value; | |
| } | |
| /** | |
| * Get the React component tree | |
| * | |
| * This accesses the React DevTools global hook to extract the Fiber tree. | |
| */ | |
| async getComponentTree(maxDepth: number = 10): Promise<ReactComponentNode | null> { | |
| const expression = ` | |
| (function() { | |
| const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; | |
| if (!hook) return { error: 'React DevTools hook not found' }; | |
| const renderers = hook.renderers; | |
| if (!renderers || renderers.size === 0) { | |
| return { error: 'No React renderers found' }; | |
| } | |
| // Get the first renderer (usually React Native) | |
| const renderer = renderers.values().next().value; | |
| if (!renderer) return { error: 'Could not get renderer' }; | |
| // Get Fiber roots | |
| const roots = hook.getFiberRoots(renderer.rendererID || 1); | |
| if (!roots || roots.size === 0) { | |
| return { error: 'No Fiber roots found' }; | |
| } | |
| const root = roots.values().next().value; | |
| if (!root || !root.current) { | |
| return { error: 'Could not get root Fiber' }; | |
| } | |
| // Track visited fibers to avoid cycles | |
| const visited = new WeakSet(); | |
| // Traverse the Fiber tree | |
| function traverseFiber(fiber, depth) { | |
| if (!fiber || depth > ${maxDepth}) return null; | |
| if (visited.has(fiber)) return null; | |
| visited.add(fiber); | |
| // Get the display name | |
| let typeName = 'Unknown'; | |
| if (typeof fiber.type === 'string') { | |
| typeName = fiber.type; | |
| } else if (fiber.type?.displayName) { | |
| typeName = fiber.type.displayName; | |
| } else if (fiber.type?.name) { | |
| typeName = fiber.type.name; | |
| } else if (fiber.tag === 5) { | |
| typeName = 'HostComponent'; | |
| } else if (fiber.tag === 6) { | |
| typeName = 'HostText'; | |
| } | |
| const node = { | |
| type: typeName, | |
| key: fiber.key, | |
| props: {}, | |
| state: null, | |
| children: [] | |
| }; | |
| // Get props (filter out functions and complex objects) | |
| if (fiber.memoizedProps) { | |
| for (const [key, value] of Object.entries(fiber.memoizedProps)) { | |
| try { | |
| if (typeof value === 'function') { | |
| node.props[key] = '[Function]'; | |
| } else if (value === null || value === undefined) { | |
| node.props[key] = value; | |
| } else if (typeof value !== 'object') { | |
| node.props[key] = value; | |
| } else if (key === 'style') { | |
| // Safely stringify style | |
| try { | |
| node.props.style = JSON.parse(JSON.stringify(value)); | |
| } catch { | |
| node.props.style = '[Complex style]'; | |
| } | |
| } else if (key === 'children' && typeof value === 'string') { | |
| node.props.children = value; | |
| } else if (Array.isArray(value)) { | |
| node.props[key] = '[Array(' + value.length + ')]'; | |
| } else { | |
| node.props[key] = '[Object]'; | |
| } | |
| } catch { | |
| // Skip problematic props | |
| } | |
| } | |
| } | |
| // Get state for class components (tag 1 = ClassComponent) | |
| if (fiber.memoizedState && fiber.tag === 1) { | |
| try { | |
| const stateStr = JSON.stringify(fiber.memoizedState); | |
| if (stateStr.length < 1000) { | |
| node.state = JSON.parse(stateStr); | |
| } else { | |
| node.state = '[Large state object]'; | |
| } | |
| } catch { | |
| node.state = '[Complex state]'; | |
| } | |
| } | |
| // Traverse children | |
| let child = fiber.child; | |
| while (child) { | |
| const childNode = traverseFiber(child, depth + 1); | |
| if (childNode) { | |
| node.children.push(childNode); | |
| } | |
| child = child.sibling; | |
| } | |
| return node; | |
| } | |
| return traverseFiber(root.current, 0); | |
| })() | |
| `; | |
| const result = await this.evaluate(expression); | |
| if (result && typeof result === 'object' && 'error' in result) { | |
| throw new Error((result as { error: string }).error); | |
| } | |
| return result as ReactComponentNode | null; | |
| } | |
| /** | |
| * Find components by display name | |
| */ | |
| async findComponents(displayName: string): Promise<ReactComponentNode[]> { | |
| const tree = await this.getComponentTree(20); | |
| if (!tree) return []; | |
| const matches: ReactComponentNode[] = []; | |
| function search(node: ReactComponentNode) { | |
| if (node.type.toLowerCase().includes(displayName.toLowerCase())) { | |
| matches.push(node); | |
| } | |
| for (const child of node.children) { | |
| search(child); | |
| } | |
| } | |
| search(tree); | |
| return matches; | |
| } | |
| /** | |
| * Get React Native navigation state | |
| */ | |
| async getNavigationState(): Promise<unknown> { | |
| const expression = ` | |
| (function() { | |
| // Try to find navigation container | |
| const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; | |
| if (!hook) return null; | |
| // Look for navigation state in global | |
| if (window.__REACT_NAVIGATION_DEVTOOLS__) { | |
| return window.__REACT_NAVIGATION_DEVTOOLS__.getState(); | |
| } | |
| return null; | |
| })() | |
| `; | |
| return this.evaluate(expression); | |
| } | |
| /** | |
| * Wait for the Fusebox dispatcher to be initialized | |
| */ | |
| private async waitForFuseboxDispatcher(maxAttempts: number = 20): Promise<void> { | |
| for (let attempt = 0; attempt < maxAttempts; attempt++) { | |
| const response = await this.sendCommand('Runtime.evaluate', { | |
| expression: 'globalThis.__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__ != undefined', | |
| returnByValue: true, | |
| }); | |
| if (response.result?.result?.value === true) { | |
| return; | |
| } | |
| // Wait 250ms before retrying | |
| await new Promise((resolve) => setTimeout(resolve, 250)); | |
| } | |
| throw new Error('Fusebox dispatcher not initialized after timeout'); | |
| } | |
| } | |
| // ============================================================================= | |
| // Singleton instance | |
| // ============================================================================= | |
| let client: MetroDevToolsClient | null = null; | |
| export function getMetroClient(metroUrl?: string): MetroDevToolsClient { | |
| if (!client) { | |
| client = new MetroDevToolsClient(metroUrl); | |
| } | |
| return client; | |
| } | |
| export function disconnectMetroClient(): void { | |
| if (client) { | |
| client.disconnect(); | |
| client = null; | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
metro-devtools.t sis ~300 lines with one dependency (ws). Drop it into any Node.js project:
const tree = await client.getComponentTree(10);