1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
16pub struct RealtimeMessage {
17 pub channel: String,
19 pub event: String,
21 pub payload: String,
23}
24
25pub struct Channel {
27 pub name: String,
29 sender: broadcast::Sender<RealtimeMessage>,
30}
31
32#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
34pub enum RealtimeError {
35 #[error("Broadcast send error: {0}")]
37 BroadcastError(String),
38 #[error("invalid realtime channel: {0}")]
40 InvalidChannel(String),
41 #[error("invalid realtime event: {0}")]
43 InvalidEvent(String),
44 #[error("realtime payload is too large: {actual} bytes exceeds {maximum}")]
46 PayloadTooLarge {
47 actual: usize,
49 maximum: usize,
51 },
52 #[error("invalid realtime presence identity: {0}")]
54 InvalidPresenceIdentity(String),
55}
56
57impl Channel {
58 pub fn new(name: impl Into<String>, capacity: usize) -> Self {
60 let (sender, _) = broadcast::channel(capacity.max(1));
63 Self {
64 name: name.into(),
65 sender,
66 }
67 }
68
69 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 pub fn subscribe(&self) -> broadcast::Receiver<RealtimeMessage> {
83 self.sender.subscribe()
84 }
85}
86
87#[derive(Clone)]
94#[non_exhaustive]
95pub struct TenantRealtime {
96 manager: Arc<BroadcastManager>,
97 tenant_id: String,
98}
99
100impl TenantRealtime {
101 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 pub fn tenant_id(&self) -> &str {
111 &self.tenant_id
112 }
113
114 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 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 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#[derive(Default)]
177pub struct BroadcastManager {
178 channels: DashMap<String, Arc<Channel>>,
179}
180
181impl BroadcastManager {
182 pub fn new() -> Self {
184 Self::default()
185 }
186
187 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 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#[derive(Default)]
210pub struct PresenceTracker {
211 online_users: DashMap<String, DashMap<String, u64>>,
212}
213
214impl PresenceTracker {
215 pub fn new() -> Self {
217 Self::default()
218 }
219
220 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 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 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#[derive(Clone)]
246#[non_exhaustive]
247pub struct TenantPresence {
248 tracker: Arc<PresenceTracker>,
249 tenant_id: String,
250}
251
252impl TenantPresence {
253 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 pub fn tenant_id(&self) -> &str {
263 &self.tenant_id
264 }
265
266 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 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 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 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}