alloy_transport_ws/
native.rs1use 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#[derive(Clone, Debug)]
19pub struct WsConnect {
20 url: String,
22 auth: Option<Authorization>,
24 config: Option<WebSocketConfig>,
26 max_retries: u32,
29 retry_interval: Duration,
34 keepalive_interval: Duration,
37}
38
39impl WsConnect {
40 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 pub fn with_auth(mut self, auth: Authorization) -> Self {
60 self.auth = Some(auth);
61 self
62 }
63
64 pub fn with_auth_opt(mut self, auth: Option<Authorization>) -> Self {
68 self.auth = auth;
69 self
70 }
71
72 pub const fn with_config(mut self, config: WebSocketConfig) -> Self {
74 self.config = Some(config);
75 self
76 }
77
78 pub fn url(&self) -> &str {
80 &self.url
81 }
82
83 pub const fn auth(&self) -> Option<&Authorization> {
85 self.auth.as_ref()
86 }
87
88 pub const fn config(&self) -> Option<&WebSocketConfig> {
90 self.config.as_ref()
91 }
92
93 pub const fn with_max_retries(mut self, max_retries: u32) -> Self {
96 self.max_retries = max_retries;
97 self
98 }
99
100 pub const fn with_retry_interval(mut self, retry_interval: Duration) -> Self {
105 self.retry_interval = retry_interval;
106 self
107 }
108
109 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#[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 let _ = rustls::crypto::CryptoProvider::install_default(provider);
175}
176
177impl WsBackend<TungsteniteStream> {
178 #[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 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 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 tokio::select! {
222 biased;
223 inst = self.interface.recv_from_frontend() => {
227 match inst {
228 Some(msg) => {
229 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 None => {
239 break
240 },
241 }
242 },
243 _ = &mut keepalive => {
246 if expecting_pong {
249 error!("WS server missed a pong");
250 errored = true;
251 break
252 }
253 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_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}