hojicha_core/async_helpers/
websocket.rs1use crate::commands;
4use crate::core::{Cmd, Message};
5use std::sync::Arc;
6use tokio::sync::Mutex;
7
8#[derive(Debug, Clone)]
10pub enum WebSocketEvent {
11 Connected,
13 Message(String),
15 Binary(Vec<u8>),
17 Closed(Option<String>),
19 Error(WebSocketError),
21}
22
23#[derive(Debug, Clone)]
25pub enum WebSocketError {
26 ConnectionFailed(String),
28 SendFailed(String),
30 ProtocolError(String),
32 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#[derive(Debug, Clone)]
51pub struct WebSocketHandle {
52 pub id: String,
54 pub url: String,
56 pub connected: Arc<Mutex<bool>>,
58}
59
60impl WebSocketHandle {
61 pub async fn send_text(&self, _message: String) -> Result<(), WebSocketError> {
66 if *self.connected.lock().await {
68 Ok(())
69 } else {
70 Err(WebSocketError::SendFailed("Not connected".to_string()))
71 }
72 }
73
74 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 pub async fn close(&self) {
88 *self.connected.lock().await = false;
89 }
90}
91
92pub 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 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
132
133 *handle.connected.lock().await = true;
135
136 handler(WebSocketEvent::Connected)
138 })
139}
140
141pub 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 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
161 handler(WebSocketEvent::Connected)
162 })
163}
164
165#[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#[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
189mod 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 #[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}