/** * WSServer — Lightweight WebSocket server running on the React Native device. * * Since we're building a JS-only library (no native modules), we implement * a simple WebSocket server using React Native's built-in networking. * The server accepts connections from CLI clients and routes messages * to the CommandHandler. * * Implementation: We use a polyfill-based approach where the RN app acts * as a WebSocket server via a TCP server. For the v1 JS-only approach, * we'll leverage the Metro dev server's message channel or a simple * HTTP polling fallback that works without native TCP sockets. * * This module provides a transport-agnostic server interface. */ import type { Command, CommandResponse, AgentKitConfig } from './types'; import { CommandHandler } from './CommandHandler'; import { ElementRegistry } from './ElementRegistry'; import { logDebug, logInfo, logWarn } from './logger'; type ConnectionId = string; interface PendingRequest { resolve: (response: string) => void; timestamp: number; } export class BridgeServer { private config: AgentKitConfig; private commandHandler: CommandHandler; private isRunning = false; private pollingClients: Map< ConnectionId, { lastActivity: number; pendingResponses: string[] } > = new Map(); private pollTimeout: ReturnType | null = null; // HTTP-like message passing via global function injection // This allows CLI tools to communicate via adb/Metro/Expo dev tools private pendingRequests: Map = new Map(); constructor(config: AgentKitConfig) { this.config = config; this.commandHandler = new CommandHandler(config); } /** * Start the bridge server. * Registers global message handlers that CLI tools can invoke. */ start(): void { if (this.isRunning) { logWarn('Server is already running'); return; } this.isRunning = true; // Register global message handler for dev tools communication this.registerGlobalHandler(); // Start client cleanup interval this.pollTimeout = setInterval(() => this.cleanupStaleClients(), 30000); // Listen for screen changes to broadcast to clients ElementRegistry.addListener((screenState) => { this.broadcast({ success: true, command: 'state', result: { type: 'screenChange', screenState }, timestamp: Date.now(), }); }); logInfo(`Bridge server started on port ${this.config.port}`); logInfo( 'Clients can connect via: global.__AGENTKIT_SEND__(JSON.stringify(command))' ); } /** * Stop the bridge server. */ stop(): void { if (!this.isRunning) return; this.isRunning = false; this.unregisterGlobalHandler(); if (this.pollTimeout) { clearInterval(this.pollTimeout); this.pollTimeout = null; } this.pollingClients.clear(); this.pendingRequests.clear(); logInfo('Bridge server stopped'); } /** * Process a raw JSON message string and return response JSON. */ async processMessage(messageJson: string): Promise { try { const command: Command = JSON.parse(messageJson); logDebug('Received command:', command.cmd, command.target || ''); const response = await this.commandHandler.handle(command); logDebug('Response:', response.success ? 'OK' : 'FAIL'); return JSON.stringify(response); } catch (e: any) { const errorResponse: CommandResponse = { success: false, command: 'ping', result: {}, error: `Invalid message: ${e.message}`, timestamp: Date.now(), }; return JSON.stringify(errorResponse); } } /** * Broadcast a response to all connected clients. */ private broadcast(response: CommandResponse): void { const json = JSON.stringify(response); for (const [, client] of this.pollingClients) { client.pendingResponses.push(json); } } /** * Register global functions that external tools can call to communicate. * * This approach works across all RN environments: * - Expo: via Expo Dev Tools custom messages * - Bare RN: via Metro dev server / debugging protocol * - Release builds: via native bridge injection (future) * * The global functions: * - __AGENTKIT_SEND__(json): Send a command, returns response promise * - __AGENTKIT_STATUS__(): Check if bridge is running * - __AGENTKIT_PORT__: Port number for reference */ private registerGlobalHandler(): void { const g = globalThis as any; // Synchronous send — processes command and returns response JSON g.__AGENTKIT_SEND__ = async (messageJson: string): Promise => { if (!this.isRunning) { return JSON.stringify({ success: false, command: 'ping', result: {}, error: 'Bridge server is not running', timestamp: Date.now(), }); } return this.processMessage(messageJson); }; // Status check g.__AGENTKIT_STATUS__ = () => ({ running: this.isRunning, port: this.config.port, elements: ElementRegistry.size, version: '0.1.0', }); // Port reference g.__AGENTKIT_PORT__ = this.config.port; // Also register as a React Native DevSettings menu item if in dev mode if (this.config.debug && g.__DEV__) { logInfo('AgentKit is active — use the CLI tool to send commands'); } } private unregisterGlobalHandler(): void { const g = globalThis as any; delete g.__AGENTKIT_SEND__; delete g.__AGENTKIT_STATUS__; delete g.__AGENTKIT_PORT__; } private cleanupStaleClients(): void { const now = Date.now(); const staleThreshold = 60000; // 1 minute for (const [id, client] of this.pollingClients) { if (now - client.lastActivity > staleThreshold) { this.pollingClients.delete(id); logDebug(`Cleaned up stale client: ${id}`); } } } get running(): boolean { return this.isRunning; } }