Skip to main content

rama_http/layer/har/spec/
websocket.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use rama_utils::str::arcstr::ArcStr;
3use serde::{Deserialize, Serialize};
4
5/// A Chrome DevTools WebSocket message stored in an HAR entry's
6/// `_webSocketMessages` extension.
7///
8/// Chrome exports complete text and binary messages rather than fragmented wire
9/// frames, plus protocol errors. Control frames and connection-close events are
10/// not included. The shape is unchanged from the [Chrome 76 announcement] and
11/// the current Chromium DevTools HAR exporter.
12///
13/// [Chrome 76 announcement]: https://developer.chrome.com/blog/new-in-devtools-76#websocket
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct WebSocketMessage {
16    /// Whether the message was sent, received, or reports a protocol error.
17    pub r#type: WebSocketMessageType,
18    /// Unix timestamp in seconds, including any fractional part.
19    ///
20    /// Unlike HAR's `startedDateTime`, Chromium emits this as a JSON number.
21    pub time: f64,
22    /// WebSocket opcode, or [`WebSocketMessageOpcode::ERROR`] for an error record.
23    pub opcode: WebSocketMessageOpcode,
24    /// Message payload.
25    ///
26    /// Text messages and error records contain their original text. Chromium's
27    /// DevTools Protocol supplies non-text message data as base64, so binary
28    /// messages use a base64 string here.
29    pub data: ArcStr,
30}
31
32impl WebSocketMessage {
33    /// Create a message with an explicitly supplied opcode and already encoded data.
34    #[must_use]
35    pub fn new(
36        r#type: WebSocketMessageType,
37        time: f64,
38        opcode: WebSocketMessageOpcode,
39        data: impl Into<ArcStr>,
40    ) -> Self {
41        Self {
42            r#type,
43            time,
44            opcode,
45            data: data.into(),
46        }
47    }
48
49    /// Create a text message (`opcode: 1`).
50    #[must_use]
51    pub fn text(r#type: WebSocketMessageType, time: f64, data: impl Into<ArcStr>) -> Self {
52        Self::new(r#type, time, WebSocketMessageOpcode::TEXT, data)
53    }
54
55    /// Create a binary message (`opcode: 2`), base64-encoding the supplied bytes.
56    #[must_use]
57    pub fn binary(r#type: WebSocketMessageType, time: f64, data: impl AsRef<[u8]>) -> Self {
58        Self::binary_with_opcode(r#type, time, WebSocketMessageOpcode::BINARY, data)
59    }
60
61    /// Create a non-text message and base64-encode its payload.
62    #[must_use]
63    pub fn binary_with_opcode(
64        r#type: WebSocketMessageType,
65        time: f64,
66        opcode: WebSocketMessageOpcode,
67        data: impl AsRef<[u8]>,
68    ) -> Self {
69        Self::new(r#type, time, opcode, STANDARD.encode(data))
70    }
71
72    /// Create the error record shape used by Chromium (`type: "error"`, `opcode: -1`).
73    #[must_use]
74    pub fn error(time: f64, message: impl Into<ArcStr>) -> Self {
75        Self::new(
76            WebSocketMessageType::Error,
77            time,
78            WebSocketMessageOpcode::ERROR,
79            message,
80        )
81    }
82
83    /// Decode a binary message's base64 payload.
84    ///
85    /// Returns `None` for other opcodes. A malformed base64 payload remains visible
86    /// as a decode error instead of being silently discarded.
87    pub fn binary_data(&self) -> Option<Result<Vec<u8>, base64::DecodeError>> {
88        (self.opcode == WebSocketMessageOpcode::BINARY)
89            .then(|| STANDARD.decode(self.data.as_bytes()))
90    }
91}
92
93/// The `type` values accepted and emitted by current Chromium DevTools.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum WebSocketMessageType {
97    /// A message sent by the inspected client.
98    Send,
99    /// A message received by the inspected client.
100    Receive,
101    /// A WebSocket protocol error recorded by DevTools.
102    Error,
103}
104
105/// Numeric opcode accepted by Chromium's `_webSocketMessages` importer.
106///
107/// This is intentionally an open newtype rather than a closed enum: Chromium's HAR
108/// importer accepts any numeric opcode, and retaining unknown values makes imported
109/// archives forward compatible. Chrome itself currently emits text, binary, and its
110/// `-1` error sentinel; the other constants allow lossless handling of archives
111/// produced by compatible extensions.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
113#[serde(transparent)]
114pub struct WebSocketMessageOpcode(i32);
115
116impl WebSocketMessageOpcode {
117    pub const ERROR: Self = Self(-1);
118    pub const CONTINUATION: Self = Self(0);
119    pub const TEXT: Self = Self(1);
120    pub const BINARY: Self = Self(2);
121    pub const CLOSE: Self = Self(8);
122    pub const PING: Self = Self(9);
123    pub const PONG: Self = Self(10);
124
125    #[must_use]
126    pub const fn new(value: i32) -> Self {
127        Self(value)
128    }
129
130    #[must_use]
131    pub const fn as_i32(self) -> i32 {
132        self.0
133    }
134}
135
136impl From<i32> for WebSocketMessageOpcode {
137    fn from(value: i32) -> Self {
138        Self::new(value)
139    }
140}
141
142impl From<WebSocketMessageOpcode> for i32 {
143    fn from(value: WebSocketMessageOpcode) -> Self {
144        value.as_i32()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152
153    const TIME: f64 = 1_558_730_482.507_147_3;
154
155    #[test]
156    fn serializes_in_chromium_shape() {
157        let message =
158            WebSocketMessage::text(WebSocketMessageType::Send, TIME, "Hello, WebSockets!");
159
160        assert_eq!(
161            serde_json::to_value(message).expect("serialize text message"),
162            json!({
163                "type": "send",
164                "time": TIME,
165                "opcode": 1,
166                "data": "Hello, WebSockets!",
167            })
168        );
169    }
170
171    #[test]
172    fn binary_constructor_base64_encodes_and_decodes_payload() {
173        let bytes = [0x00, 0x01, 0x02, 0xaa];
174        let message = WebSocketMessage::binary(WebSocketMessageType::Receive, TIME, bytes);
175
176        assert_eq!(message.opcode, WebSocketMessageOpcode::BINARY);
177        assert_eq!(message.data.as_str(), "AAECqg==");
178        assert_eq!(
179            message
180                .binary_data()
181                .expect("binary opcode")
182                .expect("valid base64"),
183            bytes
184        );
185    }
186
187    #[test]
188    fn binary_data_rejects_non_binary_messages_and_invalid_base64() {
189        let text = WebSocketMessage::text(WebSocketMessageType::Send, TIME, "hello");
190        assert!(text.binary_data().is_none());
191
192        let malformed = WebSocketMessage::new(
193            WebSocketMessageType::Receive,
194            TIME,
195            WebSocketMessageOpcode::BINARY,
196            "not base64!",
197        );
198        malformed
199            .binary_data()
200            .expect("binary opcode")
201            .expect_err("malformed base64 must fail");
202    }
203
204    #[test]
205    fn error_shape_uses_signed_opcode_sentinel() {
206        let message = WebSocketMessage::error(TIME, "Invalid frame header");
207
208        assert_eq!(
209            serde_json::to_value(&message).expect("serialize error message"),
210            json!({
211                "type": "error",
212                "time": TIME,
213                "opcode": -1,
214                "data": "Invalid frame header",
215            })
216        );
217        assert_eq!(message.opcode.as_i32(), -1);
218    }
219
220    #[test]
221    fn message_round_trip_preserves_all_types_and_unknown_opcodes() {
222        let messages = vec![
223            WebSocketMessage::text(WebSocketMessageType::Send, TIME, "sent"),
224            WebSocketMessage::binary(WebSocketMessageType::Receive, TIME + 1.0, [1, 2, 3]),
225            WebSocketMessage::error(TIME + 2.0, "failed"),
226            WebSocketMessage::new(
227                WebSocketMessageType::Receive,
228                TIME + 3.0,
229                WebSocketMessageOpcode::new(42),
230                "future opcode",
231            ),
232        ];
233
234        let encoded = serde_json::to_vec(&messages).expect("serialize messages");
235        let decoded: Vec<WebSocketMessage> =
236            serde_json::from_slice(&encoded).expect("deserialize messages");
237        assert_eq!(decoded, messages);
238        assert_eq!(decoded[3].opcode.as_i32(), 42);
239    }
240
241    #[test]
242    fn rfc_opcode_constants_have_expected_wire_values() {
243        assert_eq!(WebSocketMessageOpcode::CONTINUATION.as_i32(), 0);
244        assert_eq!(WebSocketMessageOpcode::TEXT.as_i32(), 1);
245        assert_eq!(WebSocketMessageOpcode::BINARY.as_i32(), 2);
246        assert_eq!(WebSocketMessageOpcode::CLOSE.as_i32(), 8);
247        assert_eq!(WebSocketMessageOpcode::PING.as_i32(), 9);
248        assert_eq!(WebSocketMessageOpcode::PONG.as_i32(), 10);
249    }
250
251    #[test]
252    fn opcode_integer_conversions_preserve_value() {
253        let opcode = WebSocketMessageOpcode::from(42);
254        assert_eq!(opcode, WebSocketMessageOpcode::new(42));
255        assert_eq!(i32::from(opcode), 42);
256    }
257}