Skip to main content

nexus_net/ws/
message.rs

1use super::error::ProtocolError;
2
3/// WebSocket close status codes (RFC 6455 §7.4.1).
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum CloseCode {
6    /// 1000 — normal closure.
7    Normal,
8    /// 1001 — endpoint going away.
9    GoingAway,
10    /// 1002 — protocol error.
11    Protocol,
12    /// 1003 — received unsupported data type.
13    Unsupported,
14    /// 1005 — no status code present.
15    NoStatus,
16    /// 1007 — payload data not consistent with message type.
17    InvalidPayload,
18    /// 1008 — policy violation.
19    PolicyViolation,
20    /// 1009 — message too big.
21    MessageTooBig,
22    /// 1010 — client expected server to negotiate an extension.
23    MandatoryExtension,
24    /// 1011 — server encountered an unexpected condition.
25    InternalError,
26    /// Application-defined code (3000-4999).
27    Other(u16),
28}
29
30impl CloseCode {
31    /// Parse a close code from its wire representation.
32    ///
33    /// # Errors
34    /// Returns `ProtocolError::InvalidCloseCode` for codes outside the
35    /// valid ranges defined in RFC 6455 §7.4.2.
36    pub fn from_u16(code: u16) -> Result<Self, ProtocolError> {
37        match code {
38            1000 => Ok(Self::Normal),
39            1001 => Ok(Self::GoingAway),
40            1002 => Ok(Self::Protocol),
41            1003 => Ok(Self::Unsupported),
42            // 1005 is reserved — MUST NOT appear on the wire (RFC 6455 §7.4.1)
43            1007 => Ok(Self::InvalidPayload),
44            1008 => Ok(Self::PolicyViolation),
45            1009 => Ok(Self::MessageTooBig),
46            1010 => Ok(Self::MandatoryExtension),
47            1011 => Ok(Self::InternalError),
48            3000..=4999 => Ok(Self::Other(code)),
49            _ => Err(ProtocolError::InvalidCloseCode(code)),
50        }
51    }
52
53    /// Convert to the wire representation.
54    pub fn as_u16(&self) -> u16 {
55        match self {
56            Self::Normal => 1000,
57            Self::GoingAway => 1001,
58            Self::Protocol => 1002,
59            Self::Unsupported => 1003,
60            Self::NoStatus => 1005,
61            Self::InvalidPayload => 1007,
62            Self::PolicyViolation => 1008,
63            Self::MessageTooBig => 1009,
64            Self::MandatoryExtension => 1010,
65            Self::InternalError => 1011,
66            Self::Other(code) => *code,
67        }
68    }
69}
70
71/// Parsed close frame: status code + UTF-8 reason.
72#[derive(Debug, Clone)]
73pub struct CloseFrame<'a> {
74    /// The close status code.
75    pub code: CloseCode,
76    /// UTF-8 reason string (validated, may be empty).
77    pub reason: &'a str,
78}
79
80/// Owned close frame.
81#[derive(Debug, Clone)]
82pub struct OwnedCloseFrame {
83    /// The close status code.
84    pub code: CloseCode,
85    /// UTF-8 reason string.
86    pub reason: String,
87}
88
89/// A complete WebSocket message.
90///
91/// Text payloads are validated UTF-8. Close frames are parsed into
92/// structured code + reason. No continuation frames are exposed.
93///
94/// Borrows from the reader's internal buffer — drop before calling
95/// [`FrameReader::next()`](super::FrameReader) again.
96#[derive(Debug, Clone)]
97pub enum Message<'a> {
98    /// UTF-8 text message (validated).
99    Text(&'a str),
100    /// Binary message.
101    Binary(&'a [u8]),
102    /// Ping control frame.
103    Ping(&'a [u8]),
104    /// Pong control frame.
105    Pong(&'a [u8]),
106    /// Connection close.
107    Close(CloseFrame<'a>),
108}
109
110impl<'a> Message<'a> {
111    /// Payload as bytes, regardless of message type.
112    ///
113    /// - `Text` → UTF-8 bytes
114    /// - `Binary` / `Ping` / `Pong` → raw bytes
115    /// - `Close` → reason string as bytes (excludes the 2-byte status code)
116    pub fn as_bytes(&self) -> &[u8] {
117        match self {
118            Self::Text(s) => s.as_bytes(),
119            Self::Binary(b) | Self::Ping(b) | Self::Pong(b) => b,
120            Self::Close(cf) => cf.reason.as_bytes(),
121        }
122    }
123
124    /// Consume the message, returning the payload as a byte slice.
125    ///
126    /// Releases the borrow on the `FrameReader` while keeping access
127    /// to the payload (valid until the reader is advanced).
128    pub fn into_bytes(self) -> &'a [u8] {
129        match self {
130            Self::Text(s) => s.as_bytes(),
131            Self::Binary(b) | Self::Ping(b) | Self::Pong(b) => b,
132            Self::Close(cf) => cf.reason.as_bytes(),
133        }
134    }
135
136    /// Take ownership. Copies payload out of borrowed buffer.
137    pub fn into_owned(self) -> OwnedMessage {
138        match self {
139            Self::Text(s) => OwnedMessage::Text(s.to_owned()),
140            Self::Binary(b) => OwnedMessage::Binary(b.to_vec()),
141            Self::Ping(b) => OwnedMessage::Ping(b.to_vec()),
142            Self::Pong(b) => OwnedMessage::Pong(b.to_vec()),
143            Self::Close(cf) => OwnedMessage::Close(OwnedCloseFrame {
144                code: cf.code,
145                reason: cf.reason.to_owned(),
146            }),
147        }
148    }
149}
150
151/// An owned WebSocket message, detached from reader buffers.
152#[derive(Debug, Clone)]
153pub enum OwnedMessage {
154    /// UTF-8 text message.
155    Text(String),
156    /// Binary message.
157    Binary(Vec<u8>),
158    /// Ping control frame.
159    Ping(Vec<u8>),
160    /// Pong control frame.
161    Pong(Vec<u8>),
162    /// Connection close.
163    Close(OwnedCloseFrame),
164}
165
166impl OwnedMessage {
167    /// Payload as bytes, regardless of message type.
168    ///
169    /// - `Text` → UTF-8 bytes
170    /// - `Binary` / `Ping` / `Pong` → raw bytes
171    /// - `Close` → reason string as bytes (excludes the 2-byte status code)
172    pub fn as_bytes(&self) -> &[u8] {
173        match self {
174            Self::Text(s) => s.as_bytes(),
175            Self::Binary(b) | Self::Ping(b) | Self::Pong(b) => b,
176            Self::Close(cf) => cf.reason.as_bytes(),
177        }
178    }
179
180    /// Convert to `bytes::Bytes`. Zero-copy — takes ownership of the
181    /// underlying `Vec`/`String` allocation without copying.
182    ///
183    /// ```ignore
184    /// let msg = ws.recv()?.unwrap().into_owned();
185    /// let shared: Bytes = msg.to_bytes();
186    /// tx.send(shared)?;  // Send + Clone, cheap to share
187    /// ```
188    #[cfg(feature = "bytes")]
189    pub fn to_bytes(self) -> bytes::Bytes {
190        match self {
191            Self::Text(s) => bytes::Bytes::from(s.into_bytes()),
192            Self::Binary(b) | Self::Ping(b) | Self::Pong(b) => bytes::Bytes::from(b),
193            Self::Close(cf) => bytes::Bytes::from(cf.reason.into_bytes()),
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn close_code_round_trip() {
204        let codes = [
205            (1000, CloseCode::Normal),
206            (1001, CloseCode::GoingAway),
207            (1002, CloseCode::Protocol),
208            (1003, CloseCode::Unsupported),
209            (1007, CloseCode::InvalidPayload),
210            (1008, CloseCode::PolicyViolation),
211            (1009, CloseCode::MessageTooBig),
212            (1010, CloseCode::MandatoryExtension),
213            (1011, CloseCode::InternalError),
214            (3000, CloseCode::Other(3000)),
215            (4999, CloseCode::Other(4999)),
216        ];
217        for (raw, expected) in &codes {
218            let parsed = CloseCode::from_u16(*raw).unwrap();
219            assert_eq!(parsed, *expected);
220            assert_eq!(parsed.as_u16(), *raw);
221        }
222    }
223
224    #[test]
225    fn close_code_rejects_invalid() {
226        let invalid = [0, 999, 1004, 1005, 1006, 1015, 1016, 2999, 5000, u16::MAX];
227        for code in &invalid {
228            assert!(
229                CloseCode::from_u16(*code).is_err(),
230                "should reject code {code}"
231            );
232        }
233    }
234
235    #[test]
236    fn message_into_owned() {
237        let text = Message::Text("hello");
238        let owned = text.into_owned();
239        assert!(matches!(owned, OwnedMessage::Text(s) if s == "hello"));
240
241        let binary = Message::Binary(&[1, 2, 3]);
242        let owned = binary.into_owned();
243        assert!(matches!(owned, OwnedMessage::Binary(b) if b == vec![1, 2, 3]));
244
245        let close = Message::Close(CloseFrame {
246            code: CloseCode::Normal,
247            reason: "bye",
248        });
249        let owned = close.into_owned();
250        assert!(matches!(
251            owned,
252            OwnedMessage::Close(OwnedCloseFrame { code: CloseCode::Normal, reason }) if reason == "bye"
253        ));
254    }
255
256    #[test]
257    fn owned_message_as_bytes() {
258        assert_eq!(OwnedMessage::Text("hello".into()).as_bytes(), b"hello");
259        assert_eq!(OwnedMessage::Binary(vec![1, 2, 3]).as_bytes(), &[1, 2, 3]);
260        assert_eq!(OwnedMessage::Ping(vec![4, 5]).as_bytes(), &[4, 5]);
261        assert_eq!(OwnedMessage::Pong(vec![6]).as_bytes(), &[6]);
262        // Close returns reason bytes only (excludes 2-byte status code)
263        let close = OwnedMessage::Close(OwnedCloseFrame {
264            code: CloseCode::Normal,
265            reason: "bye".into(),
266        });
267        assert_eq!(close.as_bytes(), b"bye");
268    }
269
270    #[cfg(feature = "bytes")]
271    #[test]
272    fn owned_message_to_bytes() {
273        let text = OwnedMessage::Text("hello".into());
274        let b = text.to_bytes();
275        assert_eq!(&b[..], b"hello");
276
277        let binary = OwnedMessage::Binary(vec![1, 2, 3]);
278        let b = binary.to_bytes();
279        assert_eq!(&b[..], &[1, 2, 3]);
280
281        let ping = OwnedMessage::Ping(vec![4, 5]);
282        let b = ping.to_bytes();
283        assert_eq!(&b[..], &[4, 5]);
284
285        // Close → reason bytes only
286        let close = OwnedMessage::Close(OwnedCloseFrame {
287            code: CloseCode::Normal,
288            reason: "bye".into(),
289        });
290        let b = close.to_bytes();
291        assert_eq!(&b[..], b"bye");
292    }
293}