Skip to main content

inline_client/
realtime.rs

1//! Realtime connector boundary.
2//!
3//! The full client will own a long-lived realtime manager. This module starts
4//! with the smaller production seam needed by hosts: a connector that can
5//! validate credentials and establish the SDK realtime protocol handshake, plus
6//! a fake connector for deterministic tests.
7
8use std::{
9    fmt,
10    sync::{Arc, Mutex},
11};
12
13use futures_util::future::BoxFuture;
14use inline_sdk::{ClientIdentity, RealtimeClient, RealtimeError};
15
16use crate::{AuthToken, BackendError, BackendResult, ClientErrorCategory};
17
18/// Realtime handshake input.
19#[derive(Clone, PartialEq, Eq)]
20pub struct RealtimeConnectRequest {
21    realtime_url: String,
22    auth_token: AuthToken,
23    identity: ClientIdentity,
24}
25
26impl RealtimeConnectRequest {
27    /// Creates a realtime connect request.
28    pub fn new(
29        realtime_url: impl Into<String>,
30        auth_token: AuthToken,
31        identity: ClientIdentity,
32    ) -> Self {
33        Self {
34            realtime_url: realtime_url.into(),
35            auth_token,
36            identity,
37        }
38    }
39
40    /// Returns the realtime URL.
41    pub fn realtime_url(&self) -> &str {
42        &self.realtime_url
43    }
44
45    /// Returns the auth token.
46    pub fn auth_token(&self) -> &AuthToken {
47        &self.auth_token
48    }
49
50    /// Returns the client identity.
51    pub fn identity(&self) -> &ClientIdentity {
52        &self.identity
53    }
54}
55
56impl fmt::Debug for RealtimeConnectRequest {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.debug_struct("RealtimeConnectRequest")
59            .field("realtime_url", &redacted_url_for_debug(&self.realtime_url))
60            .field("auth_token", &"[redacted]")
61            .field("identity", &self.identity)
62            .finish()
63    }
64}
65
66/// Successful realtime connection summary.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct RealtimeConnectionInfo {
69    /// Realtime URL that was connected.
70    pub realtime_url: String,
71    /// Client identity used for the handshake.
72    pub identity: ClientIdentity,
73}
74
75impl RealtimeConnectionInfo {
76    /// Creates connection info.
77    pub fn new(realtime_url: impl Into<String>, identity: ClientIdentity) -> Self {
78        Self {
79            realtime_url: realtime_url.into(),
80            identity,
81        }
82    }
83}
84
85/// Realtime connector trait.
86pub trait RealtimeConnector: fmt::Debug + Send + Sync + 'static {
87    /// Establishes a realtime protocol handshake.
88    fn connect(
89        &self,
90        request: RealtimeConnectRequest,
91    ) -> BoxFuture<'static, BackendResult<RealtimeConnectionInfo>>;
92}
93
94/// SDK-backed realtime connector.
95#[derive(Clone, Debug, Default)]
96pub struct SdkRealtimeConnector;
97
98impl SdkRealtimeConnector {
99    /// Creates a new SDK realtime connector.
100    pub fn new() -> Self {
101        Self
102    }
103}
104
105impl RealtimeConnector for SdkRealtimeConnector {
106    fn connect(
107        &self,
108        request: RealtimeConnectRequest,
109    ) -> BoxFuture<'static, BackendResult<RealtimeConnectionInfo>> {
110        Box::pin(async move {
111            let identity = request.identity;
112            let realtime_url = request.realtime_url;
113            let token = request.auth_token;
114            let debug_url = redacted_url_for_debug(&realtime_url);
115            log::debug!(
116                "connecting Inline realtime transport at {debug_url} as {}",
117                identity.client_type()
118            );
119            let _client = RealtimeClient::connect_with_identity(
120                &realtime_url,
121                token.expose_secret(),
122                identity.clone(),
123            )
124            .await
125            .map_err(realtime_error_to_backend)?;
126            log::debug!("Inline realtime transport handshake completed");
127            Ok(RealtimeConnectionInfo::new(realtime_url, identity))
128        })
129    }
130}
131
132/// Fake realtime connector for tests.
133#[derive(Clone, Debug, Default)]
134pub struct FakeRealtimeConnector {
135    state: Arc<Mutex<FakeRealtimeState>>,
136}
137
138#[derive(Clone, Debug, Default)]
139struct FakeRealtimeState {
140    attempts: Vec<FakeRealtimeAttempt>,
141    failure: Option<BackendError>,
142}
143
144/// Redacted fake realtime connect attempt.
145#[derive(Clone, Debug, PartialEq, Eq)]
146pub struct FakeRealtimeAttempt {
147    /// Realtime URL used for the attempt.
148    pub realtime_url: String,
149    /// Client identity used for the attempt.
150    pub identity: ClientIdentity,
151}
152
153impl FakeRealtimeConnector {
154    /// Creates a fake connector that succeeds.
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Creates a fake connector that fails with the provided error.
160    pub fn failing(error: BackendError) -> Self {
161        Self {
162            state: Arc::new(Mutex::new(FakeRealtimeState {
163                attempts: Vec::new(),
164                failure: Some(error),
165            })),
166        }
167    }
168
169    /// Returns all redacted connection attempts.
170    pub fn attempts(&self) -> Vec<FakeRealtimeAttempt> {
171        self.state
172            .lock()
173            .expect("fake realtime connector poisoned")
174            .attempts
175            .clone()
176    }
177}
178
179impl RealtimeConnector for FakeRealtimeConnector {
180    fn connect(
181        &self,
182        request: RealtimeConnectRequest,
183    ) -> BoxFuture<'static, BackendResult<RealtimeConnectionInfo>> {
184        let connector = self.clone();
185        Box::pin(async move {
186            let mut state = connector
187                .state
188                .lock()
189                .expect("fake realtime connector poisoned");
190            state.attempts.push(FakeRealtimeAttempt {
191                realtime_url: request.realtime_url.clone(),
192                identity: request.identity.clone(),
193            });
194            if let Some(error) = state.failure.clone() {
195                return Err(error);
196            }
197            Ok(RealtimeConnectionInfo::new(
198                request.realtime_url,
199                request.identity,
200            ))
201        })
202    }
203}
204
205fn realtime_error_to_backend(error: RealtimeError) -> BackendError {
206    match error {
207        RealtimeError::InvalidUrl { message, .. } => {
208            BackendError::new(ClientErrorCategory::InvalidInput, message)
209        }
210        RealtimeError::Timeout { .. } => {
211            BackendError::new(ClientErrorCategory::Timeout, error.to_string())
212        }
213        RealtimeError::ConnectionError { .. } | RealtimeError::RpcError { .. } => {
214            BackendError::new(ClientErrorCategory::AuthExpired, error.to_string())
215        }
216        RealtimeError::ConnectionClosed | RealtimeError::WebSocket(_) => {
217            BackendError::new(ClientErrorCategory::Network, error.to_string())
218        }
219        RealtimeError::InvalidHeaderValue { .. }
220        | RealtimeError::Protocol(_)
221        | RealtimeError::MissingResult
222        | RealtimeError::UnexpectedResult { .. } => {
223            BackendError::new(ClientErrorCategory::ProtocolMismatch, error.to_string())
224        }
225        _ => BackendError::new(ClientErrorCategory::Internal, error.to_string()),
226    }
227}
228
229pub(crate) fn redacted_url_for_debug(url: &str) -> String {
230    let without_fragment = url.split('#').next().unwrap_or(url);
231    let without_query = without_fragment
232        .split('?')
233        .next()
234        .unwrap_or(without_fragment);
235    match without_query.split_once("://") {
236        Some((scheme, rest)) => {
237            let host_and_path = rest.rsplit_once('@').map_or(rest, |(_, tail)| tail);
238            format!("{scheme}://{host_and_path}")
239        }
240        None => without_query.to_owned(),
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn realtime_connect_request_debug_redacts_token_and_url_credentials() {
250        let request = RealtimeConnectRequest::new(
251            "wss://user:secret@api.inline.chat/realtime?token=secret#frag",
252            AuthToken::try_new("secret-token").unwrap(),
253            ClientIdentity::new("test", "0.1.0"),
254        );
255
256        let rendered = format!("{request:?}");
257        assert!(rendered.contains("wss://api.inline.chat/realtime"));
258        assert!(!rendered.contains("secret-token"));
259        assert!(!rendered.contains("token="));
260    }
261
262    #[tokio::test]
263    async fn fake_realtime_connector_records_redacted_attempts() {
264        let connector = FakeRealtimeConnector::new();
265        let request = RealtimeConnectRequest::new(
266            "wss://api.inline.chat/realtime",
267            AuthToken::try_new("secret-token").unwrap(),
268            ClientIdentity::new("test", "0.1.0"),
269        );
270
271        let info = connector.connect(request).await.unwrap();
272
273        assert_eq!(info.realtime_url, "wss://api.inline.chat/realtime");
274        let attempts = connector.attempts();
275        assert_eq!(attempts.len(), 1);
276        assert_eq!(attempts[0].identity.client_type(), "test");
277    }
278
279    #[tokio::test]
280    async fn fake_realtime_connector_can_fail() {
281        let connector = FakeRealtimeConnector::failing(BackendError::new(
282            ClientErrorCategory::Network,
283            "offline",
284        ));
285        let request = RealtimeConnectRequest::new(
286            "wss://api.inline.chat/realtime",
287            AuthToken::try_new("secret-token").unwrap(),
288            ClientIdentity::new("test", "0.1.0"),
289        );
290
291        let error = connector.connect(request).await.unwrap_err();
292
293        assert_eq!(error.category, ClientErrorCategory::Network);
294    }
295}