Skip to main content

nautilus_network/transport/
message.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Neutral WebSocket message types shared by all transport backends.
17
18use bytes::Bytes;
19
20/// A WebSocket message.
21///
22/// Backend-agnostic representation handed to consumers of the
23/// `nautilus-network` transport layer. Each backend provides `From` impls
24/// between its native `Message` type and this enum.
25///
26/// `Text` is documented to carry UTF-8 by contract but the type does not
27/// enforce it. Backends that already validate (such as `tokio-tungstenite`)
28/// produce valid bytes; an in-house HFT backend may skip validation for
29/// performance and rely on the consumer's parser to catch malformed bytes.
30/// Use [`Self::as_text`] to view the payload as `&str`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Message {
33    /// Text message. Payload is UTF-8 by contract, not by type guarantee.
34    Text(Bytes),
35    /// Binary message.
36    Binary(Bytes),
37    /// Ping control frame. Payload bounded to 125 bytes by RFC 6455.
38    Ping(Bytes),
39    /// Pong control frame. Payload bounded to 125 bytes by RFC 6455.
40    Pong(Bytes),
41    /// Close control frame with optional close frame payload.
42    Close(Option<CloseFrame>),
43}
44
45impl Message {
46    /// Construct a text message from any string-like value.
47    #[inline]
48    #[must_use]
49    pub fn text(s: impl Into<String>) -> Self {
50        Self::Text(Bytes::from(s.into()))
51    }
52
53    /// Borrow a text message as `&str` if the payload is valid UTF-8.
54    ///
55    /// Validates on each call; for hot paths where the producer is trusted,
56    /// callers can read the bytes directly via [`Self::as_bytes`] and feed
57    /// them to a parser that catches malformed input.
58    #[inline]
59    #[must_use]
60    pub fn as_text(&self) -> Option<&str> {
61        match self {
62            Self::Text(b) => std::str::from_utf8(b).ok(),
63            _ => None,
64        }
65    }
66
67    /// Construct a binary message.
68    #[inline]
69    #[must_use]
70    pub fn binary(data: impl Into<Bytes>) -> Self {
71        Self::Binary(data.into())
72    }
73
74    /// Construct a ping message.
75    #[inline]
76    #[must_use]
77    pub fn ping(data: impl Into<Bytes>) -> Self {
78        Self::Ping(data.into())
79    }
80
81    /// Construct a pong message.
82    #[inline]
83    #[must_use]
84    pub fn pong(data: impl Into<Bytes>) -> Self {
85        Self::Pong(data.into())
86    }
87
88    /// Returns `true` for text messages.
89    #[inline]
90    #[must_use]
91    pub const fn is_text(&self) -> bool {
92        matches!(self, Self::Text(_))
93    }
94
95    /// Returns `true` for binary messages.
96    #[inline]
97    #[must_use]
98    pub const fn is_binary(&self) -> bool {
99        matches!(self, Self::Binary(_))
100    }
101
102    /// Returns `true` for ping messages.
103    #[inline]
104    #[must_use]
105    pub const fn is_ping(&self) -> bool {
106        matches!(self, Self::Ping(_))
107    }
108
109    /// Returns `true` for pong messages.
110    #[inline]
111    #[must_use]
112    pub const fn is_pong(&self) -> bool {
113        matches!(self, Self::Pong(_))
114    }
115
116    /// Returns `true` for close messages.
117    #[inline]
118    #[must_use]
119    pub const fn is_close(&self) -> bool {
120        matches!(self, Self::Close(_))
121    }
122
123    /// Returns `true` for control frames (ping, pong, close).
124    #[inline]
125    #[must_use]
126    pub const fn is_control(&self) -> bool {
127        matches!(self, Self::Ping(_) | Self::Pong(_) | Self::Close(_))
128    }
129
130    /// Returns the message payload as a byte slice.
131    ///
132    /// For close frames, returns the reason payload as bytes.
133    #[inline]
134    #[must_use]
135    pub fn as_bytes(&self) -> &[u8] {
136        match self {
137            Self::Text(b) | Self::Binary(b) | Self::Ping(b) | Self::Pong(b) => b,
138            Self::Close(_) => &[],
139        }
140    }
141
142    /// Consumes the message and returns its payload as `Bytes`.
143    ///
144    /// For close frames, returns an empty `Bytes`.
145    #[inline]
146    #[must_use]
147    pub fn into_bytes(self) -> Bytes {
148        match self {
149            Self::Text(b) | Self::Binary(b) | Self::Ping(b) | Self::Pong(b) => b,
150            Self::Close(_) => Bytes::new(),
151        }
152    }
153}
154
155impl From<String> for Message {
156    #[inline]
157    fn from(s: String) -> Self {
158        Self::Text(Bytes::from(s))
159    }
160}
161
162impl From<&str> for Message {
163    #[inline]
164    fn from(s: &str) -> Self {
165        Self::Text(Bytes::copy_from_slice(s.as_bytes()))
166    }
167}
168
169impl From<Vec<u8>> for Message {
170    #[inline]
171    fn from(v: Vec<u8>) -> Self {
172        Self::Binary(Bytes::from(v))
173    }
174}
175
176impl From<Bytes> for Message {
177    #[inline]
178    fn from(b: Bytes) -> Self {
179        Self::Binary(b)
180    }
181}
182
183/// A WebSocket close frame.
184///
185/// Mirrors the RFC 6455 close payload: a 16-bit status code followed by an
186/// optional UTF-8 reason string.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct CloseFrame {
189    /// RFC 6455 close status code.
190    pub code: u16,
191    /// Human-readable reason string. Empty if no reason was provided.
192    pub reason: String,
193}
194
195impl CloseFrame {
196    /// Normal closure (1000).
197    pub const NORMAL: u16 = 1000;
198    /// Going away (1001).
199    pub const GOING_AWAY: u16 = 1001;
200    /// Protocol error (1002).
201    pub const PROTOCOL_ERROR: u16 = 1002;
202    /// Unsupported data (1003).
203    pub const UNSUPPORTED: u16 = 1003;
204    /// Abnormal closure, no close frame received (1006).
205    pub const ABNORMAL: u16 = 1006;
206    /// Policy violation (1008).
207    pub const POLICY_VIOLATION: u16 = 1008;
208    /// Message too large (1009).
209    pub const MESSAGE_TOO_LARGE: u16 = 1009;
210    /// Internal server error (1011).
211    pub const INTERNAL_ERROR: u16 = 1011;
212
213    /// Construct a close frame.
214    #[inline]
215    #[must_use]
216    pub fn new(code: u16, reason: impl Into<String>) -> Self {
217        Self {
218            code,
219            reason: reason.into(),
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use rstest::rstest;
227
228    use super::*;
229
230    #[rstest]
231    fn text_constructor_round_trips() {
232        let msg = Message::text("hello");
233        assert!(msg.is_text());
234        assert_eq!(msg.as_bytes(), b"hello");
235    }
236
237    #[rstest]
238    fn binary_constructor_round_trips() {
239        let msg = Message::binary(vec![1, 2, 3]);
240        assert!(msg.is_binary());
241        assert_eq!(msg.as_bytes(), &[1, 2, 3]);
242    }
243
244    #[rstest]
245    fn ping_pong_classify_as_control() {
246        assert!(Message::ping(Bytes::new()).is_control());
247        assert!(Message::pong(Bytes::new()).is_control());
248        assert!(Message::Close(None).is_control());
249        assert!(!Message::text("x").is_control());
250        assert!(!Message::binary(vec![]).is_control());
251    }
252
253    #[rstest]
254    fn close_frame_carries_code_and_reason() {
255        let frame = CloseFrame::new(CloseFrame::GOING_AWAY, "shutdown");
256        assert_eq!(frame.code, 1001);
257        assert_eq!(frame.reason, "shutdown");
258    }
259
260    #[rstest]
261    fn into_bytes_consumes_payload() {
262        let msg = Message::binary(vec![9, 8, 7]);
263        let bytes = msg.into_bytes();
264        assert_eq!(&bytes[..], &[9, 8, 7]);
265    }
266
267    #[rstest]
268    fn into_bytes_close_returns_empty() {
269        let msg = Message::Close(Some(CloseFrame::new(1000, "bye")));
270        assert!(msg.into_bytes().is_empty());
271    }
272
273    #[rstest]
274    fn as_text_returns_str_for_valid_utf8() {
275        let msg = Message::text("café");
276        assert_eq!(msg.as_text(), Some("café"));
277    }
278
279    #[rstest]
280    fn as_text_returns_none_for_invalid_utf8() {
281        let msg = Message::Text(Bytes::from_static(&[0xFF, 0xFE]));
282        assert!(msg.as_text().is_none());
283    }
284
285    #[rstest]
286    fn as_text_returns_none_for_non_text() {
287        assert!(Message::binary(vec![1u8]).as_text().is_none());
288        assert!(Message::ping(Bytes::new()).as_text().is_none());
289        assert!(Message::Close(None).as_text().is_none());
290    }
291}