Skip to main content

alloy_transport_ws/
native.rs

1use crate::{WsBackend, DEFAULT_KEEPALIVE};
2use alloy_pubsub::PubSubConnect;
3use alloy_transport::{utils::Spawnable, Authorization, TransportErrorKind, TransportResult};
4use futures::{SinkExt, StreamExt};
5use serde_json::value::RawValue;
6use std::time::Duration;
7use tokio::time::sleep;
8use tokio_tungstenite::{
9    tungstenite::{self, client::IntoClientRequest, Message},
10    MaybeTlsStream, WebSocketStream,
11};
12
13type TungsteniteStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
14
15pub use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
16
17/// Simple connection details for a websocket connection.
18#[derive(Clone, Debug)]
19pub struct WsConnect {
20    /// The URL to connect to.
21    url: String,
22    /// The authorization header to use.
23    auth: Option<Authorization>,
24    /// The websocket config.
25    config: Option<WebSocketConfig>,
26    /// Max number of retries before failing and exiting the connection.
27    /// Default is 10.
28    max_retries: u32,
29    /// The base interval between retries.
30    ///
31    /// Reconnect retries use capped exponential backoff from this base interval.
32    /// Default is 3 seconds.
33    retry_interval: Duration,
34    /// The interval between keepalive pings.
35    /// Default is 10 seconds.
36    keepalive_interval: Duration,
37}
38
39impl WsConnect {
40    /// Creates a new websocket connection configuration.
41    ///
42    /// If the URL contains credentials (e.g. `wss://user:pass@host`), they are
43    /// automatically extracted and set as the [`Authorization`] header.
44    pub fn new<S: Into<String>>(url: S) -> Self {
45        let url = url.into();
46        let auth =
47            url::Url::parse(&url).ok().and_then(|parsed| Authorization::extract_from_url(&parsed));
48        Self {
49            url,
50            auth,
51            config: None,
52            max_retries: 10,
53            retry_interval: Duration::from_secs(3),
54            keepalive_interval: Duration::from_secs(DEFAULT_KEEPALIVE),
55        }
56    }
57
58    /// Sets the authorization header.
59    pub fn with_auth(mut self, auth: Authorization) -> Self {
60        self.auth = Some(auth);
61        self
62    }
63
64    /// Sets the optional authorization header.
65    ///
66    /// This replaces the current [`Authorization`].
67    pub fn with_auth_opt(mut self, auth: Option<Authorization>) -> Self {
68        self.auth = auth;
69        self
70    }
71
72    /// Sets the websocket config.
73    pub const fn with_config(mut self, config: WebSocketConfig) -> Self {
74        self.config = Some(config);
75        self
76    }
77
78    /// Get the URL string of the connection.
79    pub fn url(&self) -> &str {
80        &self.url
81    }
82
83    /// Get the authorization header.
84    pub const fn auth(&self) -> Option<&Authorization> {
85        self.auth.as_ref()
86    }
87
88    /// Get the websocket config.
89    pub const fn config(&self) -> Option<&WebSocketConfig> {
90        self.config.as_ref()
91    }
92
93    /// Sets the max number of retries before failing and exiting the connection.
94    /// Default is 10.
95    pub const fn with_max_retries(mut self, max_retries: u32) -> Self {
96        self.max_retries = max_retries;
97        self
98    }
99
100    /// Sets the base interval between retries.
101    ///
102    /// Reconnect retries use capped exponential backoff from this base interval.
103    /// Default is 3 seconds.
104    pub const fn with_retry_interval(mut self, retry_interval: Duration) -> Self {
105        self.retry_interval = retry_interval;
106        self
107    }
108
109    /// Sets the keepalive ping interval.
110    ///
111    /// A ping is sent if no other messages have been sent within this interval.
112    /// If the server does not respond with a pong before the next ping is due,
113    /// the connection is considered dead and will be closed.
114    ///
115    /// Default is 10 seconds.
116    pub const fn with_keepalive_interval(mut self, keepalive_interval: Duration) -> Self {
117        self.keepalive_interval = keepalive_interval;
118        self
119    }
120}
121
122impl IntoClientRequest for WsConnect {
123    fn into_client_request(self) -> tungstenite::Result<tungstenite::handshake::client::Request> {
124        let mut request: http::Request<()> = self.url.into_client_request()?;
125        if let Some(auth) = self.auth {
126            let mut auth_value = http::HeaderValue::from_str(&auth.to_string())?;
127            auth_value.set_sensitive(true);
128
129            request.headers_mut().insert(http::header::AUTHORIZATION, auth_value);
130        }
131
132        request.into_client_request()
133    }
134}
135
136impl PubSubConnect for WsConnect {
137    fn is_local(&self) -> bool {
138        alloy_transport::utils::guess_local_url(&self.url)
139    }
140
141    async fn connect(&self) -> TransportResult<alloy_pubsub::ConnectionHandle> {
142        #[cfg(any(feature = "aws-lc-rs", feature = "ring"))]
143        install_default_crypto_provider();
144
145        let request = self.clone().into_client_request();
146        let req = request.map_err(TransportErrorKind::custom)?;
147        let (socket, _) = tokio_tungstenite::connect_async_with_config(req, self.config, false)
148            .await
149            .map_err(TransportErrorKind::custom)?;
150
151        let (handle, interface) = alloy_pubsub::ConnectionHandle::new();
152        let backend = WsBackend { socket, interface, keepalive_interval: self.keepalive_interval };
153
154        backend.spawn();
155
156        Ok(handle.with_max_retries(self.max_retries).with_retry_interval(self.retry_interval))
157    }
158}
159
160/// Install a default rustls crypto provider if none is set.
161///
162/// Required since rustls 0.23+ no longer auto-installs one.
163#[cfg(any(feature = "aws-lc-rs", feature = "ring"))]
164fn install_default_crypto_provider() {
165    if rustls::crypto::CryptoProvider::get_default().is_some() {
166        return;
167    }
168    #[cfg(feature = "aws-lc-rs")]
169    let provider = rustls::crypto::aws_lc_rs::default_provider();
170    #[cfg(all(feature = "ring", not(feature = "aws-lc-rs")))]
171    let provider = rustls::crypto::ring::default_provider();
172    // install_default returns Err if a concurrent caller raced us past the get_default check;
173    // either provider is valid.
174    let _ = rustls::crypto::CryptoProvider::install_default(provider);
175}
176
177impl WsBackend<TungsteniteStream> {
178    /// Handle a message from the server.
179    #[expect(clippy::result_unit_err)]
180    pub fn handle(&mut self, msg: Message) -> Result<(), ()> {
181        match msg {
182            Message::Text(text) => self.handle_text(&text),
183            Message::Close(frame) => {
184                if frame.is_some() {
185                    error!(?frame, "Received close frame with data");
186                } else {
187                    error!("WS server has gone away");
188                }
189                Err(())
190            }
191            Message::Binary(_) => {
192                error!("Received binary message, expected text");
193                Err(())
194            }
195            Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => Ok(()),
196        }
197    }
198
199    /// Send a message to the server.
200    pub async fn send(&mut self, msg: Box<RawValue>) -> Result<(), tungstenite::Error> {
201        self.socket.send(Message::Text(msg.get().to_owned().into())).await
202    }
203
204    /// Spawn a new backend task.
205    pub fn spawn(mut self) {
206        let fut = async move {
207            let mut errored = false;
208            let mut expecting_pong = false;
209            let keepalive = sleep(self.keepalive_interval);
210            tokio::pin!(keepalive);
211            loop {
212                // We bias the loop as follows
213                // 1. New dispatch to server.
214                // 2. Keepalive.
215                // 3. Response or notification from server.
216                // This ensures that keepalive is sent only if no other messages
217                // have been sent in the keepalive interval. And prioritizes new
218                // dispatches over responses from the server. This will fail if
219                // the client saturates the task with dispatches, but that's
220                // probably not a big deal.
221                tokio::select! {
222                    biased;
223                    // we've received a new dispatch, so we send it via
224                    // websocket. We handle new work before processing any
225                    // responses from the server.
226                    inst = self.interface.recv_from_frontend() => {
227                        match inst {
228                            Some(msg) => {
229                                // Reset the keepalive timer.
230                                keepalive.set(sleep(self.keepalive_interval));
231                                if let Err(err) = self.send(msg).await {
232                                    error!(%err, "WS connection error");
233                                    errored = true;
234                                    break
235                                }
236                            },
237                            // dispatcher has gone away, or shutdown was received
238                            None => {
239                                break
240                            },
241                        }
242                    },
243                    // Send a ping to the server, if no other messages have been
244                    // sent within the keepalive interval.
245                    _ = &mut keepalive => {
246                        // Still expecting a pong from the previous ping,
247                        // meaning connection is errored.
248                        if expecting_pong {
249                            error!("WS server missed a pong");
250                            errored = true;
251                            break
252                        }
253                        // Reset the keepalive timer.
254                        keepalive.set(sleep(self.keepalive_interval));
255                        if let Err(err) = self.socket.send(Message::Ping(Default::default())).await {
256                            error!(%err, "WS connection error");
257                            errored = true;
258                            break
259                        }
260                        // Expecting to receive a pong before the next
261                        // keepalive timer resolves.
262                        expecting_pong = true;
263                    }
264                    resp = self.socket.next() => {
265                        match resp {
266                            Some(Ok(item)) => {
267                                if item.is_pong() {
268                                    expecting_pong = false;
269                                }
270                                errored = self.handle(item).is_err();
271                                if errored { break }
272                            },
273                            Some(Err(err)) => {
274                                error!(%err, "WS connection error");
275                                errored = true;
276                                break
277                            }
278                            None => {
279                                error!("WS server has gone away");
280                                errored = true;
281                                break
282                            },
283                        }
284                    }
285                }
286            }
287            if errored {
288                self.interface.close_with_error();
289            }
290        };
291        fut.spawn_task()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn parse_basic_auth_from_url() {
301        let ws = WsConnect::new("wss://user:pass@example.com/path");
302        assert_eq!(ws.url(), "wss://user:pass@example.com/path");
303        assert_eq!(ws.auth(), Some(&Authorization::basic("user", "pass")));
304    }
305
306    #[test]
307    fn parse_username_only_from_url() {
308        let ws = WsConnect::new("ws://user@example.com");
309        assert_eq!(ws.url(), "ws://user@example.com");
310        assert_eq!(ws.auth(), Some(&Authorization::basic("user", "")));
311    }
312
313    #[test]
314    fn no_auth_when_url_has_no_credentials() {
315        let ws = WsConnect::new("wss://example.com/rpc");
316        assert_eq!(ws.url(), "wss://example.com/rpc");
317        assert!(ws.auth().is_none());
318    }
319
320    #[test]
321    fn explicit_auth_overrides_url_auth() {
322        let ws =
323            WsConnect::new("wss://user:pass@example.com").with_auth(Authorization::bearer("tok"));
324        assert_eq!(ws.auth(), Some(&Authorization::bearer("tok")));
325    }
326
327    #[test]
328    fn no_auth_for_localhost_username() {
329        let ws = WsConnect::new("ws://localhost:8545");
330        assert!(ws.auth().is_none());
331    }
332}