ahp_ws/transport.rs
1//! WebSocket transport implementation built on `tokio-tungstenite`.
2
3use ahp::{Transport, TransportError, TransportMessage};
4use futures_util::{SinkExt, StreamExt};
5use thiserror::Error;
6use tokio::net::TcpStream;
7use tokio_tungstenite::tungstenite::Message;
8use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
9use url::Url;
10
11/// Errors that can occur while building a [`WebSocketTransport`].
12#[derive(Debug, Error)]
13pub enum WebSocketTransportError {
14 /// The URL could not be parsed.
15 #[error("invalid url: {0}")]
16 InvalidUrl(#[from] url::ParseError),
17 /// The underlying WebSocket handshake or I/O failed.
18 #[error("websocket error: {0}")]
19 WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
20}
21
22type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
23
24/// A [`Transport`] backed by a `tokio-tungstenite` WebSocket stream.
25///
26/// Use [`WebSocketTransport::connect`] for the common case, or
27/// [`WebSocketTransport::from_stream`] when you need custom connection
28/// options (custom TLS configuration, additional headers, etc.).
29///
30/// # Example
31///
32/// ```no_run
33/// use ahp::{Client, ClientConfig};
34/// use ahp_ws::WebSocketTransport;
35///
36/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
37/// let transport = WebSocketTransport::connect("ws://localhost:12345").await?;
38/// let client = Client::connect(transport, ClientConfig::default()).await?;
39/// client.initialize("demo".into(), vec!["0.1.0".into()], vec![]).await?;
40/// # Ok(()) }
41/// ```
42pub struct WebSocketTransport {
43 inner: WsStream,
44}
45
46impl WebSocketTransport {
47 /// Open a new WebSocket connection to `url`.
48 ///
49 /// Both `ws://` and `wss://` schemes are accepted; the `wss://` TLS
50 /// backend is chosen at compile time by Cargo feature (see the crate
51 /// docs), defaulting to rustls with OS-trust-store roots. The URL is
52 /// parsed eagerly so malformed URLs surface as
53 /// [`WebSocketTransportError::InvalidUrl`] before any network I/O.
54 pub async fn connect(url: &str) -> Result<Self, WebSocketTransportError> {
55 let _parsed = Url::parse(url)?; // validate early
56 let (stream, _resp) = connect_async(url).await?;
57 Ok(Self { inner: stream })
58 }
59
60 /// Wrap an already-connected WebSocket stream.
61 ///
62 /// Use this when you need to drive the `tokio-tungstenite`
63 /// handshake yourself — for custom TLS configuration, request
64 /// headers, or to reuse an existing socket.
65 pub fn from_stream(inner: WsStream) -> Self {
66 Self { inner }
67 }
68}
69
70impl Transport for WebSocketTransport {
71 async fn send(&mut self, msg: TransportMessage) -> Result<(), TransportError> {
72 let frame = match msg {
73 TransportMessage::Parsed(m) => {
74 let s = serde_json::to_string(&m)
75 .map_err(|e| TransportError::Protocol(e.to_string()))?;
76 Message::Text(s.into())
77 }
78 TransportMessage::Text(s) => Message::Text(s.into()),
79 TransportMessage::Binary(b) => Message::Binary(b.into()),
80 };
81 self.inner
82 .send(frame)
83 .await
84 .map_err(|e| TransportError::Io(e.to_string()))
85 }
86
87 async fn recv(&mut self) -> Result<Option<TransportMessage>, TransportError> {
88 loop {
89 match self.inner.next().await {
90 None => return Ok(None),
91 Some(Err(e)) => return Err(TransportError::Io(e.to_string())),
92 Some(Ok(Message::Text(s))) => {
93 return Ok(Some(TransportMessage::Text(s.to_string())))
94 }
95 Some(Ok(Message::Binary(b))) => {
96 return Ok(Some(TransportMessage::Binary(b.into())))
97 }
98 Some(Ok(Message::Close(_))) => return Ok(None),
99 // Ping, Pong, Frame: keep looping, tungstenite handles protocol-level response.
100 Some(Ok(_)) => continue,
101 }
102 }
103 }
104
105 async fn close(&mut self) -> Result<(), TransportError> {
106 self.inner
107 .close(None)
108 .await
109 .map_err(|e| TransportError::Io(e.to_string()))
110 }
111}