Skip to main content

sockudo_protocol/
protocol_version.rs

1use serde::{Deserialize, Serialize};
2
3/// Protocol version negotiated per-connection.
4///
5/// - V1 (default): Pusher-compatible. Uses `pusher:` / `pusher_internal:` prefixes.
6///   Features like serial, message_id, and recovery are opt-in via server config.
7/// - V2: Sockudo-native. Uses `sockudo:` / `sockudo_internal:` prefixes.
8///   Serial, message_id, and connection recovery are always enabled.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
10#[repr(u8)]
11pub enum ProtocolVersion {
12    #[default]
13    V1 = 1,
14    V2 = 2,
15}
16
17impl ProtocolVersion {
18    /// Parse from the `?protocol=` query parameter value.
19    /// Returns V1 for unrecognized values.
20    pub fn from_query_param(value: Option<u8>) -> Self {
21        match value {
22            Some(2) => Self::V2,
23            _ => Self::V1,
24        }
25    }
26
27    /// Event prefix for this protocol version (e.g. `"pusher:"` or `"sockudo:"`).
28    #[inline]
29    pub const fn event_prefix(&self) -> &'static str {
30        match self {
31            Self::V1 => "pusher:",
32            Self::V2 => "sockudo:",
33        }
34    }
35
36    /// Internal event prefix (e.g. `"pusher_internal:"` or `"sockudo_internal:"`).
37    #[inline]
38    pub const fn internal_prefix(&self) -> &'static str {
39        match self {
40            Self::V1 => "pusher_internal:",
41            Self::V2 => "sockudo_internal:",
42        }
43    }
44
45    /// Whether serial numbers are native (always-on) for this protocol version.
46    #[inline]
47    pub const fn is_serial_native(&self) -> bool {
48        matches!(self, Self::V2)
49    }
50
51    /// Whether message_id is native (always-on) for this protocol version.
52    #[inline]
53    pub const fn is_message_id_native(&self) -> bool {
54        matches!(self, Self::V2)
55    }
56
57    /// Whether connection recovery is native (always-on) for this protocol version.
58    #[inline]
59    pub const fn is_recovery_native(&self) -> bool {
60        matches!(self, Self::V2)
61    }
62
63    /// Build a wire-format event name from a canonical name.
64    /// E.g. `"subscribe"` → `"pusher:subscribe"` or `"sockudo:subscribe"`.
65    #[inline]
66    pub fn wire_event(&self, canonical: &str) -> String {
67        format!("{}{}", self.event_prefix(), canonical)
68    }
69
70    /// Build a wire-format internal event name from a canonical name.
71    /// E.g. `"subscription_succeeded"` → `"pusher_internal:subscription_succeeded"`.
72    #[inline]
73    pub fn wire_internal_event(&self, canonical: &str) -> String {
74        format!("{}{}", self.internal_prefix(), canonical)
75    }
76
77    /// Strip the protocol prefix from a wire-format event name, returning the canonical name.
78    /// Returns `None` if the event doesn't match this version's prefix.
79    pub fn parse_event_name<'a>(&self, wire: &'a str) -> Option<&'a str> {
80        wire.strip_prefix(self.event_prefix())
81            .or_else(|| wire.strip_prefix(self.internal_prefix()))
82    }
83
84    /// Determine if a wire event name belongs to ANY known protocol version,
85    /// returning the canonical name and whether it's internal.
86    pub fn parse_any_protocol_event(wire: &str) -> Option<(&str, bool)> {
87        if let Some(canonical) = wire.strip_prefix("pusher:") {
88            Some((canonical, false))
89        } else if let Some(canonical) = wire.strip_prefix("pusher_internal:") {
90            Some((canonical, true))
91        } else if let Some(canonical) = wire.strip_prefix("sockudo:") {
92            Some((canonical, false))
93        } else if let Some(canonical) = wire.strip_prefix("sockudo_internal:") {
94            Some((canonical, true))
95        } else {
96            None
97        }
98    }
99
100    /// Rewrite a `pusher:` or `pusher_internal:` event name to this version's prefix.
101    /// If the event doesn't have a known prefix, returns it unchanged.
102    pub fn rewrite_event_prefix(&self, event: &str) -> String {
103        if let Some((canonical, is_internal)) = Self::parse_any_protocol_event(event) {
104            if is_internal {
105                self.wire_internal_event(canonical)
106            } else {
107                self.wire_event(canonical)
108            }
109        } else {
110            event.to_string()
111        }
112    }
113}
114
115// Canonical event names (without prefix)
116pub const CANONICAL_CONNECTION_ESTABLISHED: &str = "connection_established";
117pub const CANONICAL_ERROR: &str = "error";
118pub const CANONICAL_PING: &str = "ping";
119pub const CANONICAL_PONG: &str = "pong";
120pub const CANONICAL_SUBSCRIBE: &str = "subscribe";
121pub const CANONICAL_UNSUBSCRIBE: &str = "unsubscribe";
122pub const CANONICAL_SIGNIN: &str = "signin";
123pub const CANONICAL_SIGNIN_SUCCESS: &str = "signin_success";
124pub const CANONICAL_CACHE_MISS: &str = "cache_miss";
125
126// Canonical internal event names (without prefix)
127pub const CANONICAL_SUBSCRIPTION_SUCCEEDED: &str = "subscription_succeeded";
128pub const CANONICAL_SUBSCRIPTION_ERROR: &str = "subscription_error";
129pub const CANONICAL_MEMBER_ADDED: &str = "member_added";
130pub const CANONICAL_MEMBER_REMOVED: &str = "member_removed";
131
132// Delta compression canonical names
133pub const CANONICAL_ENABLE_DELTA_COMPRESSION: &str = "enable_delta_compression";
134pub const CANONICAL_DELTA_COMPRESSION_ENABLED: &str = "delta_compression_enabled";
135pub const CANONICAL_DELTA: &str = "delta";
136pub const CANONICAL_DELTA_CACHE_SYNC: &str = "delta_cache_sync";
137pub const CANONICAL_DELTA_SYNC_ERROR: &str = "delta_sync_error";
138
139// Connection recovery canonical names
140pub const CANONICAL_RESUME: &str = "resume";
141pub const CANONICAL_RESUME_SUCCESS: &str = "resume_success";
142pub const CANONICAL_RESUME_FAILED: &str = "resume_failed";
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_v1_prefix() {
150        assert_eq!(ProtocolVersion::V1.event_prefix(), "pusher:");
151        assert_eq!(ProtocolVersion::V1.internal_prefix(), "pusher_internal:");
152    }
153
154    #[test]
155    fn test_v2_prefix() {
156        assert_eq!(ProtocolVersion::V2.event_prefix(), "sockudo:");
157        assert_eq!(ProtocolVersion::V2.internal_prefix(), "sockudo_internal:");
158    }
159
160    #[test]
161    fn test_wire_event() {
162        assert_eq!(
163            ProtocolVersion::V1.wire_event("subscribe"),
164            "pusher:subscribe"
165        );
166        assert_eq!(
167            ProtocolVersion::V2.wire_event("subscribe"),
168            "sockudo:subscribe"
169        );
170    }
171
172    #[test]
173    fn test_wire_internal_event() {
174        assert_eq!(
175            ProtocolVersion::V1.wire_internal_event("subscription_succeeded"),
176            "pusher_internal:subscription_succeeded"
177        );
178        assert_eq!(
179            ProtocolVersion::V2.wire_internal_event("subscription_succeeded"),
180            "sockudo_internal:subscription_succeeded"
181        );
182    }
183
184    #[test]
185    fn test_parse_event_name() {
186        assert_eq!(
187            ProtocolVersion::V1.parse_event_name("pusher:ping"),
188            Some("ping")
189        );
190        assert_eq!(ProtocolVersion::V1.parse_event_name("sockudo:ping"), None);
191        assert_eq!(
192            ProtocolVersion::V2.parse_event_name("sockudo:ping"),
193            Some("ping")
194        );
195        assert_eq!(ProtocolVersion::V2.parse_event_name("pusher:ping"), None);
196    }
197
198    #[test]
199    fn test_parse_any_protocol_event() {
200        assert_eq!(
201            ProtocolVersion::parse_any_protocol_event("pusher:subscribe"),
202            Some(("subscribe", false))
203        );
204        assert_eq!(
205            ProtocolVersion::parse_any_protocol_event("sockudo:subscribe"),
206            Some(("subscribe", false))
207        );
208        assert_eq!(
209            ProtocolVersion::parse_any_protocol_event("pusher_internal:member_added"),
210            Some(("member_added", true))
211        );
212        assert_eq!(
213            ProtocolVersion::parse_any_protocol_event("sockudo_internal:member_added"),
214            Some(("member_added", true))
215        );
216        assert_eq!(
217            ProtocolVersion::parse_any_protocol_event("client-event"),
218            None
219        );
220    }
221
222    #[test]
223    fn test_rewrite_event_prefix() {
224        // V1 → V2
225        assert_eq!(
226            ProtocolVersion::V2.rewrite_event_prefix("pusher:subscribe"),
227            "sockudo:subscribe"
228        );
229        assert_eq!(
230            ProtocolVersion::V2.rewrite_event_prefix("pusher_internal:member_added"),
231            "sockudo_internal:member_added"
232        );
233
234        // V2 → V1
235        assert_eq!(
236            ProtocolVersion::V1.rewrite_event_prefix("sockudo:subscribe"),
237            "pusher:subscribe"
238        );
239
240        // Non-protocol event stays unchanged
241        assert_eq!(
242            ProtocolVersion::V2.rewrite_event_prefix("client-my-event"),
243            "client-my-event"
244        );
245    }
246
247    #[test]
248    fn test_from_query_param() {
249        assert_eq!(ProtocolVersion::from_query_param(None), ProtocolVersion::V1);
250        assert_eq!(
251            ProtocolVersion::from_query_param(Some(1)),
252            ProtocolVersion::V1
253        );
254        assert_eq!(
255            ProtocolVersion::from_query_param(Some(2)),
256            ProtocolVersion::V2
257        );
258        assert_eq!(
259            ProtocolVersion::from_query_param(Some(99)),
260            ProtocolVersion::V1
261        );
262    }
263
264    #[test]
265    fn test_v2_native_features() {
266        assert!(!ProtocolVersion::V1.is_serial_native());
267        assert!(!ProtocolVersion::V1.is_message_id_native());
268        assert!(!ProtocolVersion::V1.is_recovery_native());
269
270        assert!(ProtocolVersion::V2.is_serial_native());
271        assert!(ProtocolVersion::V2.is_message_id_native());
272        assert!(ProtocolVersion::V2.is_recovery_native());
273    }
274}