1use std::pin::Pin;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6use std::task::{Context, Poll};
7use std::time::Duration;
8
9use futures_util::{Sink, SinkExt, Stream, StreamExt};
10use tokio::sync::{broadcast, mpsc, oneshot};
11use tokio::task::JoinHandle;
12use tokio_stream::wrappers::BroadcastStream;
13use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
14use tokio_tungstenite::tungstenite::client::IntoClientRequest;
15use tokio_tungstenite::tungstenite::http::header::{HeaderName, HeaderValue};
16use tokio_tungstenite::tungstenite::{Error as WsError, Message};
17
18use super::auth::{TokenProvider, build_auth_query_url};
19#[cfg(feature = "compression")]
20use super::compression::{inflate, with_compress_query};
21use super::protocol::{
22 ChannelEvent, InboundFrame, OutboundFrame, Subscription, parse_inbound_frame,
23};
24use super::reconnect::{ReconnectOptions, ReconnectPolicy};
25use super::subscription::SubscriptionManager;
26use crate::config::DEFAULT_WS_URL;
27use crate::error::{RadionError, Result};
28
29const EVENT_BUFFER: usize = 1024;
31const LIFECYCLE_BUFFER: usize = 64;
33
34#[derive(Debug, Clone, Copy)]
36pub struct HeartbeatOptions {
37 pub interval: Duration,
39 pub timeout: Duration,
42}
43
44impl Default for HeartbeatOptions {
45 fn default() -> Self {
46 Self {
47 interval: Duration::from_secs(15),
48 timeout: Duration::from_secs(10),
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct RealtimeOptions {
56 pub api_key: String,
58 pub url: String,
60 pub reconnect: Option<ReconnectOptions>,
62 pub heartbeat: Option<HeartbeatOptions>,
64 pub token_provider: Option<TokenProvider>,
67 pub auth_in_query: bool,
70 #[cfg(feature = "compression")]
72 #[cfg_attr(docsrs, doc(cfg(feature = "compression")))]
73 pub compression: bool,
74}
75
76impl RealtimeOptions {
77 pub fn new(api_key: impl Into<String>) -> Self {
79 Self {
80 api_key: api_key.into(),
81 url: DEFAULT_WS_URL.to_string(),
82 reconnect: Some(ReconnectOptions::default()),
83 heartbeat: Some(HeartbeatOptions::default()),
84 token_provider: None,
85 auth_in_query: false,
86 #[cfg(feature = "compression")]
87 compression: false,
88 }
89 }
90
91 #[must_use]
93 pub fn url(mut self, url: impl Into<String>) -> Self {
94 self.url = url.into();
95 self
96 }
97
98 #[must_use]
100 pub fn reconnect(mut self, options: ReconnectOptions) -> Self {
101 self.reconnect = Some(options);
102 self
103 }
104
105 #[must_use]
107 pub fn disable_reconnect(mut self) -> Self {
108 self.reconnect = None;
109 self
110 }
111
112 #[must_use]
114 pub fn heartbeat(mut self, options: HeartbeatOptions) -> Self {
115 self.heartbeat = Some(options);
116 self
117 }
118
119 #[must_use]
121 pub fn disable_heartbeat(mut self) -> Self {
122 self.heartbeat = None;
123 self
124 }
125
126 #[must_use]
128 pub fn token(mut self, token: impl Into<String>) -> Self {
129 self.token_provider = Some(TokenProvider::from_static(token));
130 self
131 }
132
133 #[must_use]
135 pub fn token_provider(mut self, provider: TokenProvider) -> Self {
136 self.token_provider = Some(provider);
137 self
138 }
139
140 #[must_use]
142 pub fn auth_in_query(mut self, enabled: bool) -> Self {
143 self.auth_in_query = enabled;
144 self
145 }
146
147 #[cfg(feature = "compression")]
153 #[cfg_attr(docsrs, doc(cfg(feature = "compression")))]
154 #[must_use]
155 pub fn compression(mut self, enabled: bool) -> Self {
156 self.compression = enabled;
157 self
158 }
159
160 fn decode_binary(&self, bytes: &[u8]) -> Result<String> {
163 #[cfg(feature = "compression")]
164 {
165 if self.compression {
166 return inflate(bytes);
167 }
168 }
169 std::str::from_utf8(bytes)
170 .map(str::to_owned)
171 .map_err(RadionError::transport)
172 }
173}
174
175#[derive(Debug, Clone)]
177#[non_exhaustive]
178pub enum LifecycleEvent {
179 Open,
181 Close {
183 code: u16,
185 reason: String,
187 },
188 Reconnect {
190 attempt: u32,
192 delay: Duration,
194 },
195 Warning {
199 code: String,
201 id: Option<String>,
203 message: String,
205 },
206 Error(RadionError),
209}
210
211enum Command {
213 Subscribe(Subscription),
214 Unsubscribe(String),
215 Close { code: u16, reason: String },
216}
217
218#[derive(Debug)]
228pub struct RealtimeClient {
229 options: RealtimeOptions,
230 cmd_tx: mpsc::UnboundedSender<Command>,
231 cmd_rx: Mutex<Option<mpsc::UnboundedReceiver<Command>>>,
232 events_tx: broadcast::Sender<ChannelEvent>,
233 lifecycle_tx: broadcast::Sender<LifecycleEvent>,
234 connected: Arc<AtomicBool>,
235 task: Mutex<Option<JoinHandle<()>>>,
236}
237
238impl RealtimeClient {
239 pub fn new(options: RealtimeOptions) -> Self {
241 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
242 let (events_tx, _) = broadcast::channel(EVENT_BUFFER);
243 let (lifecycle_tx, _) = broadcast::channel(LIFECYCLE_BUFFER);
244 Self {
245 options,
246 cmd_tx,
247 cmd_rx: Mutex::new(Some(cmd_rx)),
248 events_tx,
249 lifecycle_tx,
250 connected: Arc::new(AtomicBool::new(false)),
251 task: Mutex::new(None),
252 }
253 }
254
255 pub fn connected(&self) -> bool {
257 self.connected.load(Ordering::SeqCst)
258 }
259
260 pub async fn connect(&self) -> Result<()> {
269 if self.connected() {
270 return Ok(());
271 }
272 let Some(cmd_rx) = self.cmd_rx.lock().expect("cmd_rx mutex poisoned").take() else {
273 return Ok(());
275 };
276
277 let (ready_tx, ready_rx) = oneshot::channel();
278 let task = tokio::spawn(run(
279 self.options.clone(),
280 cmd_rx,
281 self.events_tx.clone(),
282 self.lifecycle_tx.clone(),
283 Arc::clone(&self.connected),
284 ready_tx,
285 ));
286 *self.task.lock().expect("task mutex poisoned") = Some(task);
287
288 match ready_rx.await {
289 Ok(result) => result,
290 Err(_) => Err(RadionError::connection(
291 "connection task ended before connecting",
292 )),
293 }
294 }
295
296 pub async fn subscribe(&self, subscription: Subscription) -> Result<ChannelEventStream> {
307 subscription.validate()?;
308 let id = subscription.id.clone();
309 let rx = self.events_tx.subscribe();
312 self.cmd_tx
313 .send(Command::Subscribe(subscription))
314 .map_err(|_| RadionError::connection("client has been closed"))?;
315 Ok(ChannelEventStream {
316 inner: BroadcastStream::new(rx),
317 filter_id: Some(id),
318 })
319 }
320
321 pub async fn unsubscribe(&self, id: impl Into<String>) -> Result<()> {
327 self.cmd_tx
328 .send(Command::Unsubscribe(id.into()))
329 .map_err(|_| RadionError::connection("client has been closed"))
330 }
331
332 pub fn events(&self) -> ChannelEventStream {
334 ChannelEventStream {
335 inner: BroadcastStream::new(self.events_tx.subscribe()),
336 filter_id: None,
337 }
338 }
339
340 pub fn lifecycle(&self) -> LifecycleStream {
342 LifecycleStream {
343 inner: BroadcastStream::new(self.lifecycle_tx.subscribe()),
344 }
345 }
346
347 pub async fn close(&self, code: u16, reason: impl Into<String>) {
351 let _ = self.cmd_tx.send(Command::Close {
352 code,
353 reason: reason.into(),
354 });
355 let handle = self.task.lock().expect("task mutex poisoned").take();
356 if let Some(handle) = handle {
357 let _ = handle.await;
358 }
359 }
360}
361
362pub struct ChannelEventStream {
364 inner: BroadcastStream<ChannelEvent>,
365 filter_id: Option<String>,
366}
367
368impl Stream for ChannelEventStream {
369 type Item = ChannelEvent;
370
371 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
372 loop {
373 match self.inner.poll_next_unpin(cx) {
374 Poll::Ready(Some(Ok(event))) => {
375 if self.filter_id.as_ref().is_none_or(|id| *id == event.id) {
376 return Poll::Ready(Some(event));
377 }
378 }
379 Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(_)))) => {}
380 Poll::Ready(None) => return Poll::Ready(None),
381 Poll::Pending => return Poll::Pending,
382 }
383 }
384 }
385}
386
387pub struct LifecycleStream {
389 inner: BroadcastStream<LifecycleEvent>,
390}
391
392impl Stream for LifecycleStream {
393 type Item = LifecycleEvent;
394
395 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
396 loop {
397 match self.inner.poll_next_unpin(cx) {
398 Poll::Ready(Some(Ok(event))) => return Poll::Ready(Some(event)),
399 Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(_)))) => {}
400 Poll::Ready(None) => return Poll::Ready(None),
401 Poll::Pending => return Poll::Pending,
402 }
403 }
404 }
405}
406
407enum SessionOutcome {
409 Shutdown { code: u16, reason: String },
411 Disconnected { code: u16, reason: String },
413}
414
415async fn run(
417 options: RealtimeOptions,
418 mut cmd_rx: mpsc::UnboundedReceiver<Command>,
419 events_tx: broadcast::Sender<ChannelEvent>,
420 lifecycle_tx: broadcast::Sender<LifecycleEvent>,
421 connected: Arc<AtomicBool>,
422 ready_tx: oneshot::Sender<Result<()>>,
423) {
424 let mut ready = Some(ready_tx);
425 let mut policy = ReconnectPolicy::new(options.reconnect.unwrap_or_default());
426 let mut subscriptions = SubscriptionManager::default();
427
428 loop {
429 match connect_ws(&options).await {
430 Ok(ws) => {
431 connected.store(true, Ordering::SeqCst);
432 policy.reset();
433 if let Some(tx) = ready.take() {
434 let _ = tx.send(Ok(()));
435 }
436 let _ = lifecycle_tx.send(LifecycleEvent::Open);
437 #[cfg(feature = "tracing")]
438 tracing::debug!(url = %options.url, "radion realtime connected");
439
440 let outcome = session(
441 ws,
442 &options,
443 &mut cmd_rx,
444 &events_tx,
445 &lifecycle_tx,
446 &mut subscriptions,
447 )
448 .await;
449 connected.store(false, Ordering::SeqCst);
450
451 match outcome {
452 SessionOutcome::Shutdown { code, reason } => {
453 let _ = lifecycle_tx.send(LifecycleEvent::Close { code, reason });
454 return;
455 }
456 SessionOutcome::Disconnected { code, reason } => {
457 let _ = lifecycle_tx.send(LifecycleEvent::Close { code, reason });
458 if options.reconnect.is_none() {
459 return;
460 }
461 }
462 }
463 }
464 Err(error) => {
465 if let Some(tx) = ready.take() {
466 let _ = tx.send(Err(error));
468 return;
469 }
470 let _ = lifecycle_tx.send(LifecycleEvent::Error(error));
471 if options.reconnect.is_none() {
472 return;
473 }
474 }
475 }
476
477 let delay = policy.next_delay();
479 let _ = lifecycle_tx.send(LifecycleEvent::Reconnect {
480 attempt: policy.attempts(),
481 delay,
482 });
483 #[cfg(feature = "tracing")]
484 tracing::debug!(
485 ?delay,
486 attempt = policy.attempts(),
487 "radion realtime reconnecting"
488 );
489
490 tokio::select! {
491 () = tokio::time::sleep(delay) => {}
492 cmd = cmd_rx.recv() => match cmd {
493 Some(Command::Subscribe(subscription)) => {
494 subscriptions.add(subscription);
495 }
496 Some(Command::Unsubscribe(id)) => {
497 subscriptions.remove(&id);
498 }
499 Some(Command::Close { .. }) | None => return,
500 },
501 }
502 }
503}
504
505async fn connect_ws(
508 options: &RealtimeOptions,
509) -> Result<
510 impl Stream<Item = std::result::Result<Message, WsError>> + Sink<Message, Error = WsError> + Unpin,
511> {
512 let token = match &options.token_provider {
513 Some(provider) => Some(provider.fetch().await?),
514 None => None,
515 };
516
517 let base_url = connect_url(options);
518
519 let (ws, _response) = if options.auth_in_query {
520 let url = build_auth_query_url(&base_url, &options.api_key, token.as_deref());
521 let request = url.into_client_request().map_err(RadionError::transport)?;
522 tokio_tungstenite::connect_async(request)
523 .await
524 .map_err(RadionError::transport)?
525 } else {
526 let mut request = base_url
527 .as_str()
528 .into_client_request()
529 .map_err(RadionError::transport)?;
530 let api_key = HeaderValue::from_str(&options.api_key).map_err(RadionError::transport)?;
531 request
532 .headers_mut()
533 .insert(HeaderName::from_static("x-api-key"), api_key);
534 if let Some(token) = &token {
535 let bearer = HeaderValue::from_str(&format!("Bearer {token}"))
536 .map_err(RadionError::transport)?;
537 request
538 .headers_mut()
539 .insert(HeaderName::from_static("authorization"), bearer);
540 }
541 tokio_tungstenite::connect_async(request)
542 .await
543 .map_err(RadionError::transport)?
544 };
545 Ok(ws)
546}
547
548fn connect_url(options: &RealtimeOptions) -> String {
550 #[cfg(feature = "compression")]
551 {
552 if options.compression {
553 return with_compress_query(&options.url);
554 }
555 }
556 options.url.clone()
557}
558
559async fn session<S>(
561 mut ws: S,
562 options: &RealtimeOptions,
563 cmd_rx: &mut mpsc::UnboundedReceiver<Command>,
564 events_tx: &broadcast::Sender<ChannelEvent>,
565 lifecycle_tx: &broadcast::Sender<LifecycleEvent>,
566 subscriptions: &mut SubscriptionManager,
567) -> SessionOutcome
568where
569 S: Stream<Item = std::result::Result<Message, WsError>>
570 + Sink<Message, Error = WsError>
571 + Unpin,
572{
573 let replay: Vec<_> = subscriptions
575 .desired()
576 .map(OutboundFrame::subscribe)
577 .collect();
578 for frame in replay {
579 send(&mut ws, frame).await;
580 }
581
582 let mut ping = options
583 .heartbeat
584 .map(|hb| tokio::time::interval(hb.interval));
585 let mut stale_deadline: Option<tokio::time::Instant> = None;
586
587 loop {
588 let stale = async {
589 match stale_deadline {
590 Some(deadline) => tokio::time::sleep_until(deadline).await,
591 None => std::future::pending().await,
592 }
593 };
594
595 tokio::select! {
596 message = ws.next() => match message {
597 Some(Ok(message)) => {
598 stale_deadline = None;
599 if let Some(outcome) = handle_message(&message, options, events_tx, lifecycle_tx) {
600 return outcome;
601 }
602 }
603 Some(Err(error)) => {
604 let _ = lifecycle_tx.send(LifecycleEvent::Error(RadionError::transport(error)));
605 return SessionOutcome::Disconnected { code: 1006, reason: String::new() };
606 }
607 None => return SessionOutcome::Disconnected { code: 1006, reason: String::new() },
608 },
609 command = cmd_rx.recv() => match command {
610 Some(Command::Subscribe(subscription)) => {
611 if subscriptions.add(subscription.clone()) {
612 send(&mut ws, OutboundFrame::subscribe(&subscription)).await;
613 }
614 }
615 Some(Command::Unsubscribe(id)) => {
616 if subscriptions.remove(&id) {
617 send(&mut ws, OutboundFrame::Unsubscribe { id }).await;
618 }
619 }
620 Some(Command::Close { code, reason }) => {
621 let _ = ws.close().await;
622 return SessionOutcome::Shutdown { code, reason };
623 }
624 None => {
625 let _ = ws.close().await;
627 return SessionOutcome::Shutdown { code: 1000, reason: String::from("client dropped") };
628 }
629 },
630 () = next_ping(&mut ping) => {
631 send(&mut ws, OutboundFrame::Ping).await;
632 if stale_deadline.is_none() {
633 if let Some(hb) = options.heartbeat {
634 stale_deadline = Some(tokio::time::Instant::now() + hb.timeout);
635 }
636 }
637 }
638 () = stale => {
639 let _ = lifecycle_tx.send(LifecycleEvent::Error(RadionError::connection("stale connection")));
640 return SessionOutcome::Disconnected { code: 1006, reason: String::from("stale connection") };
641 }
642 }
643 }
644}
645
646async fn next_ping(ping: &mut Option<tokio::time::Interval>) {
648 match ping {
649 Some(interval) => {
650 interval.tick().await;
651 }
652 None => std::future::pending().await,
653 }
654}
655
656fn handle_message(
661 message: &Message,
662 options: &RealtimeOptions,
663 events_tx: &broadcast::Sender<ChannelEvent>,
664 lifecycle_tx: &broadcast::Sender<LifecycleEvent>,
665) -> Option<SessionOutcome> {
666 match message {
667 Message::Text(text) => {
668 route_text(text, events_tx, lifecycle_tx);
669 None
670 }
671 Message::Binary(bytes) => {
672 match options.decode_binary(bytes) {
673 Ok(text) => route_text(&text, events_tx, lifecycle_tx),
674 Err(error) => {
675 let _ = lifecycle_tx.send(LifecycleEvent::Error(error));
676 }
677 }
678 None
679 }
680 Message::Close(frame) => {
681 let (code, reason) = frame
682 .as_ref()
683 .map(|frame| (u16::from(frame.code), frame.reason.to_string()))
684 .unwrap_or((1005, String::new()));
685 Some(SessionOutcome::Disconnected { code, reason })
686 }
687 Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => None,
688 }
689}
690
691fn route_text(
693 text: &str,
694 events_tx: &broadcast::Sender<ChannelEvent>,
695 lifecycle_tx: &broadcast::Sender<LifecycleEvent>,
696) {
697 let Some(frame) = parse_inbound_frame(text) else {
698 return;
699 };
700 match frame {
701 frame @ InboundFrame::Event { .. } => {
702 if let Some(event) = frame.into_channel_event() {
703 let _ = events_tx.send(event);
704 }
705 }
706 InboundFrame::Warning { code, id, message } => {
707 let _ = lifecycle_tx.send(LifecycleEvent::Warning { code, id, message });
708 }
709 InboundFrame::Error {
710 message,
711 code,
712 id,
713 channel,
714 ..
715 } => {
716 let _ = lifecycle_tx.send(LifecycleEvent::Error(RadionError::Server {
717 message,
718 code,
719 channel,
720 id,
721 }));
722 }
723 InboundFrame::Pong
724 | InboundFrame::Subscribed { .. }
725 | InboundFrame::Unsubscribed { .. } => {}
726 }
727}
728
729async fn send<S>(ws: &mut S, frame: OutboundFrame)
731where
732 S: Sink<Message, Error = WsError> + Unpin,
733{
734 if let Ok(text) = serde_json::to_string(&frame) {
735 let _ = ws.send(Message::text(text)).await;
736 }
737}
738
739#[cfg(test)]
740mod auth_wiring_tests {
741 use super::*;
742
743 #[test]
744 fn defaults_have_no_token_and_header_mode() {
745 let options = RealtimeOptions::new("k");
746 assert!(options.token_provider.is_none());
747 assert!(!options.auth_in_query);
748 }
749
750 #[tokio::test]
751 async fn static_token_builder_sets_provider() {
752 let options = RealtimeOptions::new("k").token("jwt");
753 let provider = options.token_provider.expect("provider set");
754 assert_eq!(provider.fetch().await.unwrap(), "jwt");
755 }
756
757 #[test]
758 fn auth_in_query_builder_flips_flag() {
759 assert!(RealtimeOptions::new("k").auth_in_query(true).auth_in_query);
760 }
761
762 #[test]
763 fn accepts_async_provider() {
764 let _ = RealtimeOptions::new("k")
765 .token_provider(TokenProvider::new(|| async { Ok("x".into()) }));
766 }
767}
768
769#[cfg(all(test, feature = "compression"))]
770mod compression_wiring_tests {
771 use std::io::Write;
772
773 use flate2::Compression;
774 use flate2::write::ZlibEncoder;
775
776 use super::*;
777
778 const PONG: &str = r#"{"type":"pong"}"#;
779 const EVENT: &str = r#"{"type":"event","id":"t","channel":"trading","confirmed":true,"seq":1,"sent_at_ms":1721818200123,"data":{"type":"order_cancelled"}}"#;
780
781 fn deflate(text: &str) -> Vec<u8> {
782 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
783 encoder.write_all(text.as_bytes()).expect("writes");
784 encoder.finish().expect("finishes")
785 }
786
787 #[test]
788 fn compression_is_off_by_default() {
789 assert!(!RealtimeOptions::new("k").compression);
790 }
791
792 #[test]
793 fn builder_flips_the_flag() {
794 assert!(RealtimeOptions::new("k").compression(true).compression);
795 }
796
797 #[test]
798 fn connect_url_is_untouched_when_compression_is_off() {
799 let options = RealtimeOptions::new("k").url("wss://example.test/ws");
800 assert_eq!(connect_url(&options), "wss://example.test/ws");
801 }
802
803 #[test]
804 fn connect_url_asks_for_zlib_when_compression_is_on() {
805 let options = RealtimeOptions::new("k")
806 .url("wss://example.test/ws")
807 .compression(true);
808 assert_eq!(connect_url(&options), "wss://example.test/ws?compress=zlib");
809 }
810
811 #[test]
812 fn connect_url_keeps_an_existing_query() {
813 let options = RealtimeOptions::new("k")
814 .url("wss://example.test/ws?v=1")
815 .compression(true);
816 assert_eq!(
817 connect_url(&options),
818 "wss://example.test/ws?v=1&compress=zlib"
819 );
820 }
821
822 #[test]
823 fn binary_frames_inflate_when_compression_is_on() {
824 let options = RealtimeOptions::new("k").compression(true);
825 assert_eq!(options.decode_binary(&deflate(PONG)).unwrap(), PONG);
826 }
827
828 #[test]
829 fn binary_frames_stay_plain_when_compression_is_off() {
830 let options = RealtimeOptions::new("k");
831 assert_eq!(options.decode_binary(PONG.as_bytes()).unwrap(), PONG);
832 }
833
834 #[test]
835 fn inflate_failure_surfaces_on_the_lifecycle_stream() {
836 let options = RealtimeOptions::new("k").compression(true);
837 let (events_tx, _events_rx) = broadcast::channel(EVENT_BUFFER);
838 let (lifecycle_tx, mut lifecycle_rx) = broadcast::channel(LIFECYCLE_BUFFER);
839
840 let outcome = handle_message(
841 &Message::binary(b"not zlib at all".to_vec()),
842 &options,
843 &events_tx,
844 &lifecycle_tx,
845 );
846
847 assert!(outcome.is_none());
848 match lifecycle_rx.try_recv().expect("lifecycle event") {
849 LifecycleEvent::Error(RadionError::Decompression(_)) => {}
850 other => panic!("expected a decompression error, got {other:?}"),
851 }
852 }
853
854 #[test]
855 fn text_and_compressed_binary_both_deliver_events() {
856 let options = RealtimeOptions::new("k").compression(true);
857 let (events_tx, mut events_rx) = broadcast::channel(EVENT_BUFFER);
858 let (lifecycle_tx, _lifecycle_rx) = broadcast::channel(LIFECYCLE_BUFFER);
859
860 handle_message(&Message::text(EVENT), &options, &events_tx, &lifecycle_tx);
861 handle_message(
862 &Message::binary(deflate(EVENT)),
863 &options,
864 &events_tx,
865 &lifecycle_tx,
866 );
867
868 assert_eq!(events_rx.try_recv().expect("text event").channel, "trading");
869 assert_eq!(
870 events_rx.try_recv().expect("binary event").channel,
871 "trading"
872 );
873 }
874}