1use std::collections::HashMap;
2
3use serde_json::{Value, json};
4use thiserror::Error;
5
6use crate::{Frame, Payload};
7
8const PHOENIX_TOPIC: &str = "phoenix";
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum ChannelState {
13 Joining,
15 Joined,
17 Leaving,
19 Closed,
21 Errored,
23 Disconnected,
25}
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum ReplyStatus {
30 Ok,
32 Error,
34}
35
36#[derive(Clone, Debug, PartialEq)]
38pub struct Outbound {
39 pub reference: String,
41 pub frame: Frame,
43}
44
45#[derive(Clone, Debug, PartialEq)]
47pub enum ProtocolEvent {
48 Joined {
50 topic: String,
52 reference: String,
54 response: Payload,
56 },
57 JoinError {
59 topic: String,
61 reference: String,
63 response: Payload,
65 },
66 Left {
68 topic: String,
70 reference: String,
72 response: Payload,
74 },
75 Reply {
77 topic: String,
79 event: String,
81 reference: String,
83 status: ReplyStatus,
85 response: Payload,
87 },
88 Message(Frame),
90 ChannelClosed {
92 topic: String,
94 payload: Payload,
96 },
97 ChannelError {
99 topic: String,
101 payload: Payload,
103 },
104 HeartbeatAck {
106 reference: String,
108 status: ReplyStatus,
110 },
111 RequestInterrupted {
113 topic: String,
115 event: String,
117 reference: String,
119 },
120 StaleMessage(Frame),
122 UnmatchedReply(Frame),
124}
125
126#[derive(Clone, Debug)]
127struct Channel {
128 state: ChannelState,
129 join_ref: String,
130 params: Value,
131}
132
133#[derive(Clone, Debug)]
134enum Pending {
135 Join { topic: String },
136 Leave { topic: String },
137 Push { topic: String, event: String },
138 Heartbeat,
139}
140
141#[derive(Debug, Default)]
147pub struct Protocol {
148 next_reference: u64,
149 channels: HashMap<String, Channel>,
150 pending: HashMap<String, Pending>,
151}
152
153impl Protocol {
154 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub fn channel_state(&self, topic: &str) -> Option<ChannelState> {
161 self.channels.get(topic).map(|channel| channel.state)
162 }
163
164 pub fn join(
169 &mut self,
170 topic: impl Into<String>,
171 params: Value,
172 ) -> Result<Outbound, ProtocolError> {
173 let topic = topic.into();
174 if self.channels.get(&topic).is_some_and(|channel| {
175 matches!(
176 channel.state,
177 ChannelState::Joining | ChannelState::Joined | ChannelState::Leaving
178 )
179 }) {
180 return Err(ProtocolError::AlreadyActive(topic));
181 }
182
183 let reference = self.allocate_reference();
184 self.channels.insert(
185 topic.clone(),
186 Channel {
187 state: ChannelState::Joining,
188 join_ref: reference.clone(),
189 params: params.clone(),
190 },
191 );
192 self.pending.insert(
193 reference.clone(),
194 Pending::Join {
195 topic: topic.clone(),
196 },
197 );
198
199 Ok(Outbound {
200 reference: reference.clone(),
201 frame: Frame::new(
202 Some(reference.clone()),
203 Some(reference),
204 topic,
205 "phx_join",
206 params,
207 ),
208 })
209 }
210
211 pub fn rejoin(
213 &mut self,
214 topic: impl Into<String>,
215 refreshed_params: Value,
216 ) -> Result<Outbound, ProtocolError> {
217 let topic = topic.into();
218 match self.channels.get(&topic).map(|channel| channel.state) {
219 Some(ChannelState::Disconnected | ChannelState::Errored | ChannelState::Closed) => {
220 self.discard_channel(&topic);
221 self.join(topic, refreshed_params)
222 }
223 Some(_) => Err(ProtocolError::AlreadyActive(topic)),
224 None => self.join(topic, refreshed_params),
225 }
226 }
227
228 pub fn rejoin_all_with_stored_params(&mut self) -> Vec<Outbound> {
230 let channels = self
231 .channels
232 .iter()
233 .filter(|(_, channel)| {
234 matches!(
235 channel.state,
236 ChannelState::Disconnected | ChannelState::Errored | ChannelState::Closed
237 )
238 })
239 .map(|(topic, channel)| (topic.clone(), channel.params.clone()))
240 .collect::<Vec<_>>();
241
242 channels
243 .into_iter()
244 .filter_map(|(topic, params)| self.rejoin(topic, params).ok())
245 .collect()
246 }
247
248 pub fn leave(&mut self, topic: &str) -> Result<Outbound, ProtocolError> {
250 let reference = self.allocate_reference();
251 let channel = self
252 .channels
253 .get_mut(topic)
254 .ok_or_else(|| ProtocolError::UnknownTopic(topic.to_owned()))?;
255 if channel.state != ChannelState::Joined {
256 return Err(ProtocolError::NotJoined(topic.to_owned()));
257 }
258
259 channel.state = ChannelState::Leaving;
260 let join_ref = channel.join_ref.clone();
261 self.pending.insert(
262 reference.clone(),
263 Pending::Leave {
264 topic: topic.to_owned(),
265 },
266 );
267
268 Ok(Outbound {
269 reference: reference.clone(),
270 frame: Frame::new(
271 Some(join_ref),
272 Some(reference.clone()),
273 topic,
274 "phx_leave",
275 json!({}),
276 ),
277 })
278 }
279
280 pub fn push(
282 &mut self,
283 topic: &str,
284 event: impl Into<String>,
285 payload: impl Into<Payload>,
286 ) -> Result<Outbound, ProtocolError> {
287 let event = event.into();
288 let join_ref = self
289 .channels
290 .get(topic)
291 .filter(|channel| channel.state == ChannelState::Joined)
292 .map(|channel| channel.join_ref.clone())
293 .ok_or_else(|| ProtocolError::NotJoined(topic.to_owned()))?;
294 let reference = self.allocate_reference();
295 self.pending.insert(
296 reference.clone(),
297 Pending::Push {
298 topic: topic.to_owned(),
299 event: event.clone(),
300 },
301 );
302
303 Ok(Outbound {
304 reference: reference.clone(),
305 frame: Frame::new(
306 Some(join_ref),
307 Some(reference.clone()),
308 topic,
309 event,
310 payload,
311 ),
312 })
313 }
314
315 pub fn cast(
319 &self,
320 topic: &str,
321 event: impl Into<String>,
322 payload: impl Into<Payload>,
323 ) -> Result<Frame, ProtocolError> {
324 let join_ref = self
325 .channels
326 .get(topic)
327 .filter(|channel| channel.state == ChannelState::Joined)
328 .map(|channel| channel.join_ref.clone())
329 .ok_or_else(|| ProtocolError::NotJoined(topic.to_owned()))?;
330
331 Ok(Frame::new(Some(join_ref), None, topic, event, payload))
332 }
333
334 pub fn forget_push(&mut self, reference: &str) -> bool {
339 if matches!(self.pending.get(reference), Some(Pending::Push { .. })) {
340 self.pending.remove(reference);
341 true
342 } else {
343 false
344 }
345 }
346
347 pub fn forget_heartbeat(&mut self, reference: &str) -> bool {
350 if matches!(self.pending.get(reference), Some(Pending::Heartbeat)) {
351 self.pending.remove(reference);
352 true
353 } else {
354 false
355 }
356 }
357
358 pub fn discard_channel(&mut self, topic: &str) -> bool {
363 let removed = self.channels.remove(topic).is_some();
364 self.pending.retain(|_, pending| match pending {
365 Pending::Join {
366 topic: pending_topic,
367 }
368 | Pending::Leave {
369 topic: pending_topic,
370 }
371 | Pending::Push {
372 topic: pending_topic,
373 ..
374 } => pending_topic != topic,
375 Pending::Heartbeat => true,
376 });
377 removed
378 }
379
380 pub fn heartbeat(&mut self) -> Outbound {
382 let reference = self.allocate_reference();
383 self.pending.insert(reference.clone(), Pending::Heartbeat);
384 Outbound {
385 reference: reference.clone(),
386 frame: Frame::new(
387 None,
388 Some(reference.clone()),
389 PHOENIX_TOPIC,
390 "heartbeat",
391 json!({}),
392 ),
393 }
394 }
395
396 pub fn receive(&mut self, frame: Frame) -> Result<ProtocolEvent, ProtocolError> {
398 if self.channels.get(&frame.topic).is_some_and(|channel| {
399 frame.join_ref.is_some() && frame.join_ref.as_deref() != Some(channel.join_ref.as_str())
400 }) {
401 return Ok(ProtocolEvent::StaleMessage(frame));
402 }
403
404 if frame.event == "phx_reply" {
405 return self.receive_reply(frame);
406 }
407
408 match frame.event.as_str() {
409 "phx_close" => {
410 if let Some(channel) = self.channels.get_mut(&frame.topic) {
411 channel.state = ChannelState::Closed;
412 }
413 Ok(ProtocolEvent::ChannelClosed {
414 topic: frame.topic,
415 payload: frame.payload,
416 })
417 }
418 "phx_error" => {
419 if let Some(channel) = self.channels.get_mut(&frame.topic) {
420 channel.state = ChannelState::Errored;
421 }
422 Ok(ProtocolEvent::ChannelError {
423 topic: frame.topic,
424 payload: frame.payload,
425 })
426 }
427 _ => Ok(ProtocolEvent::Message(frame)),
428 }
429 }
430
431 pub fn reset_connection(&mut self) -> Vec<ProtocolEvent> {
433 let interrupted = self
434 .pending
435 .drain()
436 .filter_map(|(reference, pending)| match pending {
437 Pending::Join { topic } => Some(ProtocolEvent::RequestInterrupted {
438 topic,
439 event: "phx_join".into(),
440 reference,
441 }),
442 Pending::Leave { topic } => Some(ProtocolEvent::RequestInterrupted {
443 topic,
444 event: "phx_leave".into(),
445 reference,
446 }),
447 Pending::Push { topic, event } => Some(ProtocolEvent::RequestInterrupted {
448 topic,
449 event,
450 reference,
451 }),
452 Pending::Heartbeat => None,
453 })
454 .collect();
455
456 for channel in self.channels.values_mut() {
457 channel.state = ChannelState::Disconnected;
458 }
459 interrupted
460 }
461
462 fn receive_reply(&mut self, frame: Frame) -> Result<ProtocolEvent, ProtocolError> {
463 let Some(reference) = frame.reference.clone() else {
464 return Ok(ProtocolEvent::UnmatchedReply(frame));
465 };
466 let Some(pending) = self.pending.remove(&reference) else {
467 return Ok(ProtocolEvent::UnmatchedReply(frame));
468 };
469 let (status, response) = decode_reply_payload(&frame.payload)?;
470
471 match pending {
472 Pending::Join { topic } => {
473 if let Some(channel) = self.channels.get_mut(&topic) {
474 channel.state = match status {
475 ReplyStatus::Ok => ChannelState::Joined,
476 ReplyStatus::Error => ChannelState::Errored,
477 };
478 }
479 match status {
480 ReplyStatus::Ok => Ok(ProtocolEvent::Joined {
481 topic,
482 reference,
483 response,
484 }),
485 ReplyStatus::Error => Ok(ProtocolEvent::JoinError {
486 topic,
487 reference,
488 response,
489 }),
490 }
491 }
492 Pending::Leave { topic } => {
493 self.channels.remove(&topic);
494 Ok(ProtocolEvent::Left {
495 topic,
496 reference,
497 response,
498 })
499 }
500 Pending::Push { topic, event } => Ok(ProtocolEvent::Reply {
501 topic,
502 event,
503 reference,
504 status,
505 response,
506 }),
507 Pending::Heartbeat => Ok(ProtocolEvent::HeartbeatAck { reference, status }),
508 }
509 }
510
511 fn allocate_reference(&mut self) -> String {
512 self.next_reference = self.next_reference.wrapping_add(1);
513 if self.next_reference == 0 {
514 self.next_reference = 1;
515 }
516 self.next_reference.to_string()
517 }
518}
519
520fn decode_reply_payload(payload: &Payload) -> Result<(ReplyStatus, Payload), ProtocolError> {
521 match payload {
522 Payload::Json(value) => {
523 let object = value
524 .as_object()
525 .ok_or(ProtocolError::InvalidReplyPayload)?;
526 let status = decode_reply_status(object.get("status").and_then(Value::as_str))?;
527 let response = object.get("response").cloned().unwrap_or_else(|| json!({}));
528 Ok((status, Payload::Json(response)))
529 }
530 Payload::Reply { status, response } => {
531 Ok((decode_reply_status(Some(status))?, (**response).clone()))
532 }
533 Payload::Binary(_) => Err(ProtocolError::InvalidReplyPayload),
534 }
535}
536
537fn decode_reply_status(status: Option<&str>) -> Result<ReplyStatus, ProtocolError> {
538 match status {
539 Some("ok") => Ok(ReplyStatus::Ok),
540 Some("error") => Ok(ReplyStatus::Error),
541 _ => Err(ProtocolError::InvalidReplyStatus),
542 }
543}
544
545#[derive(Debug, Error, PartialEq)]
547pub enum ProtocolError {
548 #[error("topic is already active: {0}")]
550 AlreadyActive(String),
551 #[error("topic is not joined: {0}")]
553 NotJoined(String),
554 #[error("unknown topic: {0}")]
556 UnknownTopic(String),
557 #[error("phx_reply payload must be an object")]
559 InvalidReplyPayload,
560 #[error("phx_reply status must be ok or error")]
562 InvalidReplyStatus,
563}
564
565#[cfg(test)]
566mod tests {
567 use proptest::prelude::*;
568 use serde_json::json;
569
570 use super::*;
571
572 fn reply(outbound: &Outbound, status: &str, response: Value) -> Frame {
573 Frame::new(
574 outbound.frame.join_ref.clone(),
575 Some(outbound.reference.clone()),
576 outbound.frame.topic.clone(),
577 "phx_reply",
578 json!({"status": status, "response": response}),
579 )
580 }
581
582 fn joined_protocol() -> Protocol {
583 let mut protocol = Protocol::new();
584 let join = protocol
585 .join("room:lobby", json!({"token": "abc"}))
586 .unwrap();
587 protocol.receive(reply(&join, "ok", json!({}))).unwrap();
588 protocol
589 }
590
591 proptest! {
592 #[test]
593 fn arbitrary_state_transitions_never_panic(actions in proptest::collection::vec(any::<u8>(), 0..512)) {
594 let mut protocol = Protocol::new();
595 for action in actions {
596 match action % 7 {
597 0 => { let _ = protocol.join("room:lobby", json!({})); }
598 1 => {
599 let frame = Frame::new(
600 Some((action / 7).to_string()),
601 Some(action.to_string()),
602 "room:lobby",
603 if action & 1 == 0 { "phx_error" } else { "event" },
604 json!({}),
605 );
606 let _ = protocol.receive(frame);
607 }
608 2 => { let _ = protocol.reset_connection(); }
609 3 => { let _ = protocol.discard_channel("room:lobby"); }
610 4 => { let _ = protocol.heartbeat(); }
611 5 => { let _ = protocol.leave("room:lobby"); }
612 _ => { let _ = protocol.push("room:lobby", "event", json!({})); }
613 }
614 }
615 }
616 }
617
618 #[test]
619 fn joins_a_topic_and_correlates_the_reply() {
620 let mut protocol = Protocol::new();
621 let outbound = protocol
622 .join("room:lobby", json!({"token": "abc"}))
623 .unwrap();
624
625 assert_eq!(outbound.frame.event, "phx_join");
626 assert_eq!(outbound.frame.join_ref, outbound.frame.reference);
627 assert_eq!(
628 protocol.channel_state("room:lobby"),
629 Some(ChannelState::Joining)
630 );
631
632 let event = protocol
633 .receive(reply(&outbound, "ok", json!({"ready": true})))
634 .unwrap();
635 assert_eq!(
636 event,
637 ProtocolEvent::Joined {
638 topic: "room:lobby".into(),
639 reference: outbound.reference,
640 response: json!({"ready": true}).into(),
641 }
642 );
643 assert_eq!(
644 protocol.channel_state("room:lobby"),
645 Some(ChannelState::Joined)
646 );
647 }
648
649 #[test]
650 fn correlates_push_replies_without_swallowing_server_events() {
651 let mut protocol = joined_protocol();
652 let push = protocol
653 .push("room:lobby", "new_message", json!({"body": "hello"}))
654 .unwrap();
655 let broadcast = Frame::new(
656 push.frame.join_ref.clone(),
657 None,
658 "room:lobby",
659 "presence_changed",
660 json!({"online": 2}),
661 );
662
663 assert_eq!(
664 protocol.receive(broadcast.clone()).unwrap(),
665 ProtocolEvent::Message(broadcast)
666 );
667 assert_eq!(
668 protocol
669 .receive(reply(&push, "ok", json!({"id": 99})))
670 .unwrap(),
671 ProtocolEvent::Reply {
672 topic: "room:lobby".into(),
673 event: "new_message".into(),
674 reference: push.reference,
675 status: ReplyStatus::Ok,
676 response: json!({"id": 99}).into(),
677 }
678 );
679 }
680
681 #[test]
682 fn forgets_a_timed_out_heartbeat() {
683 let mut protocol = Protocol::new();
684 let heartbeat = protocol.heartbeat();
685 assert!(protocol.forget_heartbeat(&heartbeat.reference));
686 assert!(!protocol.forget_heartbeat(&heartbeat.reference));
687 assert_eq!(
688 protocol
689 .receive(reply(&heartbeat, "ok", json!({})))
690 .unwrap(),
691 ProtocolEvent::UnmatchedReply(reply(&heartbeat, "ok", json!({})))
692 );
693 }
694
695 #[test]
696 fn rejects_pushes_before_join_completes() {
697 let mut protocol = Protocol::new();
698 protocol.join("room:lobby", json!({})).unwrap();
699
700 assert_eq!(
701 protocol.push("room:lobby", "event", json!({})).unwrap_err(),
702 ProtocolError::NotJoined("room:lobby".into())
703 );
704 }
705
706 #[test]
707 fn resets_and_rejoins_with_a_fresh_join_reference() {
708 let mut protocol = joined_protocol();
709 let push = protocol
710 .push("room:lobby", "new_message", json!({}))
711 .unwrap();
712 let old_join_ref = push.frame.join_ref.clone();
713
714 let interrupted = protocol.reset_connection();
715 assert_eq!(interrupted.len(), 1);
716 assert_eq!(
717 protocol.channel_state("room:lobby"),
718 Some(ChannelState::Disconnected)
719 );
720
721 let rejoins = protocol.rejoin_all_with_stored_params();
722 assert_eq!(rejoins.len(), 1);
723 assert_ne!(rejoins[0].frame.join_ref, old_join_ref);
724 }
725
726 #[test]
727 fn identifies_messages_from_an_old_join_generation() {
728 let mut protocol = joined_protocol();
729 protocol.reset_connection();
730 let rejoin = protocol.rejoin_all_with_stored_params().remove(0);
731 protocol.receive(reply(&rejoin, "ok", json!({}))).unwrap();
732
733 let stale = Frame::new(
734 Some("1".into()),
735 None,
736 "room:lobby",
737 "new_message",
738 json!({}),
739 );
740 assert_eq!(
741 protocol.receive(stale.clone()).unwrap(),
742 ProtocolEvent::StaleMessage(stale)
743 );
744 }
745
746 #[test]
747 fn rejects_replies_from_an_old_join_generation() {
748 let mut protocol = joined_protocol();
749 let push = protocol
750 .push("room:lobby", "save", json!({"version": 1}))
751 .unwrap();
752 let channel_error = Frame::new(
753 push.frame.join_ref.clone(),
754 None,
755 "room:lobby",
756 "phx_error",
757 json!({}),
758 );
759 protocol.receive(channel_error).unwrap();
760
761 let rejoin = protocol
762 .rejoin("room:lobby", json!({"token": "refreshed"}))
763 .unwrap();
764 protocol.receive(reply(&rejoin, "ok", json!({}))).unwrap();
765
766 let stale_reply = reply(&push, "ok", json!({"version": 2}));
767 assert_eq!(
768 protocol.receive(stale_reply.clone()).unwrap(),
769 ProtocolEvent::StaleMessage(stale_reply)
770 );
771 }
772}