qt-chat-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

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 }