Skip to main content

ws_kit/
config.rs

1//! Configuration for WebSocket infrastructure.
2//!
3//! [`WsConfig`] controls heartbeat cadence, broadcast buffering, global
4//! connection limits, and the Origin allow-list for the upgrade handshake
5//! ([`WsConfig::allowed_origins`]). Use the builder for ergonomic
6//! construction.
7
8use std::time::Duration;
9
10/// Configuration for WebSocket connections and broadcast channels.
11///
12/// # Example
13///
14/// ```
15/// use ws_kit::config::WsConfig;
16/// use std::time::Duration;
17///
18/// let cfg = WsConfig::builder()
19///     .heartbeat_interval(Duration::from_secs(15))
20///     .broadcast_capacity(2048)
21///     .max_connections(5000)
22///     .allow_origin("https://app.example.com")
23///     .build();
24/// assert_eq!(cfg.heartbeat_interval, Duration::from_secs(15));
25/// assert_eq!(cfg.allowed_origins, vec!["https://app.example.com".to_string()]);
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct WsConfig {
29    /// Interval between heartbeat pings.
30    pub heartbeat_interval: Duration,
31    /// Capacity of each broadcast channel.
32    pub broadcast_capacity: usize,
33    /// Maximum concurrent connections allowed (0 = unlimited).
34    pub max_connections: usize,
35    /// Origin allow-list for the WebSocket upgrade handshake (REQ-WSKIT-200).
36    ///
37    /// **WARNING — DEFAULT IS ALLOW-ALL**: an empty list (the default)
38    /// accepts upgrades from ANY origin — cross-site WebSocket hijacking
39    /// (CSWSH) is possible, especially when authentication rides cookies.
40    /// This matches pre-0.3.0 behavior so existing users don't break; it is
41    /// a documented residual risk (see THREAT-MODEL.md). Set at least the
42    /// origins your own frontend uses, e.g. `https://app.example.com`.
43    ///
44    /// When non-empty, an upgrade whose `Origin` header is missing or not in
45    /// this list must be rejected with `403` before `on_upgrade` — use
46    /// `ws_kit::origin_allowed_in_parts`. Comparison is exact after
47    /// normalization (lowercase scheme/host, default ports omitted); no
48    /// wildcard or suffix matching in v1.
49    pub allowed_origins: Vec<String>,
50}
51
52impl Default for WsConfig {
53    fn default() -> Self {
54        Self {
55            heartbeat_interval: Duration::from_secs(30),
56            broadcast_capacity: 1024,
57            max_connections: 1000,
58            allowed_origins: Vec::new(),
59        }
60    }
61}
62
63impl WsConfig {
64    /// Create a new config with defaults.
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Create a builder for [`WsConfig`].
70    pub fn builder() -> WsConfigBuilder {
71        WsConfigBuilder::default()
72    }
73}
74
75/// Builder for [`WsConfig`].
76#[derive(Debug, Clone)]
77pub struct WsConfigBuilder {
78    heartbeat_interval: Duration,
79    broadcast_capacity: usize,
80    max_connections: usize,
81    allowed_origins: Vec<String>,
82}
83
84impl Default for WsConfigBuilder {
85    fn default() -> Self {
86        let d = WsConfig::default();
87        Self {
88            heartbeat_interval: d.heartbeat_interval,
89            broadcast_capacity: d.broadcast_capacity,
90            max_connections: d.max_connections,
91            allowed_origins: d.allowed_origins,
92        }
93    }
94}
95
96impl WsConfigBuilder {
97    /// Set heartbeat interval.
98    pub fn heartbeat_interval(mut self, v: Duration) -> Self {
99        self.heartbeat_interval = v;
100        self
101    }
102
103    /// Set broadcast channel capacity.
104    pub fn broadcast_capacity(mut self, v: usize) -> Self {
105        self.broadcast_capacity = v;
106        self
107    }
108
109    /// Set maximum connection limit.
110    pub fn max_connections(mut self, v: usize) -> Self {
111        self.max_connections = v;
112        self
113    }
114
115    /// Replace the Origin allow-list (REQ-WSKIT-200). Empty (default) =
116    /// allow ALL origins — see [`WsConfig::allowed_origins`] for why you
117    /// almost certainly want to set this.
118    pub fn allowed_origins(mut self, origins: Vec<String>) -> Self {
119        self.allowed_origins = origins;
120        self
121    }
122
123    /// Add one origin to the allow-list (REQ-WSKIT-200), e.g.
124    /// `.allow_origin("https://app.example.com")`. Exact match after
125    /// normalization; no wildcards in v1.
126    pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
127        self.allowed_origins.push(origin.into());
128        self
129    }
130
131    /// Build the [`WsConfig`].
132    pub fn build(self) -> WsConfig {
133        WsConfig {
134            heartbeat_interval: self.heartbeat_interval,
135            broadcast_capacity: self.broadcast_capacity,
136            max_connections: self.max_connections,
137            allowed_origins: self.allowed_origins,
138        }
139    }
140}
141
142// Tests exercise failure paths and invariants directly; unwrap/expect,
143// slicing, and panicking asserts are acceptable here — violations
144// surface as test failures, not production panics.
145#[allow(
146    clippy::unwrap_used,
147    clippy::expect_used,
148    clippy::indexing_slicing,
149    clippy::panic
150)]
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn defaults() {
157        let c = WsConfig::default();
158        assert_eq!(c.heartbeat_interval, Duration::from_secs(30));
159        assert_eq!(c.broadcast_capacity, 1024);
160        assert_eq!(c.max_connections, 1000);
161    }
162
163    #[test]
164    fn builder_overrides() {
165        let c = WsConfig::builder()
166            .heartbeat_interval(Duration::from_secs(10))
167            .broadcast_capacity(512)
168            .max_connections(10)
169            .build();
170        assert_eq!(c.heartbeat_interval, Duration::from_secs(10));
171        assert_eq!(c.broadcast_capacity, 512);
172        assert_eq!(c.max_connections, 10);
173    }
174
175    #[test]
176    fn builder_default_equals_config_default() {
177        let via_builder = WsConfig::builder().build();
178        assert_eq!(via_builder, WsConfig::default());
179    }
180
181    #[test]
182    fn req_wskit_201_default_allowed_origins_empty_allows_all() {
183        // Default is allow-all (documented residual): empty list.
184        assert!(WsConfig::default().allowed_origins.is_empty());
185        assert!(WsConfig::builder().build().allowed_origins.is_empty());
186    }
187
188    #[test]
189    fn req_wskit_200_builder_sets_allowed_origins() {
190        let c = WsConfig::builder()
191            .allow_origin("https://app.example.com")
192            .allow_origin("https://staging.example.com")
193            .build();
194        assert_eq!(
195            c.allowed_origins,
196            vec![
197                "https://app.example.com".to_string(),
198                "https://staging.example.com".to_string()
199            ]
200        );
201        // replace semantics
202        let c2 = WsConfig::builder()
203            .allow_origin("https://a.dev")
204            .allowed_origins(vec!["https://b.dev".to_string()])
205            .build();
206        assert_eq!(c2.allowed_origins, vec!["https://b.dev".to_string()]);
207    }
208}