Skip to main content

rullst_core/
realtime.rs

1//! Native Real-Time Engine (Channels, Broadcast, Presence).
2
3use dashmap::DashMap;
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use tokio::sync::broadcast;
7
8use crate::security::TenantContext;
9
10const MAX_CHANNEL_BYTES: usize = 128;
11const MAX_EVENT_BYTES: usize = 128;
12const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
13
14/// Payload model for realtime broadcast events.
15#[derive(Clone, Debug, Serialize, Deserialize)]
16pub struct RealtimeMessage {
17    /// Target channel name.
18    pub channel: String,
19    /// Event name or topic.
20    pub event: String,
21    /// Stringified JSON or HTML payload.
22    pub payload: String,
23}
24
25/// Represents a named realtime channel with broadcast capabilities.
26pub struct Channel {
27    /// Channel identifier name.
28    pub name: String,
29    sender: broadcast::Sender<RealtimeMessage>,
30}
31
32/// Strongly-typed error domain for Rullst Realtime and Broadcast operations.
33#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
34pub enum RealtimeError {
35    /// Broadcast transmission failed.
36    #[error("Broadcast send error: {0}")]
37    BroadcastError(String),
38    /// A tenant-scoped channel name is empty, oversized, or ambiguous.
39    #[error("invalid realtime channel: {0}")]
40    InvalidChannel(String),
41    /// An event name is empty, oversized, or ambiguous.
42    #[error("invalid realtime event: {0}")]
43    InvalidEvent(String),
44    /// A payload exceeds the bounded in-process realtime envelope.
45    #[error("realtime payload is too large: {actual} bytes exceeds {maximum}")]
46    PayloadTooLarge {
47        /// Observed UTF-8 payload length in bytes.
48        actual: usize,
49        /// Maximum accepted UTF-8 payload length in bytes.
50        maximum: usize,
51    },
52    /// A presence identity is empty, oversized, or ambiguous.
53    #[error("invalid realtime presence identity: {0}")]
54    InvalidPresenceIdentity(String),
55}
56
57impl Channel {
58    /// Creates a new realtime channel with specified message queue capacity.
59    pub fn new(name: impl Into<String>, capacity: usize) -> Self {
60        // Tokio rejects zero-capacity broadcast channels. Keep this infallible
61        // compatibility constructor panic-free while preserving a bounded queue.
62        let (sender, _) = broadcast::channel(capacity.max(1));
63        Self {
64            name: name.into(),
65            sender,
66        }
67    }
68
69    /// Broadcasts an event and payload to all subscribed clients.
70    pub fn broadcast(&self, event: &str, payload: &str) -> Result<usize, RealtimeError> {
71        let msg = RealtimeMessage {
72            channel: self.name.clone(),
73            event: event.to_string(),
74            payload: payload.to_string(),
75        };
76        self.sender
77            .send(msg)
78            .map_err(|e| RealtimeError::BroadcastError(e.to_string()))
79    }
80
81    /// Subscribes to messages broadcasted on this channel.
82    pub fn subscribe(&self) -> broadcast::Receiver<RealtimeMessage> {
83        self.sender.subscribe()
84    }
85}
86
87/// In-process realtime facade permanently bound to one authenticated tenant.
88///
89/// Logical channel names are mapped to an immutable tenant namespace before a
90/// channel is opened or published. Authentication and room-level authorization
91/// remain application responsibilities; this wrapper prevents accidental
92/// cross-tenant reuse of the same logical room name.
93#[derive(Clone)]
94#[non_exhaustive]
95pub struct TenantRealtime {
96    manager: Arc<BroadcastManager>,
97    tenant_id: String,
98}
99
100impl TenantRealtime {
101    /// Binds a shared broadcast manager to an authenticated tenant context.
102    pub fn from_context(manager: Arc<BroadcastManager>, context: &TenantContext) -> Self {
103        Self {
104            manager,
105            tenant_id: context.tenant_id.clone(),
106        }
107    }
108
109    /// Returns the authenticated tenant identifier bound to this instance.
110    pub fn tenant_id(&self) -> &str {
111        &self.tenant_id
112    }
113
114    /// Returns the canonical backend channel inside this tenant namespace.
115    pub fn namespaced_channel(&self, logical_channel: &str) -> Result<String, RealtimeError> {
116        validate_room(logical_channel)?;
117        Ok(format!("tenants:{}:{logical_channel}", self.tenant_id))
118    }
119
120    /// Subscribes only to the tenant-scoped version of a logical channel.
121    pub fn subscribe(
122        &self,
123        logical_channel: &str,
124    ) -> Result<broadcast::Receiver<RealtimeMessage>, RealtimeError> {
125        let channel = self.namespaced_channel(logical_channel)?;
126        Ok(self.manager.get_or_create(&channel).subscribe())
127    }
128
129    /// Publishes a bounded event only to this tenant's logical channel.
130    pub fn publish(
131        &self,
132        logical_channel: &str,
133        event: &str,
134        payload: &str,
135    ) -> Result<usize, RealtimeError> {
136        let channel = self.namespaced_channel(logical_channel)?;
137        validate_name(event, MAX_EVENT_BYTES, RealtimeError::InvalidEvent)?;
138        if payload.len() > MAX_PAYLOAD_BYTES {
139            return Err(RealtimeError::PayloadTooLarge {
140                actual: payload.len(),
141                maximum: MAX_PAYLOAD_BYTES,
142            });
143        }
144        self.manager.publish(&channel, event, payload)
145    }
146}
147
148fn validate_name(
149    value: &str,
150    maximum: usize,
151    error: impl FnOnce(String) -> RealtimeError,
152) -> Result<(), RealtimeError> {
153    if value.is_empty()
154        || value.len() > maximum
155        || !value.bytes().all(|byte| {
156            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
157        })
158    {
159        return Err(error(value.to_string()));
160    }
161    Ok(())
162}
163
164fn validate_room(room: &str) -> Result<(), RealtimeError> {
165    validate_name(room, MAX_CHANNEL_BYTES, RealtimeError::InvalidChannel)?;
166    if room
167        .split('/')
168        .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
169    {
170        return Err(RealtimeError::InvalidChannel(room.to_string()));
171    }
172    Ok(())
173}
174
175/// Thread-safe in-memory pub/sub manager for realtime channels.
176#[derive(Default)]
177pub struct BroadcastManager {
178    channels: DashMap<String, Arc<Channel>>,
179}
180
181impl BroadcastManager {
182    /// Creates a new BroadcastManager instance.
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Retrieves an existing channel or creates a new one if it does not exist.
188    pub fn get_or_create(&self, channel_name: &str) -> Arc<Channel> {
189        self.channels
190            .entry(channel_name.to_string())
191            .or_insert_with(|| Arc::new(Channel::new(channel_name, 100)))
192            .value()
193            .clone()
194    }
195
196    /// Publishes a message directly to a channel by name.
197    pub fn publish(
198        &self,
199        channel_name: &str,
200        event: &str,
201        payload: &str,
202    ) -> Result<usize, RealtimeError> {
203        let ch = self.get_or_create(channel_name);
204        ch.broadcast(event, payload)
205    }
206}
207
208/// In-memory tracker for active user presence across channels/rooms.
209#[derive(Default)]
210pub struct PresenceTracker {
211    online_users: DashMap<String, DashMap<String, u64>>,
212}
213
214impl PresenceTracker {
215    /// Creates a new PresenceTracker instance.
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    /// Registers a user as online in a specific room.
221    pub fn user_joined(&self, room: &str, user_id: &str) {
222        let now = std::time::SystemTime::now()
223            .duration_since(std::time::UNIX_EPOCH)
224            .unwrap_or_default()
225            .as_secs();
226
227        let room_map = self.online_users.entry(room.to_string()).or_default();
228        room_map.insert(user_id.to_string(), now);
229    }
230
231    /// Removes a user from a specific room upon disconnect.
232    pub fn user_left(&self, room: &str, user_id: &str) {
233        if let Some(room_map) = self.online_users.get(room) {
234            room_map.remove(user_id);
235        }
236    }
237
238    /// Returns the count of currently online users in a room.
239    pub fn count_online(&self, room: &str) -> usize {
240        self.online_users.get(room).map(|m| m.len()).unwrap_or(0)
241    }
242}
243
244/// In-process presence facade permanently bound to one authenticated tenant.
245#[derive(Clone)]
246#[non_exhaustive]
247pub struct TenantPresence {
248    tracker: Arc<PresenceTracker>,
249    tenant_id: String,
250}
251
252impl TenantPresence {
253    /// Binds a shared presence tracker to an authenticated tenant context.
254    pub fn from_context(tracker: Arc<PresenceTracker>, context: &TenantContext) -> Self {
255        Self {
256            tracker,
257            tenant_id: context.tenant_id.clone(),
258        }
259    }
260
261    /// Returns the authenticated tenant identifier bound to this instance.
262    pub fn tenant_id(&self) -> &str {
263        &self.tenant_id
264    }
265
266    /// Registers a bounded identity only in this tenant's logical room.
267    pub fn user_joined(&self, room: &str, user_id: &str) -> Result<(), RealtimeError> {
268        let room = self.namespaced_room(room)?;
269        validate_name(
270            user_id,
271            MAX_CHANNEL_BYTES,
272            RealtimeError::InvalidPresenceIdentity,
273        )?;
274        self.tracker.user_joined(&room, user_id);
275        Ok(())
276    }
277
278    /// Removes a bounded identity only from this tenant's logical room.
279    pub fn user_left(&self, room: &str, user_id: &str) -> Result<(), RealtimeError> {
280        let room = self.namespaced_room(room)?;
281        validate_name(
282            user_id,
283            MAX_CHANNEL_BYTES,
284            RealtimeError::InvalidPresenceIdentity,
285        )?;
286        self.tracker.user_left(&room, user_id);
287        Ok(())
288    }
289
290    /// Returns the online count only for this tenant's logical room.
291    pub fn count_online(&self, room: &str) -> Result<usize, RealtimeError> {
292        Ok(self.tracker.count_online(&self.namespaced_room(room)?))
293    }
294
295    fn namespaced_room(&self, room: &str) -> Result<String, RealtimeError> {
296        validate_room(room)?;
297        Ok(format!("tenants:{}:{room}", self.tenant_id))
298    }
299}
300
301#[cfg(test)]
302#[allow(clippy::expect_used)]
303mod tests {
304    use super::*;
305    use crate::security::TenantMembership;
306
307    #[tokio::test]
308    // TM-TENANT-04
309    async fn identical_channels_are_isolated_by_authenticated_tenant_context() {
310        let membership = TenantMembership::try_new(["school-alpha", "school-beta"])
311            .expect("valid tenant membership");
312        let alpha_context = membership.select("school-alpha").expect("alpha membership");
313        let beta_context = membership.select("school-beta").expect("beta membership");
314        let manager = Arc::new(BroadcastManager::new());
315        let alpha = TenantRealtime::from_context(Arc::clone(&manager), &alpha_context);
316        let beta = TenantRealtime::from_context(manager, &beta_context);
317        let mut alpha_receiver = alpha.subscribe("course/1").expect("alpha subscription");
318        let mut beta_receiver = beta.subscribe("course/1").expect("beta subscription");
319
320        alpha
321            .publish("course/1", "lesson.completed", r#"{"lesson_id":7}"#)
322            .expect("alpha publish");
323        let alpha_message = alpha_receiver.recv().await.expect("alpha message");
324
325        assert_eq!(alpha_message.channel, "tenants:school-alpha:course/1");
326        assert_eq!(alpha_message.event, "lesson.completed");
327        assert!(matches!(
328            beta_receiver.try_recv(),
329            Err(broadcast::error::TryRecvError::Empty)
330        ));
331        assert!(matches!(
332            beta.publish("../school-alpha/course/1", "lesson.completed", "{}"),
333            Err(RealtimeError::InvalidChannel(_))
334        ));
335        assert!(matches!(
336            beta.publish("course/1", "lesson completed", "{}"),
337            Err(RealtimeError::InvalidEvent(_))
338        ));
339        assert!(matches!(
340            beta.publish("course/1", "lesson.completed", &"x".repeat(65_537)),
341            Err(RealtimeError::PayloadTooLarge { .. })
342        ));
343
344        let presence = Arc::new(PresenceTracker::new());
345        let alpha_presence = TenantPresence::from_context(Arc::clone(&presence), &alpha_context);
346        let beta_presence = TenantPresence::from_context(presence, &beta_context);
347        alpha_presence
348            .user_joined("course/1", "learner-7")
349            .expect("alpha presence");
350        assert_eq!(
351            alpha_presence
352                .count_online("course/1")
353                .expect("alpha presence count"),
354            1
355        );
356        assert_eq!(
357            beta_presence
358                .count_online("course/1")
359                .expect("beta presence count"),
360            0
361        );
362        assert!(matches!(
363            beta_presence.user_joined("course/1", "learner 7"),
364            Err(RealtimeError::InvalidPresenceIdentity(_))
365        ));
366    }
367
368    #[test]
369    fn zero_capacity_channel_is_panic_free() {
370        let channel = Channel::new("bounded", 0);
371        let _receiver = channel.subscribe();
372        assert_eq!(channel.name, "bounded");
373    }
374}