# SSE Streaming Architecture rdev uses Server-Sent Events (SSE) for real-time command output streaming. ## Overview ``` ┌────────────┐ ┌────────────┐ ┌────────────┐ │ Client │ │ rdev API │ │ K8s Pod │ │ │ │ │ │ │ │ 1. POST │─────────────────▶│ Start │─────────────────▶│ Execute │ │ /claude │ │ Command │ │ Command │ │ │◀─────────────────│ │ │ │ │ response: │ {id, stream_url}│ │ │ │ │ │ │ │ │ │ │ 2. GET │─────────────────▶│ SSE │◀─────────────────│ Output │ │ /events │◀─────────────────│ Stream │◀─────────────────│ Lines │ │ │ event: output │ │ │ │ │ │ event: output │ │ │ │ │ │ event: complete │ │◀─────────────────│ Exit │ └────────────┘ └────────────┘ └────────────┘ ``` ## SSE Protocol ### Event Format ``` id: evt-001 event: output data: {"line": "Hello, world!", "stream": "stdout"} id: evt-002 event: output data: {"line": "Processing...", "stream": "stdout"} id: evt-003 event: complete data: {"exit_code": 0, "duration_ms": 1234} ``` ### Event Types | Event | Description | Data | |-------|-------------|------| | `connected` | Stream established | `{project, stream_id, reconnecting}` | | `output` | Command output line | `{line, stream}` | | `complete` | Command finished | `{exit_code, duration_ms}` | | `heartbeat` | Keep-alive signal | `{timestamp}` | | `error` | Error occurred | `{message}` | ### Output Streams - `stdout` - Standard output - `stderr` - Standard error ## Reconnection Support ### Last-Event-ID Clients can reconnect and resume from where they left off: ``` GET /projects/test/events?stream_id=cmd-001 Last-Event-ID: evt-002 ``` The server replays all events after `evt-002`. ### Implementation ```go type StreamManager struct { streams map[string]*Stream mu sync.RWMutex } type Stream struct { events []StreamEvent listeners []chan StreamEvent mu sync.RWMutex } func (sm *StreamManager) SubscribeFromID(streamID, lastEventID string) (<-chan StreamEvent, func()) { sm.mu.RLock() stream := sm.streams[streamID] sm.mu.RUnlock() ch := make(chan StreamEvent, 100) // Replay events after lastEventID stream.mu.RLock() foundLast := false for _, event := range stream.events { if event.ID == lastEventID { foundLast = true continue } if foundLast { ch <- event } } stream.mu.RUnlock() // Subscribe for new events stream.addListener(ch) return ch, func() { stream.removeListener(ch) } } ``` ### Client Handling ```javascript // JavaScript SSE client with reconnection function connectSSE(url) { const eventSource = new EventSource(url); eventSource.onopen = () => { console.log('Connected'); }; eventSource.addEventListener('output', (e) => { const data = JSON.parse(e.data); console.log(data.stream + ':', data.line); }); eventSource.addEventListener('complete', (e) => { const data = JSON.parse(e.data); console.log('Exit code:', data.exit_code); eventSource.close(); }); eventSource.onerror = (e) => { console.log('Connection error, will auto-reconnect'); // Browser automatically reconnects with Last-Event-ID }; return eventSource; } ``` ## Stream Lifecycle ``` ┌─────────────────────────────────────────────────────────┐ │ Command Started │ └────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Stream Created │ │ (StreamManager) │ └────────────────────────┬────────────────────────────────┘ │ ┌──────────────┴──────────────┐ │ │ ▼ ▼ ┌───────────────────┐ ┌───────────────────┐ │ Client Subscribe │ │ Output Events │ │ (SSE Connection) │◀────────│ (from executor) │ └───────────────────┘ └───────────────────┘ │ │ │ │ └──────────────┬──────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Complete Event │ │ (exit_code) │ └────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Stream Cleanup │ │ (after 30s grace period) │ └─────────────────────────────────────────────────────────┘ ``` ## Handler Implementation ```go func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { streamID := r.URL.Query().Get("stream_id") lastEventID := r.Header.Get("Last-Event-ID") // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") flusher := w.(http.Flusher) // Subscribe with reconnection support var events <-chan StreamEvent if lastEventID != "" { events, cleanup = h.streams.SubscribeFromID(streamID, lastEventID) } else { events, cleanup = h.streams.Subscribe(streamID) } defer cleanup() // Send connected event writeSSE(w, flusher, "connected", map[string]any{ "stream_id": streamID, "reconnecting": lastEventID != "", }) // Heartbeat ticker heartbeat := time.NewTicker(30 * time.Second) defer heartbeat.Stop() // Event loop for { select { case <-r.Context().Done(): return case event, ok := <-events: if !ok { return } writeSSEWithID(w, flusher, event.ID, event.Type, event.Data) if event.Type == "complete" { return } case <-heartbeat.C: writeSSE(w, flusher, "heartbeat", map[string]any{ "timestamp": time.Now().UTC().Format(time.RFC3339), }) } } } func writeSSEWithID(w http.ResponseWriter, flusher http.Flusher, id, event string, data map[string]any) { dataBytes, _ := json.Marshal(data) if id != "" { fmt.Fprintf(w, "id: %s\n", id) } fmt.Fprintf(w, "event: %s\n", event) fmt.Fprintf(w, "data: %s\n\n", dataBytes) flusher.Flush() } ``` ## Performance Considerations ### Buffer Sizing ```go // 100-event buffer to handle bursts ch := make(chan StreamEvent, 100) ``` ### Heartbeats 30-second heartbeats prevent: - Proxy timeouts - Connection drops from inactive connections - Client uncertainty about connection state ### Cleanup Streams are cleaned up 30 seconds after completion: - Allows time for reconnections - Prevents memory leaks - Enables late-arriving clients to see final state ### Fanout Multiple clients can subscribe to the same stream: ```go func (sm *StreamManager) Send(streamID, eventType string, data map[string]any) { sm.mu.RLock() stream := sm.streams[streamID] sm.mu.RUnlock() event := StreamEvent{ ID: generateEventID(), Type: eventType, Data: data, } // Store for replay stream.addEvent(event) // Fanout to all listeners stream.mu.RLock() for _, ch := range stream.listeners { select { case ch <- event: default: // Channel full, skip (client too slow) } } stream.mu.RUnlock() } ``` ## Error Handling ### Connection Errors SSE automatically reconnects on error. The browser: 1. Closes failed connection 2. Waits 3 seconds (configurable) 3. Reconnects with `Last-Event-ID` ### Slow Clients If a client can't keep up: 1. Events are dropped (non-blocking send) 2. Client eventually catches up via replay on reconnect ### Stream Not Found If stream doesn't exist (expired or never created): ``` event: error data: {"message": "stream not found"} ```