sse_client.rs (8068B)
1 //! Server-Sent Events (SSE) client with auto-reconnection 2 //! 3 //! Handles real-time message subscriptions from the TrailBase API. 4 5 use crate::types::{SseEventWrapper, BASE_URL, MAX_RECONNECT_DELAY_MS}; 6 use futures::StreamExt; 7 use reqwest_eventsource::{Event, EventSource}; 8 use serde_json::Value; 9 use std::collections::HashSet; 10 use std::time::Duration; 11 use tokio::sync::mpsc; 12 13 const MAX_SEEN_IDS: usize = 100; 14 15 /// Commands that can be sent to the SSE task 16 #[derive(Debug)] 17 pub enum SseCommand { 18 /// Stop the SSE connection 19 Stop, 20 } 21 22 /// Events emitted by the SSE client 23 #[derive(Debug)] 24 pub enum SseEvent { 25 /// Successfully connected 26 Connected { chat_id: i32 }, 27 /// Received a message 28 Message { data: Value }, 29 /// Connection was disconnected 30 Disconnected { chat_id: i32, reason: String }, 31 /// An error occurred 32 Error { message: String }, 33 } 34 35 /// Parse an SSE event and extract the message data 36 fn parse_sse_message(data: &str, expected_chat_id: Option<i32>) -> Option<Value> { 37 let json: Value = serde_json::from_str(data).ok()?; 38 39 // TrailBase wraps data in "Insert" key 40 let wrapper: SseEventWrapper = serde_json::from_value(json.clone()).ok()?; 41 let message = wrapper.insert.or(Some(json))?; 42 43 // Validate chat_id if we're subscribed to a specific chat 44 if let Some(expected) = expected_chat_id { 45 let msg_chat_id = message.get("chat_id")?.as_i64()? as i32; 46 if msg_chat_id != expected { 47 tracing::debug!( 48 "Message for wrong chat: got {}, expected {}", 49 msg_chat_id, 50 expected 51 ); 52 return None; 53 } 54 } 55 56 // Validate message has an ID 57 if message.get("id").is_none() { 58 tracing::warn!("SSE: Message without valid ID"); 59 return None; 60 } 61 62 Some(message) 63 } 64 65 /// Start an SSE subscription for a single chat 66 pub async fn subscribe_to_chat( 67 auth_token: String, 68 chat_id: i32, 69 event_tx: mpsc::Sender<SseEvent>, 70 command_rx: mpsc::Receiver<SseCommand>, 71 ) { 72 let url = format!( 73 "{}/api/records/v1/message/subscribe/*?filter[chat_id][$eq]={}", 74 BASE_URL, chat_id 75 ); 76 77 run_sse_loop(url, auth_token, Some(chat_id), event_tx, command_rx).await; 78 } 79 80 /// Start an SSE subscription for multiple chats 81 pub async fn subscribe_to_chats( 82 auth_token: String, 83 chat_ids: Vec<i32>, 84 event_tx: mpsc::Sender<SseEvent>, 85 command_rx: mpsc::Receiver<SseCommand>, 86 ) { 87 if chat_ids.is_empty() { 88 return; 89 } 90 91 let chat_id_list = chat_ids 92 .iter() 93 .map(|id| id.to_string()) 94 .collect::<Vec<_>>() 95 .join(","); 96 97 let url = format!( 98 "{}/api/records/v1/message/subscribe/*?chat_id={}", 99 BASE_URL, chat_id_list 100 ); 101 102 // -1 indicates multi-chat subscription 103 run_sse_loop(url, auth_token, None, event_tx, command_rx).await; 104 } 105 106 /// Main SSE event loop with reconnection logic and deduplication 107 async fn run_sse_loop( 108 url: String, 109 auth_token: String, 110 expected_chat_id: Option<i32>, 111 event_tx: mpsc::Sender<SseEvent>, 112 mut command_rx: mpsc::Receiver<SseCommand>, 113 ) { 114 let chat_id_for_signals = expected_chat_id.unwrap_or(-1); 115 let mut reconnect_attempts = 0u32; 116 let mut seen_ids: HashSet<i64> = HashSet::new(); 117 118 loop { 119 // Check for stop command before connecting 120 if let Ok(SseCommand::Stop) = command_rx.try_recv() { 121 let _ = event_tx 122 .send(SseEvent::Disconnected { 123 chat_id: chat_id_for_signals, 124 reason: "User initiated".to_string(), 125 }) 126 .await; 127 return; 128 } 129 130 // Create EventSource with proper headers 131 let request = reqwest::Client::new() 132 .get(&url) 133 .header("Accept", "text/event-stream") 134 .header("Cache-Control", "no-cache") 135 .bearer_auth(&auth_token); 136 137 let mut es = EventSource::new(request).expect("Failed to create EventSource"); 138 139 // Notify connected 140 let _ = event_tx 141 .send(SseEvent::Connected { 142 chat_id: chat_id_for_signals, 143 }) 144 .await; 145 146 reconnect_attempts = 0; // Reset on successful connect 147 148 // Process events 149 loop { 150 tokio::select! { 151 // Check for stop command 152 cmd = command_rx.recv() => { 153 if matches!(cmd, Some(SseCommand::Stop) | None) { 154 es.close(); 155 let _ = event_tx.send(SseEvent::Disconnected { 156 chat_id: chat_id_for_signals, 157 reason: "User initiated".to_string(), 158 }).await; 159 return; 160 } 161 } 162 163 // Process SSE events 164 event = es.next() => { 165 match event { 166 Some(Ok(Event::Open)) => { 167 tracing::info!("SSE connection opened for chat {:?}", expected_chat_id); 168 } 169 Some(Ok(Event::Message(message))) => { 170 if let Some(data) = parse_sse_message(&message.data, expected_chat_id) { 171 // Deduplicate by message ID 172 if let Some(msg_id) = data.get("id").and_then(|v| v.as_i64()) { 173 if seen_ids.contains(&msg_id) { 174 continue; 175 } 176 seen_ids.insert(msg_id); 177 178 // Keep bounded 179 if seen_ids.len() > MAX_SEEN_IDS { 180 // Remove oldest entries (arbitrary since HashSet is unordered) 181 let to_remove: Vec<_> = seen_ids.iter().take(MAX_SEEN_IDS / 2).cloned().collect(); 182 for id in to_remove { 183 seen_ids.remove(&id); 184 } 185 } 186 } 187 188 let _ = event_tx.send(SseEvent::Message { data }).await; 189 } 190 } 191 Some(Err(e)) => { 192 let error_msg = e.to_string(); 193 tracing::warn!("SSE error: {}", error_msg); 194 let _ = event_tx.send(SseEvent::Error { message: error_msg }).await; 195 break; // Exit inner loop to reconnect 196 } 197 None => { 198 // Stream ended 199 tracing::info!("SSE stream ended"); 200 break; 201 } 202 } 203 } 204 } 205 } 206 207 // Connection lost - notify and schedule reconnect 208 let _ = event_tx 209 .send(SseEvent::Disconnected { 210 chat_id: chat_id_for_signals, 211 reason: "Connection closed by server".to_string(), 212 }) 213 .await; 214 215 // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s 216 let delay = std::cmp::min( 217 1000u64 * (1u64 << reconnect_attempts), 218 MAX_RECONNECT_DELAY_MS, 219 ); 220 reconnect_attempts += 1; 221 222 tracing::info!( 223 "Scheduling SSE reconnect in {}ms (attempt {})", 224 delay, 225 reconnect_attempts 226 ); 227 228 // Wait for delay or stop command 229 tokio::select! { 230 _ = tokio::time::sleep(Duration::from_millis(delay)) => { 231 // Continue to reconnect 232 } 233 cmd = command_rx.recv() => { 234 if matches!(cmd, Some(SseCommand::Stop) | None) { 235 return; 236 } 237 } 238 } 239 } 240 }