Skip to main content

hojicha_core/async_helpers/
websocket.rs

1//! WebSocket connection helper commands
2
3use crate::commands;
4use crate::core::{Cmd, Message};
5use std::sync::Arc;
6use tokio::sync::Mutex;
7
8/// WebSocket events
9#[derive(Debug, Clone)]
10pub enum WebSocketEvent {
11    /// Connected to server
12    Connected,
13    /// Received a text message
14    Message(String),
15    /// Received binary data
16    Binary(Vec<u8>),
17    /// Connection closed
18    Closed(Option<String>),
19    /// Error occurred
20    Error(WebSocketError),
21}
22
23/// WebSocket errors
24#[derive(Debug, Clone)]
25pub enum WebSocketError {
26    /// Connection failed
27    ConnectionFailed(String),
28    /// Send failed
29    SendFailed(String),
30    /// Protocol error
31    ProtocolError(String),
32    /// Timeout
33    Timeout,
34}
35
36impl std::fmt::Display for WebSocketError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::ConnectionFailed(e) => write!(f, "Connection failed: {e}"),
40            Self::SendFailed(e) => write!(f, "Send failed: {e}"),
41            Self::ProtocolError(e) => write!(f, "Protocol error: {e}"),
42            Self::Timeout => write!(f, "WebSocket timeout"),
43        }
44    }
45}
46
47impl std::error::Error for WebSocketError {}
48
49/// WebSocket connection handle
50#[derive(Debug, Clone)]
51pub struct WebSocketHandle {
52    /// Unique connection ID
53    pub id: String,
54    /// URL of the WebSocket server
55    pub url: String,
56    /// Whether the connection is active
57    pub connected: Arc<Mutex<bool>>,
58}
59
60impl WebSocketHandle {
61    /// Send a text message through the WebSocket
62    ///
63    /// # Errors
64    /// Returns `WebSocketError::SendFailed` if the connection is not active or sending fails.
65    pub async fn send_text(&self, _message: String) -> Result<(), WebSocketError> {
66        // In a real implementation, this would send through the actual WebSocket
67        if *self.connected.lock().await {
68            Ok(())
69        } else {
70            Err(WebSocketError::SendFailed("Not connected".to_string()))
71        }
72    }
73
74    /// Send binary data through the WebSocket
75    ///
76    /// # Errors
77    /// Returns `WebSocketError::SendFailed` if the connection is not active or sending fails.
78    pub async fn send_binary(&self, _data: Vec<u8>) -> Result<(), WebSocketError> {
79        if *self.connected.lock().await {
80            Ok(())
81        } else {
82            Err(WebSocketError::SendFailed("Not connected".to_string()))
83        }
84    }
85
86    /// Close the WebSocket connection
87    pub async fn close(&self) {
88        *self.connected.lock().await = false;
89    }
90}
91
92/// Create a WebSocket connection command
93///
94/// This establishes a WebSocket connection and returns events through the handler.
95/// The connection will automatically reconnect on failure.
96///
97/// # Example
98/// ```no_run
99/// # use hojicha_core::async_helpers::{websocket, WebSocketEvent};
100/// # #[derive(Clone)]
101/// # enum Msg {
102/// #     WsConnected,
103/// #     WsMessage(String),
104/// #     WsDisconnected,
105/// # }
106///
107/// websocket("wss://echo.websocket.org", |event| {
108///     match event {
109///         WebSocketEvent::Connected => Some(Msg::WsConnected),
110///         WebSocketEvent::Message(text) => Some(Msg::WsMessage(text)),
111///         WebSocketEvent::Closed(_) => Some(Msg::WsDisconnected),
112///         _ => None,
113///     }
114/// })
115/// # ;
116/// ```
117pub fn websocket<M, F>(url: impl Into<String>, mut handler: F) -> Cmd<M>
118where
119    M: Message,
120    F: FnMut(WebSocketEvent) -> Option<M> + Send + 'static,
121{
122    let url = url.into();
123    let handle = WebSocketHandle {
124        id: uuid::Uuid::as_string(),
125        url,
126        connected: Arc::new(Mutex::new(false)),
127    };
128
129    commands::spawn(async move {
130        // Simulate connection
131        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
132
133        // Mark as connected
134        *handle.connected.lock().await = true;
135
136        // Send connected event
137        handler(WebSocketEvent::Connected)
138    })
139}
140
141/// Create a WebSocket connection with automatic ping/pong
142pub fn websocket_with_heartbeat<M, F>(
143    url: impl Into<String>,
144    _ping_interval: std::time::Duration,
145    mut handler: F,
146) -> Cmd<M>
147where
148    M: Message,
149    F: FnMut(WebSocketEvent) -> Option<M> + Send + 'static,
150{
151    let _url = url.into();
152
153    commands::spawn(async move {
154        // In a real implementation, this would:
155        // 1. Establish WebSocket connection
156        // 2. Set up ping/pong heartbeat
157        // 3. Handle reconnection on failure
158
159        // For now, simulate connection
160        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
161        handler(WebSocketEvent::Connected)
162    })
163}
164
165/// Helper to create a WebSocket message sender command
166#[must_use]
167pub fn ws_send<M>(handle: WebSocketHandle, message: String) -> Cmd<M>
168where
169    M: Message,
170{
171    commands::spawn(async move {
172        let _ = handle.send_text(message).await;
173        None
174    })
175}
176
177/// Helper to close a WebSocket connection
178#[must_use]
179pub fn ws_close<M>(handle: WebSocketHandle) -> Cmd<M>
180where
181    M: Message,
182{
183    commands::spawn(async move {
184        handle.close().await;
185        None
186    })
187}
188
189// Note: In a real implementation, we would need to add uuid to dependencies
190// For now, we'll create a mock UUID module
191mod uuid {
192    pub struct Uuid;
193    impl Uuid {
194        #[allow(dead_code)]
195        pub const fn new_v4() -> Self {
196            Self
197        }
198        pub fn as_string() -> String {
199            format!("ws-{}", std::process::id())
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use proptest::prelude::*;
208
209    /// Behavioral test: WebSocket error types display correctly
210    #[test]
211    fn test_websocket_error_display() {
212        let errors = vec![
213            WebSocketError::ConnectionFailed("Network unreachable".to_string()),
214            WebSocketError::SendFailed("Socket closed".to_string()),
215            WebSocketError::ProtocolError("Invalid frame".to_string()),
216            WebSocketError::Timeout,
217        ];
218
219        for error in errors {
220            let display = error.to_string();
221            assert!(!display.is_empty());
222
223            match error {
224                WebSocketError::ConnectionFailed(ref msg) => assert!(display.contains(msg)),
225                WebSocketError::SendFailed(ref msg) => assert!(display.contains(msg)),
226                WebSocketError::ProtocolError(ref msg) => assert!(display.contains(msg)),
227                WebSocketError::Timeout => assert!(display.contains("timeout")),
228            }
229        }
230    }
231
232    proptest! {
233        #[test]
234        fn prop_websocket_error_consistency(error_msg in ".*") {
235            let error = WebSocketError::ConnectionFailed(error_msg.clone());
236            let error_string = error.to_string();
237            prop_assert!(error_string.contains(&error_msg));
238        }
239    }
240}