Skip to main content

clasp_core/
p2p.rs

1//! P2P WebRTC types and signaling primitives
2//!
3//! This module provides core types for WebRTC peer-to-peer connections:
4//! - Signaling messages (offers, answers, ICE candidates)
5//! - P2P configuration
6//! - Reserved namespaces for P2P signaling
7
8use serde::{Deserialize, Serialize};
9
10/// Reserved P2P namespace prefix
11pub const P2P_NAMESPACE: &str = "/clasp/p2p";
12
13/// Address for P2P signaling to a specific session
14/// Format: /clasp/p2p/signal/{target_session_id}
15pub const P2P_SIGNAL_PREFIX: &str = "/clasp/p2p/signal/";
16
17/// Address for P2P capability announcements (broadcast)
18pub const P2P_ANNOUNCE: &str = "/clasp/p2p/announce";
19
20/// Default connection timeout in seconds
21pub const DEFAULT_CONNECTION_TIMEOUT_SECS: u64 = 30;
22
23/// Default maximum connection retries
24pub const DEFAULT_MAX_RETRIES: u32 = 3;
25
26/// P2P signaling message types
27///
28/// These messages are sent via PUBLISH to `/clasp/p2p/signal/{target_session_id}`
29/// and relayed by the router to the target peer.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(tag = "type", rename_all = "snake_case")]
32pub enum P2PSignal {
33    /// SDP offer to initiate connection
34    Offer {
35        /// Session ID of the sender
36        from: String,
37        /// SDP offer string
38        sdp: String,
39        /// Correlation ID for matching offer/answer
40        correlation_id: String,
41    },
42    /// SDP answer in response to offer
43    Answer {
44        /// Session ID of the sender
45        from: String,
46        /// SDP answer string
47        sdp: String,
48        /// Correlation ID matching the offer
49        correlation_id: String,
50    },
51    /// ICE candidate for NAT traversal
52    IceCandidate {
53        /// Session ID of the sender
54        from: String,
55        /// ICE candidate JSON string
56        candidate: String,
57        /// Correlation ID for the connection
58        correlation_id: String,
59    },
60    /// P2P connection established notification
61    Connected {
62        /// Session ID of the sender
63        from: String,
64        /// Correlation ID for the connection
65        correlation_id: String,
66    },
67    /// P2P connection closed notification
68    Disconnected {
69        /// Session ID of the sender
70        from: String,
71        /// Correlation ID for the connection
72        correlation_id: String,
73        /// Reason for disconnection
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        reason: Option<String>,
76    },
77}
78
79impl P2PSignal {
80    /// Get the sender's session ID
81    pub fn from_session(&self) -> &str {
82        match self {
83            P2PSignal::Offer { from, .. } => from,
84            P2PSignal::Answer { from, .. } => from,
85            P2PSignal::IceCandidate { from, .. } => from,
86            P2PSignal::Connected { from, .. } => from,
87            P2PSignal::Disconnected { from, .. } => from,
88        }
89    }
90
91    /// Get the correlation ID
92    pub fn correlation_id(&self) -> &str {
93        match self {
94            P2PSignal::Offer { correlation_id, .. } => correlation_id,
95            P2PSignal::Answer { correlation_id, .. } => correlation_id,
96            P2PSignal::IceCandidate { correlation_id, .. } => correlation_id,
97            P2PSignal::Connected { correlation_id, .. } => correlation_id,
98            P2PSignal::Disconnected { correlation_id, .. } => correlation_id,
99        }
100    }
101}
102
103/// P2P announce message for capability advertisement
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct P2PAnnounce {
106    /// Session ID of the announcing peer
107    pub session_id: String,
108    /// Whether this peer supports P2P connections
109    pub p2p_capable: bool,
110    /// Supported features (e.g., "webrtc", "reliable", "unreliable")
111    #[serde(default)]
112    pub features: Vec<String>,
113}
114
115/// P2P connection configuration
116#[derive(Debug, Clone)]
117pub struct P2PConfig {
118    /// ICE servers for NAT traversal (STUN/TURN URLs)
119    pub ice_servers: Vec<String>,
120    /// Optional TURN servers for symmetric NAT traversal
121    pub turn_servers: Vec<TurnServer>,
122    /// Connection timeout duration
123    pub connection_timeout_secs: u64,
124    /// Maximum retry attempts for failed connections
125    pub max_retries: u32,
126    /// Whether to automatically fall back to server relay on P2P failure
127    pub auto_fallback: bool,
128}
129
130impl Default for P2PConfig {
131    fn default() -> Self {
132        Self {
133            ice_servers: vec![
134                "stun:stun.l.google.com:19302".to_string(),
135                "stun:stun1.l.google.com:19302".to_string(),
136            ],
137            turn_servers: Vec::new(),
138            connection_timeout_secs: DEFAULT_CONNECTION_TIMEOUT_SECS,
139            max_retries: DEFAULT_MAX_RETRIES,
140            auto_fallback: true,
141        }
142    }
143}
144
145/// TURN server configuration
146#[derive(Debug, Clone)]
147pub struct TurnServer {
148    /// TURN server URL
149    pub url: String,
150    /// Username for authentication
151    pub username: String,
152    /// Credential for authentication
153    pub credential: String,
154}
155
156/// P2P connection state
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum P2PConnectionState {
159    /// Initial state, not connected
160    Disconnected,
161    /// Signaling in progress
162    Connecting,
163    /// ICE candidates being exchanged
164    GatheringCandidates,
165    /// WebRTC connection established
166    Connected,
167    /// Connection failed
168    Failed,
169    /// Connection closed
170    Closed,
171}
172
173/// Routing mode for message delivery
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub enum RoutingMode {
176    /// Only use server relay (no P2P)
177    ServerOnly,
178    /// Only use P2P (fail if unavailable)
179    P2POnly,
180    /// Prefer P2P, fall back to server
181    #[default]
182    PreferP2P,
183}
184
185/// Check if an address is in the P2P namespace
186pub fn is_p2p_address(address: &str) -> bool {
187    address.starts_with(P2P_NAMESPACE)
188}
189
190/// Check if an address is a P2P signal address
191pub fn is_p2p_signal_address(address: &str) -> bool {
192    address.starts_with(P2P_SIGNAL_PREFIX)
193}
194
195/// Extract the target session ID from a P2P signal address
196///
197/// Returns None if the address is not a valid P2P signal address
198pub fn extract_target_session(address: &str) -> Option<&str> {
199    if let Some(target) = address.strip_prefix(P2P_SIGNAL_PREFIX) {
200        if !target.is_empty() && !target.contains('/') {
201            return Some(target);
202        }
203    }
204    None
205}
206
207/// Create a P2P signal address for a target session
208pub fn signal_address(target_session_id: &str) -> String {
209    format!("{}{}", P2P_SIGNAL_PREFIX, target_session_id)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_p2p_signal_serialization() {
218        let offer = P2PSignal::Offer {
219            from: "session-123".to_string(),
220            sdp: "v=0\r\n...".to_string(),
221            correlation_id: "conn-456".to_string(),
222        };
223
224        let json = serde_json::to_string(&offer).unwrap();
225        assert!(json.contains("\"type\":\"offer\""));
226        assert!(json.contains("\"from\":\"session-123\""));
227
228        let parsed: P2PSignal = serde_json::from_str(&json).unwrap();
229        assert_eq!(parsed, offer);
230    }
231
232    #[test]
233    fn test_is_p2p_address() {
234        assert!(is_p2p_address("/clasp/p2p/signal/abc"));
235        assert!(is_p2p_address("/clasp/p2p/announce"));
236        assert!(!is_p2p_address("/lumen/scene/0/opacity"));
237        assert!(!is_p2p_address("/clasp/other"));
238    }
239
240    #[test]
241    fn test_extract_target_session() {
242        assert_eq!(
243            extract_target_session("/clasp/p2p/signal/session-123"),
244            Some("session-123")
245        );
246        assert_eq!(extract_target_session("/clasp/p2p/signal/"), None);
247        assert_eq!(extract_target_session("/clasp/p2p/signal/a/b"), None);
248        assert_eq!(extract_target_session("/other/path"), None);
249    }
250
251    #[test]
252    fn test_signal_address() {
253        assert_eq!(
254            signal_address("session-123"),
255            "/clasp/p2p/signal/session-123"
256        );
257    }
258
259    #[test]
260    fn test_p2p_announce_serialization() {
261        let announce = P2PAnnounce {
262            session_id: "session-123".to_string(),
263            p2p_capable: true,
264            features: vec!["webrtc".to_string(), "reliable".to_string()],
265        };
266
267        let json = serde_json::to_string(&announce).unwrap();
268        let parsed: P2PAnnounce = serde_json::from_str(&json).unwrap();
269        assert_eq!(parsed, announce);
270    }
271}