Skip to content

Instantly share code, notes, and snippets.

@tyrauber
Created February 14, 2026 20:09
Show Gist options
  • Select an option

  • Save tyrauber/fd85d13c2b4a46a446164ae63cd16dea to your computer and use it in GitHub Desktop.

Select an option

Save tyrauber/fd85d13c2b4a46a446164ae63cd16dea to your computer and use it in GitHub Desktop.
Metro DevTools Connection
/**
* 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;
}
}
@tyrauber
Copy link
Author

metro-devtools.t sis ~300 lines with one dependency (ws). Drop it into any Node.js project:

npm install ws @types/ws

import { MetroDevToolsClient } from './metro-devtools';

const client = new MetroDevToolsClient('http://localhost:8081');
const apps = await client.listApps();
await client.connect();

const tree = await client.getComponentTree(10);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment