بنقرة واحدة
expo-devtools-cli
// Building Expo DevTools Plugins with CLI Interfaces for interacting with running Expo apps using agents.
// Building Expo DevTools Plugins with CLI Interfaces for interacting with running Expo apps using agents.
Building great Expo native modules for iOS and Android. Views, APIs, Marshalling, Shared Objects, Expo Documentation, Verifying Expo modules.
Seed and verify HealthKit data in running Expo apps using the apple-health CLI
Interact with iOS simulators and verify app behavior using xcobra
| name | expo-devtools-cli |
| description | Building Expo DevTools Plugins with CLI Interfaces for interacting with running Expo apps using agents. |
Build CLI tools that communicate with running Expo apps via the DevTools plugin system.
┌─────────────────┐ WebSocket ┌─────────────────┐
│ CLI Client │◄──────────────────►│ Expo Dev Server │
│ (Bun + Stricli)│ │ (Metro) │
└─────────────────┘ └────────┬────────┘
│
┌────────▼────────┐
│ React Native │
│ App + Hook │
└─────────────────┘
| Component | Technology | Why |
|---|---|---|
| Runtime | Bun | Fast startup, native TypeScript, built-in WebSocket |
| CLI Framework | @stricli/core | Type-safe, lazy loading, tree-shakeable |
| App Hook | expo/devtools | useDevToolsPluginClient for app-side connection |
| Protocol | JSON over WebSocket | Simple, debuggable with standard tools |
cli/
├── index.ts # Entry point with shebang
├── app.ts # Stricli app definition with routes
├── client.ts # WebSocket client for devtools
├── types.ts # Shared TypeScript types
├── formatters.ts # Output formatting (table, JSON)
└── commands/
├── query.ts # Read commands
├── write.ts # Write commands
└── status.ts # Status/health commands
src/devtools/
└── useMyPluginDevTools.ts # App-side message handler hook
Add devtools config to expo-module.config.json:
{
"name": "MyModule",
"platforms": ["ios", "android"],
"devtools": {
"name": "My Plugin",
"id": "my-plugin"
}
}
// src/devtools/useMyPluginDevTools.ts
import { useEffect } from "react";
import { useDevToolsPluginClient } from "expo/devtools";
interface PluginMessage {
id: string;
type: string;
payload: Record<string, unknown>;
}
export function useMyPluginDevTools() {
const client = useDevToolsPluginClient("my-plugin"); // Must match devtools.id
useEffect(() => {
if (!client) return;
const handleMessage = (data: PluginMessage) => {
const { id, type, payload } = data;
const sendResult = (result: unknown) => {
client.sendMessage("result", { id, type: "result", data: result });
};
const sendError = (error: Error) => {
client.sendMessage("error", {
id,
type: "error",
error: error.message,
});
};
(async () => {
try {
switch (type) {
case "getData":
const data = await fetchData(payload.query as string);
sendResult(data);
break;
default:
sendError(new Error(`Unknown message type: ${type}`));
}
} catch (error) {
sendError(error as Error);
}
})();
};
const subscription = client.addMessageListener(
"message",
(msg: unknown) => {
handleMessage(msg as PluginMessage);
}
);
return () => {
subscription?.remove?.();
};
}, [client]);
}
// cli/client.ts
const DEFAULT_PORT = 8081;
const REQUEST_TIMEOUT = 30000;
const PROTOCOL_VERSION = 1;
export class PluginClient {
private ws: WebSocket | null = null;
private pending = new Map<string, { resolve: Function; reject: Function }>();
private connected = false;
private browserClientId = Date.now().toString();
private pluginName = "my-plugin"; // Must match devtools.id
async connect(port = DEFAULT_PORT): Promise<void> {
if (this.connected) return;
return new Promise((resolve, reject) => {
// IMPORTANT: Use the broadcast endpoint
const url = `ws://localhost:${port}/expo-dev-plugins/broadcast`;
this.ws = new WebSocket(url);
const timeout = setTimeout(() => {
reject(new Error(`Connection timeout to ${url}`));
}, 10000);
this.ws.addEventListener("open", () => {
clearTimeout(timeout);
this.connected = true;
this.sendHandshake();
resolve();
});
this.ws.addEventListener("error", () => {
clearTimeout(timeout);
reject(new Error(`Failed to connect to Expo devtools at ${url}`));
});
this.ws.addEventListener("close", () => {
this.connected = false;
});
this.ws.addEventListener("message", (event) => {
this.handleMessage(event.data);
});
});
}
private sendHandshake(): void {
// CRITICAL: Must include all these fields
const handshake = {
protocolVersion: PROTOCOL_VERSION, // Must be 1
pluginName: this.pluginName,
method: "handshake",
browserClientId: this.browserClientId,
__isHandshakeMessages: true, // Required flag
};
this.ws?.send(JSON.stringify(handshake));
}
private handleMessage(data: string | ArrayBuffer): void {
if (typeof data === "string") {
try {
const parsed = JSON.parse(data);
if (parsed.__isHandshakeMessages) return; // Ignore handshake acks
if (parsed.messageKey) {
this.handlePackedMessage(parsed);
}
} catch {
// Not JSON, ignore
}
}
}
private handlePackedMessage(msg: { messageKey: any; payload: any }): void {
const { messageKey, payload } = msg;
if (messageKey.pluginName !== this.pluginName) return;
if (messageKey.method === "result" || messageKey.method === "error") {
const response = payload as {
id: string;
data?: unknown;
error?: string;
};
const pending = this.pending.get(response.id);
if (!pending) return;
this.pending.delete(response.id);
if (messageKey.method === "error" || response.error) {
pending.reject(new Error(response.error ?? "Unknown error"));
} else {
pending.resolve(response.data);
}
}
}
async send<T>(type: string, payload: unknown): Promise<T> {
if (!this.ws || !this.connected) {
throw new Error("Not connected to Expo devtools");
}
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
// CRITICAL: Send as JSON string, NOT binary ArrayBuffer
const msg = {
messageKey: { pluginName: this.pluginName, method: "message" },
payload: { id, type, payload },
};
this.ws!.send(JSON.stringify(msg));
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error("Request timeout"));
}
}, REQUEST_TIMEOUT);
});
}
async disconnect(): Promise<void> {
this.ws?.close();
this.ws = null;
this.connected = false;
}
}
// cli/index.ts
#!/usr/bin/env bun
import { run } from "@stricli/core";
import { app } from "./app";
await run(app, process.argv.slice(2), { process });
// cli/app.ts
import { buildApplication, buildRouteMap } from "@stricli/core";
const routes = buildRouteMap({
routes: {
status: () => import("./commands/status").then((m) => m.default),
query: () => import("./commands/query").then((m) => m.default),
},
});
export const app = buildApplication(routes, {
name: "my-cli",
versionInfo: { currentVersion: "1.0.0" },
});
{
"bin": {
"my-cli": "cli/index.ts"
},
"scripts": {
"cli": "bun cli/index.ts"
},
"dependencies": {
"@stricli/core": "^1.1.0"
}
}
Problem: Messages sent as ArrayBuffer are silently ignored.
// WRONG - Will not work
const encoder = new TextEncoder();
this.ws.send(encoder.encode(JSON.stringify(msg)).buffer);
// CORRECT - Send as JSON string
this.ws.send(JSON.stringify(msg));
Debugging: Use websocat to test the WebSocket:
websocat -v ws://localhost:8081/expo-dev-plugins/broadcast
Problem: Using /message or other endpoints won't work.
// WRONG
const url = `ws://localhost:${port}/message`;
// CORRECT - Must use broadcast endpoint
const url = `ws://localhost:${port}/expo-dev-plugins/broadcast`;
Debugging: Use curl to verify WebSocket upgrade:
curl -v -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: test" -H "Sec-WebSocket-Version: 13" \
http://localhost:8081/expo-dev-plugins/broadcast
Problem: Connection appears to work but messages aren't routed.
// WRONG - Missing required fields
const handshake = { pluginName: "my-plugin" };
// CORRECT - All fields required
const handshake = {
protocolVersion: 1, // Must be 1
pluginName: "my-plugin",
method: "handshake",
browserClientId: "unique-id",
__isHandshakeMessages: true, // Critical flag
};
Problem: terminateBrowserClient messages with warning about incompatible clients.
// WRONG
protocolVersion: 2;
// CORRECT - Use version 1
protocolVersion: 1;
Problem: Messages sent but never received by app.
The pluginName must match exactly across:
expo-module.config.json → devtools.iduseDevToolsPluginClient("my-plugin")this.pluginName = "my-plugin"Problem: Hook logs "connected" but messages timeout.
Check that useDevToolsPluginClient is imported from the correct package:
// CORRECT
import { useDevToolsPluginClient } from "expo/devtools";
// WRONG - different package
import { useDevToolsPluginClient } from "@expo/devtools-plugin-client";
Problem: App receives connection but not messages.
The addMessageListener method name must match the messageKey.method from CLI:
// CLI sends with method: "message"
const msg = {
messageKey: { pluginName: "my-plugin", method: "message" },
payload: { id, type, payload },
};
// App listens for "message"
client.addMessageListener("message", handler);
# Listen to all broadcasts
websocat --no-close -v ws://localhost:8081/expo-dev-plugins/broadcast
# Send test handshake
echo '{"protocolVersion":1,"pluginName":"my-plugin","method":"handshake","browserClientId":"test","__isHandshakeMessages":true}' | \
websocat ws://localhost:8081/expo-dev-plugins/broadcast
bunx xcobra expo console --json | grep -i "my-plugin\|devtools"
Add temporary logging to the hook:
useEffect(() => {
console.log("[DevTools] client:", client ? "connected" : "null");
if (!client) return;
console.log("[DevTools] Setting up listener");
// ...
}, [client]);
// Minimal test script
const ws = new WebSocket("ws://localhost:8081/expo-dev-plugins/broadcast");
ws.onopen = () => {
console.log("Connected");
ws.send(
JSON.stringify({
protocolVersion: 1,
pluginName: "my-plugin",
method: "handshake",
browserClientId: "test",
__isHandshakeMessages: true,
})
);
};
ws.onmessage = (e) => console.log("Received:", e.data);
yarn expo run:ios or have simulator running with Expo Gohttp://localhost:8081 respondsbun cli/index.ts statusSee the HealthKit CLI in this repo:
cli/ - Full CLI implementationsrc/dev-tools/useHealthKitDevTools.ts - App-side hookexample/App.tsx - Hook usage in app