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}