Skip to main content

actioncable/
builder.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use http::header::{COOKIE, ORIGIN};
6use http::{HeaderMap, HeaderName, HeaderValue};
7use url::Url;
8
9use crate::client::Client;
10use crate::error::Error;
11use crate::protocol::Protocol;
12use crate::transport::Transport;
13use crate::v1_json::V1Json;
14
15/// Builds the headers for a dial, asked on every dial rather than once. A client reconnects
16/// on its own for as long as it runs, which is longer than a credential that expires lives,
17/// and a reconnect carrying the token the first dial used would be turned down for good.
18///
19/// What it returns is laid over the headers already set on the builder, so an `Origin` or a
20/// token given with [`ClientBuilder::header`] survives. An error turns down that dial, and
21/// the client tries again on its backoff.
22///
23/// Any `Fn() -> Future<Output = Result<HeaderMap, Error>>` is a provider, so the quick form
24/// is a closure:
25///
26/// ```no_run
27/// use actioncable::{Client, Error};
28/// use http::{HeaderMap, HeaderValue};
29///
30/// # fn build() -> Result<Client, Error> {
31/// let client = Client::builder("wss://example.com/cable")
32///     .header_provider(|| async {
33///         let mut headers = HeaderMap::new();
34///         headers.insert("Authorization", HeaderValue::from_static("Bearer token"));
35///         Ok(headers)
36///     })
37///     .build()?;
38/// # Ok(client)
39/// # }
40/// ```
41///
42/// A credentials type that already knows how to refresh itself implements the trait
43/// directly.
44#[async_trait]
45pub trait HeaderProvider: Send + Sync {
46    /// The headers for the dial about to happen, or [`Error::headers`] wrapping whatever
47    /// stopped them from being built.
48    async fn headers(&self) -> Result<HeaderMap, Error>;
49}
50
51#[async_trait]
52impl<F, Fut> HeaderProvider for F
53where
54    F: Fn() -> Fut + Send + Sync,
55    Fut: Future<Output = Result<HeaderMap, Error>> + Send,
56{
57    async fn headers(&self) -> Result<HeaderMap, Error> {
58        self().await
59    }
60}
61
62/// A shared provider answers like the one it shares, so an application can keep one — and
63/// the credentials behind it — and hand it to every client it builds.
64#[async_trait]
65impl<T: HeaderProvider + ?Sized> HeaderProvider for Arc<T> {
66    async fn headers(&self) -> Result<HeaderMap, Error> {
67        (**self).headers().await
68    }
69}
70
71/// Recognizes the connection errors that retrying cannot repair. See
72/// [`ClientBuilder::stop_on_error`].
73type Terminal = Arc<dyn Fn(&Error) -> bool + Send + Sync>;
74
75/// Configures a [`Client`]. Nothing touches the network until [`Client::connect`].
76pub struct ClientBuilder {
77    url: String,
78    /// `None` until [`transport`](ClientBuilder::transport) is called; `build` then falls
79    /// back to the built-in WebSocket transport when the `websocket` feature supplies one.
80    transport: Option<Arc<dyn Transport>>,
81    protocols: Vec<Arc<dyn Protocol>>,
82    headers: HeaderMap,
83    invalid_header: Option<Error>,
84    header_provider: Option<Arc<dyn HeaderProvider>>,
85    stop_on_error: Option<Terminal>,
86    stale_after: Duration,
87    subscribe_retry: Duration,
88    initial_backoff: Duration,
89    longest_backoff: Duration,
90    max_attempts: u32,
91    message_buffer: usize,
92}
93
94impl ClientBuilder {
95    pub(crate) fn new(url: String) -> ClientBuilder {
96        ClientBuilder {
97            url,
98            transport: None,
99            protocols: vec![Arc::new(V1Json)],
100            headers: HeaderMap::new(),
101            invalid_header: None,
102            header_provider: None,
103            stop_on_error: None,
104            stale_after: Duration::from_secs(6),
105            subscribe_retry: Duration::from_millis(500),
106            initial_backoff: Duration::from_secs(1),
107            longest_backoff: Duration::from_secs(30),
108            max_attempts: 0,
109            message_buffer: 64,
110        }
111    }
112
113    /// Sets the transport: one of your own, or the built-in [`WebSocketTransport`] configured
114    /// differently. Without it `build` uses `WebSocketTransport::default()`, and with the
115    /// `websocket` feature off this is the only way to get a client at all.
116    ///
117    /// [`WebSocketTransport`]: crate::WebSocketTransport
118    pub fn transport(mut self, transport: impl Transport + 'static) -> ClientBuilder {
119        self.transport = Some(Arc::new(transport));
120        self
121    }
122
123    /// Sets the protocols offered during the handshake, most preferred first, replacing the
124    /// default of [`V1Json`]. The server picks one of them and the client speaks it for the
125    /// rest of the connection.
126    pub fn protocols(mut self, protocols: Vec<Arc<dyn Protocol>>) -> ClientBuilder {
127        self.protocols = protocols;
128        self
129    }
130
131    /// Offers protocols ahead of the ones already there, so preferring a new protocol
132    /// doesn't mean restating the ones to fall back to.
133    pub fn prefer_protocols(mut self, protocols: Vec<Arc<dyn Protocol>>) -> ClientBuilder {
134        let mut preferred = protocols;
135        preferred.append(&mut self.protocols);
136        self.protocols = preferred;
137        self
138    }
139
140    /// Sets a header on the upgrade request. An Action Cable server authorizes that request,
141    /// so this is where a session cookie or an API token goes. A name or value that isn't a
142    /// valid HTTP header, one carrying a newline say, fails [`build`](ClientBuilder::build).
143    pub fn header(mut self, name: &str, value: &str) -> ClientBuilder {
144        match parse_header(name, value) {
145            Ok((name, value)) => {
146                self.headers.insert(name, value);
147            }
148            Err(error) => {
149                self.invalid_header.get_or_insert(error);
150            }
151        }
152        self
153    }
154
155    /// Shorthand for one `Cookie` header.
156    pub fn cookie(self, cookie: &str) -> ClientBuilder {
157        self.header(COOKIE.as_str(), cookie)
158    }
159
160    /// Sets the `Origin` header. Rails compares it against the host it serves on and turns
161    /// down anything else, a request carrying no `Origin` at all included, so by default it
162    /// is derived from the cable URL: `wss://example.com/cable` sends
163    /// `https://example.com`. A server behind a proxy that terminates TLS sees a different
164    /// scheme than the URL says and needs this.
165    pub fn origin(self, origin: &str) -> ClientBuilder {
166        self.header(ORIGIN.as_str(), origin)
167    }
168
169    /// Asks `provider` for headers on every dial, laid over the ones set here. For a
170    /// credential that expires; see [`HeaderProvider`].
171    pub fn header_provider(mut self, provider: impl HeaderProvider + 'static) -> ClientBuilder {
172        self.header_provider = Some(Arc::new(provider));
173        self
174    }
175
176    /// Recognizes the connection errors that dialing again cannot repair. The predicate
177    /// sees every one of them — a header provider that refused, a dial that failed, a
178    /// connection that died — and returning `true` stops the client with that error instead
179    /// of reconnecting. [`Error::cause`] hands back the application's own error, for
180    /// `downcast_ref` to recognize:
181    ///
182    /// ```no_run
183    /// # use actioncable::Client;
184    /// # #[derive(Debug, thiserror::Error)]
185    /// # #[error("sign in again")]
186    /// # struct SignedOut;
187    /// let client = Client::builder("wss://example.com/cable")
188    ///     .stop_on_error(|error| error.cause().is_some_and(|cause| cause.is::<SignedOut>()));
189    /// ```
190    ///
191    /// It runs on the connection's task and must return promptly.
192    pub fn stop_on_error(
193        mut self,
194        terminal: impl Fn(&Error) -> bool + Send + Sync + 'static,
195    ) -> ClientBuilder {
196        self.stop_on_error = Some(Arc::new(terminal));
197        self
198    }
199
200    /// How long a connection may go without a frame before it counts as dead. The server
201    /// beats every three seconds; the default is six, so two missed beats.
202    pub fn stale_after(mut self, after: Duration) -> ClientBuilder {
203        self.stale_after = after;
204        self
205    }
206
207    /// The reconnect delay. It starts at `initial`, doubles per failed attempt up to
208    /// `longest`, and is spread with jitter. Defaults to a second and half a minute.
209    pub fn backoff(mut self, initial: Duration, longest: Duration) -> ClientBuilder {
210        self.initial_backoff = initial;
211        self.longest_backoff = longest;
212        self
213    }
214
215    /// How many connection attempts may fail in a row before the client stops with
216    /// [`Error::GaveUp`]. A welcome resets the count, so this bounds an outage rather than
217    /// the client's lifetime. Zero, the default, keeps dialing until
218    /// [`Client::close`](crate::Client::close).
219    pub fn max_attempts(mut self, attempts: u32) -> ClientBuilder {
220        self.max_attempts = attempts;
221        self
222    }
223
224    /// How often an unconfirmed subscribe command is resent. Defaults to half a second,
225    /// like the JavaScript client's guarantor.
226    pub fn subscribe_retry(mut self, retry: Duration) -> ClientBuilder {
227        self.subscribe_retry = retry;
228        self
229    }
230
231    /// How many messages a subscription buffers before it starts dropping them. Defaults
232    /// to 64, and applies to every subscription on the client.
233    pub fn message_buffer(mut self, messages: usize) -> ClientBuilder {
234        self.message_buffer = messages;
235        self
236    }
237
238    /// Fails on a header that isn't valid HTTP, reported here rather than where it was given
239    /// so the chain of setters stays plain, and on having no transport to dial with.
240    pub fn build(mut self) -> Result<Client, Error> {
241        if let Some(error) = self.invalid_header {
242            return Err(error);
243        }
244
245        let transport = match self.transport.take() {
246            Some(transport) => transport,
247            None => default_transport()?,
248        };
249        self.assume_origin();
250        Ok(Client::new(Config {
251            url: self.url,
252            transport,
253            protocols: self.protocols,
254            headers: self.headers,
255            header_provider: self.header_provider,
256            stop_on_error: self.stop_on_error,
257            stale_after: self.stale_after,
258            subscribe_retry: self.subscribe_retry,
259            initial_backoff: self.initial_backoff,
260            longest_backoff: self.longest_backoff,
261            max_attempts: self.max_attempts,
262            message_buffer: self.message_buffer,
263        }))
264    }
265
266    fn assume_origin(&mut self) {
267        if !self.headers.contains_key(ORIGIN)
268            && let Some(origin) = origin_of(&self.url)
269            && let Ok(origin) = HeaderValue::from_str(&origin)
270        {
271            self.headers.insert(ORIGIN, origin);
272        }
273    }
274}
275
276#[cfg(feature = "websocket")]
277#[allow(
278    clippy::unnecessary_wraps,
279    reason = "the other half of this pair, without the feature, has nothing to hand back"
280)]
281fn default_transport() -> Result<Arc<dyn Transport>, Error> {
282    Ok(Arc::new(crate::websocket::WebSocketTransport::default()))
283}
284
285#[cfg(not(feature = "websocket"))]
286fn default_transport() -> Result<Arc<dyn Transport>, Error> {
287    Err(Error::NoTransport)
288}
289
290fn parse_header(name: &str, value: &str) -> Result<(HeaderName, HeaderValue), Error> {
291    let invalid = |source: http::Error| Error::InvalidHeader {
292        name: name.to_string(),
293        source: Arc::new(source),
294    };
295    let name = HeaderName::from_bytes(name.as_bytes()).map_err(|error| invalid(error.into()))?;
296    let value = HeaderValue::from_str(value).map_err(|error| invalid(error.into()))?;
297    Ok((name, value))
298}
299
300fn origin_of(url: &str) -> Option<String> {
301    let endpoint = Url::parse(url).ok()?;
302    let host = endpoint.host_str()?;
303    let authority = match endpoint.port() {
304        Some(port) => format!("{host}:{port}"),
305        None => host.to_string(),
306    };
307    match endpoint.scheme() {
308        "wss" | "https" => Some(format!("https://{authority}")),
309        "ws" | "http" => Some(format!("http://{authority}")),
310        _ => None,
311    }
312}
313
314/// Everything the builder decided, handed to the client whole.
315pub(crate) struct Config {
316    pub(crate) url: String,
317    pub(crate) transport: Arc<dyn Transport>,
318    pub(crate) protocols: Vec<Arc<dyn Protocol>>,
319    pub(crate) headers: HeaderMap,
320    pub(crate) header_provider: Option<Arc<dyn HeaderProvider>>,
321    pub(crate) stop_on_error: Option<Terminal>,
322    pub(crate) stale_after: Duration,
323    pub(crate) subscribe_retry: Duration,
324    pub(crate) initial_backoff: Duration,
325    pub(crate) longest_backoff: Duration,
326    pub(crate) max_attempts: u32,
327    pub(crate) message_buffer: usize,
328}
329
330#[cfg(test)]
331#[allow(
332    clippy::unwrap_used,
333    clippy::expect_used,
334    reason = "a test that can't have what it asked for has nothing left to assert"
335)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn the_origin_follows_the_cable_url() {
341        let origins = [
342            (
343                "wss://cable.example.com/cable",
344                Some("https://cable.example.com"),
345            ),
346            (
347                "ws://cable.example.com:3000/cable",
348                Some("http://cable.example.com:3000"),
349            ),
350            (
351                "wss://cable.example.com:8443/cable",
352                Some("https://cable.example.com:8443"),
353            ),
354            ("ftp://cable.example.com/cable", None),
355            ("not a url", None),
356        ];
357
358        for (url, origin) in origins {
359            assert_eq!(origin.map(str::to_string), origin_of(url), "{url}");
360        }
361    }
362
363    #[cfg(not(feature = "websocket"))]
364    #[test]
365    fn without_the_websocket_feature_a_transport_is_required() {
366        assert!(matches!(
367            Client::builder("wss://cable.example.com/cable").build(),
368            Err(Error::NoTransport)
369        ));
370    }
371
372    #[cfg(feature = "websocket")]
373    #[test]
374    fn the_websocket_transport_is_the_default() {
375        assert!(
376            Client::builder("wss://cable.example.com/cable")
377                .build()
378                .is_ok()
379        );
380    }
381}