Skip to main content

socketeer/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3mod codec;
4mod config;
5mod error;
6mod handler;
7#[cfg(feature = "mocking")]
8mod mock_server;
9mod socket_loop;
10mod split;
11
12pub use bytes::Bytes;
13#[cfg(feature = "msgpack")]
14pub use codec::MsgPackCodec;
15pub use codec::{Codec, JsonCodec, RawCodec};
16pub use config::{ConnectOptions, ConnectOptionsBuilder};
17pub use error::Error;
18pub use handler::{ConnectionHandler, HandshakeContext, NoopHandler};
19#[cfg(all(feature = "mocking", feature = "msgpack"))]
20pub use mock_server::msgpack_echo_server;
21#[cfg(feature = "mocking")]
22pub use mock_server::{EchoControlMessage, auth_echo_server, echo_server, get_mock_address};
23pub use split::{ReuniteError, SocketeerRx, SocketeerTx};
24pub use tokio_tungstenite::tungstenite::{self, Message, http};
25
26/// The concrete `WebSocketStream` type the mock-server handlers operate on.
27/// Re-exported so downstream code can write custom servers for
28/// [`get_mock_address`]. Primarily useful with the `mocking` feature, which
29/// gates `get_mock_address` and the built-in test servers.
30pub use socket_loop::WebSocketStreamType;
31use socket_loop::{
32    TerminalError, TxChannelPayload, poll_recv_raw, recv_raw, send_close, send_confirmed,
33    socket_loop_split,
34};
35
36use std::pin::Pin;
37use std::sync::{Arc, Mutex, PoisonError};
38use std::task::{Context, Poll};
39
40use futures::{Stream, StreamExt};
41use tokio::sync::mpsc;
42use tokio_tungstenite::connect_async;
43
44#[cfg(feature = "tracing")]
45use tracing::{debug, info, instrument};
46use url::Url;
47
48/// A WebSocket client that manages the connection to a WebSocket server.
49/// The client can send and receive messages, and will transparently handle protocol messages.
50///
51/// # Type Parameters
52///
53/// - `C`: A [`Codec`] that defines the connection's `Tx` and `Rx` types and how
54///   they map to WebSocket frames. Use [`JsonCodec`] for the common case,
55///   [`MsgPackCodec`] (behind the `msgpack` feature) for `MessagePack`, or
56///   [`RawCodec`] for direct [`Message`] access.
57/// - `Handler`: A [`ConnectionHandler`] for lifecycle hooks (auth, subscriptions).
58///   Defaults to [`NoopHandler`] for the simple case.
59/// - `CHANNEL_SIZE`: Capacity of the internal mpsc channels between the
60///   connection task and the handle. Defaults to 256. A larger buffer absorbs
61///   transient consumer slowdowns so backpressure (which holds one frame and
62///   relies on keepalives to stay alive) engages only under sustained overload;
63///   tune it for your feed's burstiness.
64pub struct Socketeer<C: Codec, Handler = NoopHandler, const CHANNEL_SIZE: usize = 256>
65where
66    Handler: ConnectionHandler<C>,
67{
68    url: Url,
69    options: ConnectOptions,
70    codec: C,
71    handler: Handler,
72    receiver: mpsc::Receiver<Message>,
73    sender: mpsc::Sender<TxChannelPayload>,
74    socket_handle: tokio::task::JoinHandle<()>,
75    terminal_error: TerminalError,
76}
77
78impl<C: Codec, Handler, const CHANNEL_SIZE: usize> std::fmt::Debug
79    for Socketeer<C, Handler, CHANNEL_SIZE>
80where
81    Handler: ConnectionHandler<C>,
82{
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("Socketeer")
85            .field("url", &self.url)
86            .finish_non_exhaustive()
87    }
88}
89
90impl<C: Codec + Unpin, Handler, const CHANNEL_SIZE: usize> Stream
91    for Socketeer<C, Handler, CHANNEL_SIZE>
92where
93    Handler: ConnectionHandler<C> + Unpin,
94{
95    type Item = Result<C::Rx, Error>;
96
97    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
98        let this = self.get_mut();
99        poll_recv_raw(&mut this.receiver, &this.terminal_error, cx)
100            .map(|frame| frame.map(|result| result.and_then(|message| this.codec.decode(&message))))
101    }
102}
103
104impl<C, const CHANNEL_SIZE: usize> Socketeer<C, NoopHandler, CHANNEL_SIZE>
105where
106    C: Codec + Default,
107{
108    /// Create a `Socketeer` connected to the provided URL with default options.
109    /// Once connected, Socketeer manages the underlying WebSocket connection, transparently handling protocol messages.
110    /// # Errors
111    /// - If the URL cannot be parsed
112    /// - If the WebSocket connection to the requested URL fails
113    #[cfg_attr(feature = "tracing", instrument)]
114    pub async fn connect(url: &str) -> Result<Self, Error> {
115        Self::connect_with(url, ConnectOptions::default()).await
116    }
117
118    /// Create a `Socketeer` connected to the provided URL with custom connection options.
119    /// # Errors
120    /// - If the URL cannot be parsed
121    /// - If the WebSocket connection to the requested URL fails
122    #[cfg_attr(feature = "tracing", instrument(skip(options)))]
123    pub async fn connect_with(url: &str, options: ConnectOptions) -> Result<Self, Error> {
124        Socketeer::connect_with_codec(url, options, C::default(), NoopHandler).await
125    }
126}
127
128impl<C, Handler, const CHANNEL_SIZE: usize> Socketeer<C, Handler, CHANNEL_SIZE>
129where
130    C: Codec,
131    Handler: ConnectionHandler<C>,
132{
133    /// Create a `Socketeer` with an explicit codec and [`ConnectionHandler`].
134    ///
135    /// The handler's [`ConnectionHandler::on_connected`] method is called after the
136    /// WebSocket upgrade completes, before the socket loop starts. This is where
137    /// you should perform authentication handshakes and initial subscriptions.
138    /// # Errors
139    /// - If the URL cannot be parsed
140    /// - If the WebSocket connection to the requested URL fails
141    /// - If the handler's `on_connected` returns an error
142    #[cfg_attr(feature = "tracing", instrument(skip(options, codec, handler)))]
143    pub async fn connect_with_codec(
144        url: &str,
145        options: ConnectOptions,
146        codec: C,
147        mut handler: Handler,
148    ) -> Result<Self, Error> {
149        let url = Url::parse(url).map_err(|source| Error::UrlParse {
150            url: url.to_string(),
151            source,
152        })?;
153
154        let request = options.build_request(&url)?;
155        #[allow(unused_variables)]
156        let (socket, response) = connect_async(request).await?;
157        #[cfg(feature = "tracing")]
158        debug!("Connection Successful, connection info: \n{:#?}", response);
159
160        let (mut sink, mut stream) = socket.split();
161        {
162            let mut ctx = HandshakeContext::new(&mut sink, &mut stream, &codec);
163            handler.on_connected(&mut ctx).await?;
164        }
165
166        let keepalive_interval = options.keepalive_interval;
167        let keepalive_message = options.custom_keepalive_message.clone();
168
169        let (tx_tx, tx_rx) = mpsc::channel::<TxChannelPayload>(CHANNEL_SIZE);
170        let (rx_tx, rx_rx) = mpsc::channel::<Message>(CHANNEL_SIZE);
171
172        let terminal_error: TerminalError = Arc::new(Mutex::new(None));
173        let loop_terminal_error = Arc::clone(&terminal_error);
174        let socket_handle = tokio::spawn(async move {
175            socket_loop_split(
176                tx_rx,
177                rx_tx,
178                sink,
179                stream,
180                keepalive_interval,
181                keepalive_message,
182                loop_terminal_error,
183            )
184            .await;
185        });
186        Ok(Socketeer {
187            url,
188            options,
189            codec,
190            handler,
191            receiver: rx_rx,
192            sender: tx_tx,
193            socket_handle,
194            terminal_error,
195        })
196    }
197
198    /// Reassemble a `Socketeer` from its parts. Used by
199    /// [`SocketeerRx::reunite`](crate::SocketeerRx::reunite).
200    #[allow(clippy::too_many_arguments)]
201    pub(crate) fn from_parts(
202        url: Url,
203        options: ConnectOptions,
204        codec: C,
205        handler: Handler,
206        receiver: mpsc::Receiver<Message>,
207        sender: mpsc::Sender<TxChannelPayload>,
208        socket_handle: tokio::task::JoinHandle<()>,
209        terminal_error: TerminalError,
210    ) -> Self {
211        Self {
212            url,
213            options,
214            codec,
215            handler,
216            receiver,
217            sender,
218            socket_handle,
219            terminal_error,
220        }
221    }
222
223    /// Wait for the next message from the WebSocket connection, decoded by the
224    /// connection's [`Codec`].
225    ///
226    /// # Errors
227    ///
228    /// - If the WebSocket connection is closed or otherwise errored
229    /// - If the codec fails to decode the frame
230    #[cfg_attr(feature = "tracing", instrument(skip(self)))]
231    pub async fn next_message(&mut self) -> Result<C::Rx, Error> {
232        let message = recv_raw(&mut self.receiver, &self.terminal_error).await?;
233        self.codec.decode(&message)
234    }
235
236    /// Encode and send a message via the connection's [`Codec`].
237    /// This function will wait for the message to be sent before returning.
238    ///
239    /// # Errors
240    ///
241    /// - If the codec fails to encode the value
242    /// - If the WebSocket connection is closed, or otherwise errored
243    #[cfg_attr(feature = "tracing", instrument(skip(self, message)))]
244    pub async fn send(&self, message: C::Tx) -> Result<(), Error> {
245        let encoded = self.codec.encode(&message)?;
246        self.send_raw(encoded).await
247    }
248
249    /// Receive the next raw [`Message`] from the WebSocket connection without
250    /// running the codec.
251    ///
252    /// Useful when you need to inspect the underlying frame type or handle a
253    /// message that the codec would reject.
254    ///
255    /// # Errors
256    ///
257    /// - If the WebSocket connection is closed or otherwise errored
258    pub async fn next_raw_message(&mut self) -> Result<Message, Error> {
259        recv_raw(&mut self.receiver, &self.terminal_error).await
260    }
261
262    /// Send a raw [`Message`] to the WebSocket connection without running the codec.
263    ///
264    /// Useful for sending control frames or pre-encoded payloads.
265    ///
266    /// # Errors
267    ///
268    /// - If the WebSocket connection is closed, or otherwise errored
269    pub async fn send_raw(&self, message: Message) -> Result<(), Error> {
270        send_confirmed(&self.sender, &self.terminal_error, message).await
271    }
272
273    /// Consume self, closing down any remaining send/receive, and return a new Socketeer instance if successful.
274    /// This function attempts to close the connection gracefully before returning,
275    /// but will not return an error if the connection is already closed,
276    /// as its intended use is to re-establish a failed connection.
277    ///
278    /// The handler's [`ConnectionHandler::on_disconnected`] is called before closing,
279    /// and [`ConnectionHandler::on_connected`] is called after reconnecting.
280    /// # Errors
281    /// - If a new connection cannot be established
282    /// - If the handler's `on_connected` returns an error
283    pub async fn reconnect(self) -> Result<Self, Error> {
284        let url = self.url.as_str().to_owned();
285        let options = self.options.clone();
286        let codec = self.codec;
287        let mut handler = self.handler;
288        #[cfg(feature = "tracing")]
289        info!("Reconnecting");
290        handler.on_disconnected().await;
291        // Attempt graceful close, but don't fail if already closed
292        match send_close(&self.sender).await {
293            Ok(()) => (),
294            #[allow(unused_variables)]
295            Err(e) => {
296                #[cfg(feature = "tracing")]
297                debug!("Socket loop already stopped during reconnect: {}", e);
298            }
299        }
300        Self::connect_with_codec(&url, options, codec, handler).await
301    }
302
303    /// Close the WebSocket connection gracefully.
304    /// This function will wait for the connection to close before returning.
305    /// # Errors
306    /// - If the WebSocket connection is already closed
307    /// - If the WebSocket connection cannot be closed
308    #[cfg_attr(feature = "tracing", instrument(skip(self)))]
309    pub async fn close_connection(self) -> Result<(), Error> {
310        #[cfg(feature = "tracing")]
311        debug!("Closing Connection");
312        let close_sent = send_close(&self.sender).await;
313        // Wait for the loop to finish so any terminal error has been recorded.
314        if self.socket_handle.await.is_err() {
315            unreachable!("Socket loop does not panic, and is not cancelled");
316        }
317        // Prefer the loop's recorded cause (it errored during or before the
318        // close); otherwise propagate whether the close frame was sent.
319        // Access the field directly: `self` is partially moved by the await
320        // above, so a `&self` helper can't be called here.
321        let terminal = self
322            .terminal_error
323            .lock()
324            .unwrap_or_else(PoisonError::into_inner)
325            .take();
326        match terminal {
327            Some(error) => Err(error),
328            None => close_sent,
329        }
330    }
331}
332
333impl<C, Handler, const CHANNEL_SIZE: usize> Socketeer<C, Handler, CHANNEL_SIZE>
334where
335    C: Codec + Clone,
336    Handler: ConnectionHandler<C>,
337{
338    /// Split into an owned, cloneable send half and an owned receive half.
339    ///
340    /// A pure move — no new tasks are spawned and the existing channels are
341    /// reused. The codec is cloned into both halves (the send half encodes,
342    /// the receive half decodes). Recombine with
343    /// [`SocketeerRx::reunite`](crate::SocketeerRx::reunite).
344    #[must_use]
345    pub fn split(self) -> (SocketeerTx<C>, SocketeerRx<C, Handler, CHANNEL_SIZE>) {
346        let tx = SocketeerTx {
347            sender: self.sender,
348            codec: self.codec.clone(),
349            terminal_error: Arc::clone(&self.terminal_error),
350        };
351        let rx = SocketeerRx {
352            receiver: self.receiver,
353            codec: self.codec,
354            terminal_error: self.terminal_error,
355            socket_handle: self.socket_handle,
356            url: self.url,
357            options: self.options,
358            handler: self.handler,
359        };
360        (tx, rx)
361    }
362}