expo-devtools-cli

Building Expo DevTools Plugins with CLI Interfaces

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "expo-devtools-cli" with this command: npx skills add evanbacon/apple-health/evanbacon-apple-health-expo-devtools-cli

Building Expo DevTools Plugins with CLI Interfaces

Build CLI tools that communicate with running Expo apps via the DevTools plugin system.

Architecture Overview

┌─────────────────┐ WebSocket ┌─────────────────┐ │ CLI Client │◄──────────────────►│ Expo Dev Server │ │ (Bun + Stricli)│ │ (Metro) │ └─────────────────┘ └────────┬────────┘ │ ┌────────▼────────┐ │ React Native │ │ App + Hook │ └─────────────────┘

Preferred Tech Stack

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

Project Structure

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

Step 1: Configure the Module

Add devtools config to expo-module.config.json :

{ "name": "MyModule", "platforms": ["ios", "android"], "devtools": { "name": "My Plugin", "id": "my-plugin" } }

Step 2: Create the App-Side Hook

// 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]); }

Step 3: Create the CLI 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; } }

Step 4: Create the CLI Entry Point

// 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" }, });

Step 5: Configure package.json

{ "bin": { "my-cli": "cli/index.ts" }, "scripts": { "cli": "bun cli/index.ts" }, "dependencies": { "@stricli/core": "^1.1.0" } }

Footguns and Solutions

  1. Binary vs JSON Messages

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

  1. Wrong WebSocket Endpoint

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

  1. Missing Handshake Fields

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 };

  1. Protocol Version Mismatch

Problem: terminateBrowserClient messages with warning about incompatible clients.

// WRONG protocolVersion: 2;

// CORRECT - Use version 1 protocolVersion: 1;

  1. Plugin Name Mismatch

Problem: Messages sent but never received by app.

The pluginName must match exactly across:

  • expo-module.config.json → devtools.id

  • App hook → useDevToolsPluginClient("my-plugin")

  • CLI client → this.pluginName = "my-plugin"

  1. Hook Not Setting Up Listener

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";

  1. Message Listener Method Name

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);

Debugging Techniques

  1. Monitor WebSocket Traffic

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

  1. Check App Console Logs

bunx xcobra expo console --json | grep -i "my-plugin|devtools"

  1. Verify Hook is Running

Add temporary logging to the hook:

useEffect(() => { console.log("[DevTools] client:", client ? "connected" : "null"); if (!client) return; console.log("[DevTools] Setting up listener"); // ... }, [client]);

  1. Test Connection Independently

// 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);

Testing Workflow

  • Start the app: yarn expo run:ios or have simulator running with Expo Go

  • Verify Metro is running: Check http://localhost:8081 responds

  • Test CLI connection: bun cli/index.ts status

  • Check for errors: Monitor both CLI output and app console

Reference Implementation

See the HealthKit CLI in this repo:

  • cli/

  • Full CLI implementation

  • src/dev-tools/useHealthKitDevTools.ts

  • App-side hook

  • example/App.tsx

  • Hook usage in app

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

healthkit-cli

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

device-testing

No summary provided by upstream source.

Repository SourceNeeds Review
General

expo-modules

No summary provided by upstream source.

Repository SourceNeeds Review