Skip to main content

confium_net_ws/
transport.rs

1//! Connected WebSocket transport and the registry kind for
2//! `ws` / `wss`.
3//!
4//! [`WsTransport`] wraps a [`tungstenite::WebSocket`] and implements
5//! [`confium_net::Transport`]. Each `send` is written as one
6//! WebSocket binary frame; each `recv` reads one binary frame. The
7//! WebSocket protocol itself preserves message boundaries, so — unlike
8//! [`confium_net_tcp`] — no length-prefix layer is needed.
9//!
10//! The transport is generic over the underlying stream so the same
11//! type serves both dial-out clients (whose stream is
12//! `MaybeTlsStream<TcpStream>` — plain for `ws://`, TLS for `wss://`)
13//! and accepted servers (whose stream is bare `TcpStream`). A sealed
14//! [`WsInner`] trait abstracts the two behind a single
15//! `Box<dyn WsInner>` so callers see one concrete `WsTransport` type
16//! regardless of how the connection was established.
17
18use std::net::TcpStream;
19
20use tungstenite::Message;
21use tungstenite::WebSocket;
22use tungstenite::client::IntoClientRequest;
23use tungstenite::stream::MaybeTlsStream;
24use url::Url;
25
26use confium_net::Listener;
27use confium_net::Result;
28use confium_net::Transport;
29use confium_net::error::ClosedSnafu;
30use confium_net::error::MalformedUrlSnafu;
31use confium_net::registry::TransportKind;
32
33// ---- sealed stream abstraction --------------------------------------
34
35/// Private trait that the two WebSocket stream shapes (`MaybeTlsStream`
36/// for dial-out, bare `TcpStream` for accepted) satisfy. Sealed so
37/// downstream crates cannot add new impls and accidentally break the
38/// `Box<dyn WsInner>` vtable contract.
39mod private {
40    use std::net::TcpStream;
41
42    use tungstenite::stream::MaybeTlsStream;
43
44    pub trait Sealed {}
45
46    impl Sealed for MaybeTlsStream<TcpStream> {}
47    impl Sealed for TcpStream {}
48}
49
50/// Anything a [`tungstenite::WebSocket`] can sit on top of in this
51/// crate. Trait-sealed to `MaybeTlsStream<TcpStream>` (client,
52/// `ws://` or `wss://`) and bare `TcpStream` (server, `ws://`).
53pub(crate) trait WsStream: std::io::Read + std::io::Write + private::Sealed + Send {}
54
55impl WsStream for MaybeTlsStream<TcpStream> {}
56impl WsStream for TcpStream {}
57
58/// Type-erased WebSocket handle so [`WsTransport`] is one concrete
59/// type regardless of whether the underlying stream is TLS-wrapped.
60pub(crate) trait WsInner: Send {
61    fn send(&mut self, msg: Message) -> tungstenite::Result<()>;
62    fn read(&mut self) -> tungstenite::Result<Message>;
63    fn close(&mut self) -> tungstenite::Result<()>;
64}
65
66impl<S: WsStream> WsInner for WebSocket<S> {
67    fn send(&mut self, msg: Message) -> tungstenite::Result<()> {
68        WebSocket::send(self, msg)
69    }
70
71    fn read(&mut self) -> tungstenite::Result<Message> {
72        WebSocket::read(self)
73    }
74
75    fn close(&mut self) -> tungstenite::Result<()> {
76        WebSocket::send(self, Message::Close(None))
77    }
78}
79
80// ---- connected transport -------------------------------------------
81
82/// Connected WebSocket transport. Owns the underlying
83/// [`tungstenite::WebSocket`]; `close` sends a WebSocket Close frame
84/// so the peer observes a clean end-of-stream.
85pub struct WsTransport {
86    inner: Option<Box<dyn WsInner>>,
87}
88
89impl WsTransport {
90    /// Wrap an already-handshaked client WebSocket (used by
91    /// [`WsTransportKind::connect`]).
92    fn from_client<S: WsStream + 'static>(ws: WebSocket<S>) -> Self {
93        Self {
94            inner: Some(Box::new(ws)),
95        }
96    }
97
98    /// Wrap an already-handshaked accepted WebSocket (used by
99    /// [`crate::listener::WsListener`] for accepted peers).
100    pub(crate) fn from_server<S: WsStream + 'static>(ws: WebSocket<S>) -> Self {
101        Self {
102            inner: Some(Box::new(ws)),
103        }
104    }
105
106    /// Dial a peer at `url`, performing the WebSocket handshake. The
107    /// scheme in `url` (`ws` or `wss`) selects plain TCP or TLS via
108    /// rustls.
109    pub(crate) fn connect(url: &Url) -> tungstenite::Result<Self> {
110        // `IntoClientRequest` for `&str` / `String` requires the
111        // `url` feature on tungstenite. Build the request from the
112        // canonical URL string so the Host header and request-target
113        // are populated correctly.
114        let req = url.as_str().into_client_request()?;
115        let (ws, _resp) = tungstenite::connect(req)?;
116        Ok(Self::from_client(ws))
117    }
118}
119
120impl Transport for WsTransport {
121    fn send(&mut self, data: &[u8]) -> Result<()> {
122        let ws = match &mut self.inner {
123            Some(w) => w,
124            None => return ClosedSnafu.fail(),
125        };
126        // Binary frames carry arbitrary bytes; tungstenite handles
127        // fragmentation for large payloads transparently.
128        ws.send(Message::binary(data.to_vec()))
129            .map_err(ws_to_closed)
130    }
131
132    fn recv(&mut self, buf: &mut [u8]) -> Result<usize> {
133        let ws = match &mut self.inner {
134            Some(w) => w,
135            None => return ClosedSnafu.fail(),
136        };
137        loop {
138            let msg = match ws.read() {
139                Ok(m) => m,
140                Err(e) => return Err(ws_to_closed(e)),
141            };
142            match msg {
143                // Binary is the payload-bearing message type. Deliver
144                // as much as fits in the caller's buffer; the
145                // remainder is dropped, matching the inproc/tcp
146                // transports' "fill what you can" semantics for
147                // undersized buffers.
148                Message::Binary(data) => {
149                    let n = std::cmp::min(data.len(), buf.len());
150                    buf[..n].copy_from_slice(&data[..n]);
151                    return Ok(n);
152                }
153                // Close frame: the peer ended the session cleanly.
154                Message::Close(_) => return ClosedSnafu.fail(),
155                // Pings are answered by tungstenite automatically with
156                // a matching pong (the library handles the keep-alive
157                // half of the protocol); pongs arriving here are not
158                // application payload, so skip them.
159                Message::Pong(_) | Message::Ping(_) => continue,
160                // Text frames are not used by Confium transports;
161                // treat their UTF-8 bytes as binary data so a peer
162                // that sends text anyway still works.
163                Message::Text(t) => {
164                    let bytes = t.as_bytes();
165                    let n = std::cmp::min(bytes.len(), buf.len());
166                    buf[..n].copy_from_slice(&bytes[..n]);
167                    return Ok(n);
168                }
169                // Raw frames are never surfaced by tungstenite's
170                // `read()` — the library assembles complete messages.
171                // If we somehow see one, hand its payload over.
172                Message::Frame(_) => continue,
173            }
174        }
175    }
176
177    fn close(&mut self) -> Result<()> {
178        if let Some(mut ws) = self.inner.take() {
179            // Send a Close frame so the peer's read observes a clean
180            // end-of-stream rather than a TCP RST. Best-effort: a
181            // peer that already went away surfaces an error here that
182            // we swallow.
183            ws.close().ok();
184        }
185        Ok(())
186    }
187}
188
189impl Drop for WsTransport {
190    fn drop(&mut self) {
191        if let Some(mut ws) = self.inner.take() {
192            ws.close().ok();
193        }
194    }
195}
196
197// ---- registry kind -------------------------------------------------
198
199/// Registry kind for `ws`, `wss`.
200pub struct WsTransportKind;
201
202impl TransportKind for WsTransportKind {
203    fn schemes(&self) -> &'static [&'static str] {
204        &["ws", "wss"]
205    }
206
207    fn connect(&self, url: &Url) -> Result<Box<dyn Transport>> {
208        let scheme = url.scheme();
209        host_check(url, scheme)?;
210        match WsTransport::connect(url) {
211            Ok(t) => Ok(Box::new(t)),
212            Err(_) => MalformedUrlSnafu {
213                scheme,
214                url: url.to_string(),
215                reason: "could not connect to peer",
216            }
217            .fail(),
218        }
219    }
220
221    fn listen(&self, url: &Url) -> Result<Box<dyn Listener>> {
222        let scheme = url.scheme();
223        let (host, port) = host_port(url, scheme)?;
224        match crate::listener::WsListener::bind(scheme, host, port) {
225            Ok(l) => Ok(Box::new(l)),
226            Err(_) => MalformedUrlSnafu {
227                scheme,
228                url: url.to_string(),
229                reason: "could not bind to address",
230            }
231            .fail(),
232        }
233    }
234}
235
236// ---- shared helpers ------------------------------------------------
237
238/// Extract the `(host, port)` pair from a `ws://` / `wss://` URL. The
239/// port is mandatory (no implicit 80/443 defaulting — Confium URLs
240/// always spell the port out, mirroring the `tcp://` contract).
241pub(crate) fn host_port<'a>(url: &'a Url, scheme: &str) -> Result<(&'a str, u16)> {
242    host_check(url, scheme)?;
243    let port = match url.port() {
244        Some(p) => p,
245        None => {
246            return MalformedUrlSnafu {
247                scheme,
248                url: url.to_string(),
249                reason: "missing port (use ws://<host>:<port>)",
250            }
251            .fail();
252        }
253    };
254    Ok((url.host_str().unwrap_or(""), port))
255}
256
257/// Reject URLs with no host.
258fn host_check(url: &Url, scheme: &str) -> Result<()> {
259    if url.host_str().map(|h| h.is_empty()).unwrap_or(true) {
260        return MalformedUrlSnafu {
261            scheme,
262            url: url.to_string(),
263            reason: "missing host (use ws://<host>:<port>)",
264        }
265        .fail();
266    }
267    Ok(())
268}
269
270/// Map a [`tungstenite::Error`] to a [`confium_net::Error`]. The
271/// confium-net error type does not today carry an arbitrary source,
272/// so connection / read / write failures are reported as `Closed` —
273/// the transport is unusable after a WebSocket error, matching how
274/// the inproc/tcp transports report a broken channel.
275pub(crate) fn ws_to_closed(_: tungstenite::Error) -> confium_net::Error {
276    ClosedSnafu.build()
277}
278
279/// Map an [`std::io::Error`] (e.g. from `accept` on the underlying
280/// TCP socket before the WebSocket handshake completes) to a
281/// [`confium_net::Error`]. Mirrors [`ws_to_closed`] — the transport
282/// is unusable after an I/O failure.
283pub(crate) fn io_to_closed(_: std::io::Error) -> confium_net::Error {
284    ClosedSnafu.build()
285}
286
287/// Map a [`tungstenite::HandshakeError`] (raised when the server-side
288/// `accept` or client-side `connect` handshake fails) to a
289/// [`confium_net::Error`]. Same "report as Closed" rationale as the
290/// other mappers: the resulting transport cannot carry bytes.
291pub(crate) fn handshake_to_closed<R: tungstenite::handshake::HandshakeRole>(
292    _e: tungstenite::HandshakeError<R>,
293) -> confium_net::Error {
294    ClosedSnafu.build()
295}
296
297// Silence an unused-import lint when this file is read in isolation
298// during type-checking of the trait object's vtable.
299#[allow(dead_code)]
300fn _assert_tcp_stream_send() {
301    fn needs_send<T: Send>() {}
302    needs_send::<TcpStream>();
303}