web_server.design.md
This document describes the implementation of Opcode's web server mode, which allows access to Claude Code from mobile devices and browsers while maintaining full functionality.
The web server provides a REST API and WebSocket interface that mirrors the Tauri desktop app's functionality, enabling phone/browser access to Claude Code sessions.
┌─────────────────┐ WebSocket ┌─────────────────┐ Process ┌─────────────────┐
│ Browser UI │ ←──────────────→ │ Rust Backend │ ────────────→ │ Claude Binary │
│ │ REST API │ (Axum Server) │ │ │
│ • React/TS │ ←──────────────→ │ │ │ • claude-code │
│ • WebSocket │ │ • Session Mgmt │ │ • Subprocess │
│ • DOM Events │ │ • Process Spawn │ │ • Stream Output │
└─────────────────┘ └─────────────────┘ └─────────────────┘
src-tauri/src/web_server.rs)Main Functions:
create_web_server() - Sets up Axum server with routesclaude_websocket_handler() - Manages WebSocket connectionsexecute_claude_command() / continue_claude_command() / resume_claude_command() - Execute Claude processesfind_claude_binary_web() - Locates Claude binary (bundled or system)Key Features:
src/components/ClaudeCodeSession.tsx)Dual Mode Support:
const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => {
// Web mode: Use DOM events
const domEventHandler = (event: any) => {
callback({ payload: event.detail });
};
window.addEventListener(eventName, domEventHandler);
return Promise.resolve(() => window.removeEventListener(eventName, domEventHandler));
});
Message Processing:
src/lib/apiAdapter.ts)Request Format:
{
"command_type": "execute|continue|resume",
"project_path": "/path/to/project",
"prompt": "user prompt",
"model": "sonnet|opus",
"session_id": "uuid-for-resume"
}
Response Format:
{
"type": "start|output|completion|error",
"content": "parsed Claude message",
"message": "status message",
"status": "success|error"
}
Browser → WebSocket Request → Rust Backend → Claude Process
Claude Process → Rust Backend → WebSocket → Browser DOM Events → UI Update
/ws/claudeclaude subprocessclaude-outputopcode/
├── src-tauri/src/
│ └── web_server.rs # Main web server implementation
├── src/
│ ├── lib/
│ │ └── apiAdapter.ts # WebSocket client & environment detection
│ └── components/
│ ├── ClaudeCodeSession.tsx # Main session component
│ └── claude-code-session/
│ └── useClaudeMessages.ts # Alternative hook implementation
└── justfile # Build configuration (just web)
nix-shell --run 'just web'
# Builds frontend and starts Rust server on port 8080
[TRACE] WebSocket handler started - session_id: uuid
[TRACE] Successfully parsed request: {...}
[TRACE] Claude process spawned successfully
[TRACE] Forwarding message to WebSocket: {...}
[TRACE] DOM event received: claude-output {...}
[TRACE] handleStreamMessage - message type: assistant
Problem: Original code only worked with Tauri events
Solution: Enhanced listen function to support DOM events in web mode
Problem: Backend sent JSON strings, frontend expected parsed objects
Solution: Parse content field in WebSocket handler before dispatching events
Problem: Web mode lacked Claude binary execution Solution: Full subprocess spawning with proper argument passing and output streaming
Problem: No state tracking for multiple concurrent sessions Solution: HashMap-based session tracking with proper cleanup
Problem: Frontend expected cancel and output endpoints that didn't exist
Solution: Added /api/sessions/{sessionId}/cancel and /api/sessions/{sessionId}/output endpoints
Problem: WebSocket errors and unexpected closures didn't dispatch UI events
Solution: Added claude-error and claude-complete event dispatching for all error scenarios
Problem: The UI expects session-specific events like claude-output:${sessionId} but the backend only dispatches generic events like claude-output.
Current Backend Behavior:
// Only dispatches generic events
window.dispatchEvent(new CustomEvent('claude-output', { detail: claudeMessage }));
window.dispatchEvent(new CustomEvent('claude-complete', { detail: success }));
window.dispatchEvent(new CustomEvent('claude-error', { detail: error }));
Frontend Expectations:
// Expects session-scoped events
await listen(`claude-output:${sessionId}`, handleOutput);
await listen(`claude-error:${sessionId}`, handleError);
await listen(`claude-complete:${sessionId}`, handleComplete);
Impact: Session isolation doesn't work - all sessions receive all events.
Problem: The cancel endpoint is just a stub that doesn't actually terminate running Claude processes.
Current Implementation:
async fn cancel_claude_execution(Path(sessionId): Path<String>) -> Json<ApiResponse<()>> {
// Just logs - doesn't actually cancel anything
println!("[TRACE] Cancel request for session: {}", sessionId);
Json(ApiResponse::success(()))
}
Missing:
kill() or process handlesProblem: Claude processes can write errors to stderr, but the web server only captures stdout.
Current: Only child.stdout is captured and streamed
Missing: child.stderr capture and claude-error event emission
Problem: The Tauri implementation emits claude-cancelled events but the web server doesn't.
Tauri Implementation:
let _ = app.emit(&format!("claude-cancelled:{}", sid), true);
let _ = app.emit("claude-cancelled", true);
Web Server: No claude-cancelled events are dispatched.
Problem: The web server generates its own session IDs but doesn't map them to the frontend's session IDs.
Current: WebSocket handler creates uuid::Uuid::new_v4().to_string() but frontend passes sessionId in request.
Missing: Proper session ID mapping and tracking.
Session-Scoped Event Dispatching
apiAdapter.ts to dispatch both generic and session-specific eventsclaude-output:${sessionId} are dispatched correctlyProcess Management and Cancellation
cancel_claude_executionstderr Handling
claude-error events for stderr contentclaude-cancelled Events
claude-cancelled event dispatching for consistency with TauriThe web server should dispatch both generic and session-specific events to match Tauri:
// Both events should be dispatched
window.dispatchEvent(new CustomEvent('claude-output', { detail: claudeMessage }));
window.dispatchEvent(new CustomEvent(`claude-output:${sessionId}`, { detail: claudeMessage }));
The AppState should track process handles:
pub struct AppState {
pub active_sessions: Arc<Mutex<HashMap<String, tokio::sync::mpsc::Sender<String>>>>,
pub active_processes: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
}
--dangerously-skip-permissions flag for web modenix-shell --run 'just web'http://localhost:8080# Check Claude binary
which claude
# Test WebSocket endpoint
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: test" -H "Sec-WebSocket-Version: 13" \
http://localhost:8080/ws/claude
# Monitor server logs
tail -f server.log # if logging to file
The web server implementation provides basic functionality but has critical issues that prevent full feature parity with the Tauri desktop app:
The web server is functional for single-session use but not suitable for production due to the session isolation issues. Multiple concurrent sessions will interfere with each other, and users cannot cancel running processes.
This implementation successfully bridges the gap between Tauri desktop and web deployment, but requires the above fixes to achieve full feature parity while adapting to browser constraints.