Skip to main content

jacquard_common/xrpc/
subscription.rs

1//! WebSocket subscription support for XRPC
2//!
3//! This module defines traits and types for typed WebSocket subscriptions,
4//! mirroring the request/response pattern used for HTTP XRPC endpoints.
5
6use crate::bos::BosStr;
7use crate::deps::fluent_uri::{
8    ParseError, Uri,
9    pct_enc::{
10        EString,
11        encoder::{Data as EncData, Query},
12    },
13};
14use crate::error::DecodeError;
15use crate::stream::StreamError;
16use crate::websocket::{WebSocketClient, WebSocketConnection, WsSink, WsStream};
17use crate::{CowStr, Data, IntoStatic, RawData, WsMessage};
18use alloc::borrow::ToOwned;
19use alloc::string::String;
20use alloc::string::ToString;
21use alloc::vec::Vec;
22use core::error::Error;
23use core::future::Future;
24use core::marker::PhantomData;
25#[cfg(not(target_arch = "wasm32"))]
26use n0_future::stream::Boxed;
27#[cfg(target_arch = "wasm32")]
28use n0_future::stream::BoxedLocal as Boxed;
29use serde::de::DeserializeOwned;
30use serde::{Deserialize, Serialize};
31use smol_str::SmolStr;
32
33/// Encoding format for subscription messages
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MessageEncoding {
36    /// JSON text frames
37    Json,
38    /// DAG-CBOR binary frames
39    DagCbor,
40}
41
42/// XRPC subscription stream response trait
43///
44/// Analogous to `XrpcResp` but for WebSocket subscriptions.
45/// Defines the message and error types for a subscription stream.
46///
47/// This trait is implemented on a marker struct to keep it lifetime-free
48/// while using GATs for the message/error types.
49pub trait SubscriptionResp {
50    /// The NSID for this subscription
51    const NSID: &'static str;
52
53    /// Message encoding (JSON or DAG-CBOR)
54    const ENCODING: MessageEncoding;
55
56    /// Message union type, parameterised on backing string type.
57    type Message<S: BosStr>;
58
59    /// Error union type. Always owned (`DeserializeOwned`).
60    type Error: Error + DeserializeOwned;
61
62    /// Decode a message from bytes.
63    ///
64    /// Default implementation uses simple deserialization via serde.
65    /// Subscriptions that use framed encoding (header + body) can override
66    /// this to do two-stage deserialization.
67    fn decode_message<'de, S>(bytes: &'de [u8]) -> Result<Self::Message<S>, DecodeError>
68    where
69        S: BosStr + Deserialize<'de>,
70        Self::Message<S>: Deserialize<'de>,
71    {
72        match Self::ENCODING {
73            MessageEncoding::Json => serde_json::from_slice(bytes).map_err(DecodeError::from),
74            MessageEncoding::DagCbor => {
75                serde_ipld_dagcbor::from_slice(bytes).map_err(DecodeError::from)
76            }
77        }
78    }
79}
80
81/// XRPC subscription (WebSocket)
82///
83/// This trait is analogous to `XrpcRequest` but for WebSocket subscriptions.
84/// It defines the NSID and associated stream response type.
85///
86/// The trait is implemented on the subscription parameters type.
87pub trait XrpcSubscription {
88    /// The NSID for this XRPC subscription
89    const NSID: &'static str;
90
91    /// Message encoding (JSON or DAG-CBOR)
92    const ENCODING: MessageEncoding;
93
94    /// Custom path override (e.g., "/subscribe" for Jetstream).
95    /// If None, defaults to "/xrpc/{NSID}"
96    const CUSTOM_PATH: Option<&'static str> = None;
97
98    /// Stream response type (marker struct)
99    type Stream: SubscriptionResp;
100
101    /// Encode query params for WebSocket URL
102    ///
103    /// Default implementation uses serde_html_form to encode the struct as query parameters.
104    fn query_params(&self) -> Vec<(String, String)>
105    where
106        Self: Serialize,
107    {
108        // Default: use serde_html_form to encode self
109        serde_html_form::to_string(self)
110            .ok()
111            .map(|s| {
112                s.split('&')
113                    .filter_map(|pair| {
114                        let mut parts = pair.splitn(2, '=');
115                        Some((parts.next()?.to_string(), parts.next()?.to_string()))
116                    })
117                    .collect()
118            })
119            .unwrap_or_default()
120    }
121}
122
123/// Header for framed DAG-CBOR subscription messages.
124///
125/// Used in ATProto subscription streams where each message has a CBOR-encoded header
126/// followed by the message body.
127#[derive(Debug, serde::Deserialize)]
128pub struct EventHeader {
129    /// Operation code
130    pub op: i64,
131    /// Event type discriminator (e.g., "#commit", "#identity")
132    pub t: smol_str::SmolStr,
133}
134
135/// A minimal cursor for no_std that tracks read position.
136///
137/// Implements `ciborium_io::Read` to work with ciborium's CBOR parser.
138#[cfg(not(feature = "std"))]
139struct SliceCursor<'a> {
140    slice: &'a [u8],
141    position: usize,
142}
143
144#[cfg(not(feature = "std"))]
145impl<'a> SliceCursor<'a> {
146    fn new(slice: &'a [u8]) -> Self {
147        Self { slice, position: 0 }
148    }
149
150    fn position(&self) -> usize {
151        self.position
152    }
153}
154
155#[cfg(not(feature = "std"))]
156impl ciborium_io::Read for SliceCursor<'_> {
157    type Error = core::convert::Infallible;
158
159    fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> {
160        let end = self.position + buf.len();
161        buf.copy_from_slice(&self.slice[self.position..end]);
162        self.position = end;
163        Ok(())
164    }
165}
166
167/// Parse a framed DAG-CBOR message header and return the header plus remaining body bytes.
168///
169/// Used for two-stage deserialization of subscription messages in formats like
170/// `com.atproto.sync.subscribeRepos`.
171#[cfg(feature = "std")]
172pub fn parse_event_header<'a>(bytes: &'a [u8]) -> Result<(EventHeader, &'a [u8]), DecodeError> {
173    let mut cursor = std::io::Cursor::new(bytes);
174    let header: EventHeader = ciborium::de::from_reader(&mut cursor)?;
175    let position = cursor.position() as usize;
176    drop(cursor); // explicit drop before reborrowing bytes
177
178    Ok((header, &bytes[position..]))
179}
180
181/// Parse a framed DAG-CBOR message header and return the header plus remaining body bytes.
182///
183/// Used for two-stage deserialization of subscription messages in formats like
184/// `com.atproto.sync.subscribeRepos`.
185#[cfg(not(feature = "std"))]
186pub fn parse_event_header<'a>(bytes: &'a [u8]) -> Result<(EventHeader, &'a [u8]), DecodeError> {
187    let mut cursor = SliceCursor::new(bytes);
188    let header: EventHeader = ciborium::de::from_reader(&mut cursor)?;
189    let position = cursor.position();
190
191    Ok((header, &bytes[position..]))
192}
193
194/// Decode JSON messages from a WebSocket stream
195pub fn decode_json_msg<S: SubscriptionResp>(
196    msg_result: Result<crate::websocket::WsMessage, StreamError>,
197) -> Option<Result<StreamMessage<SmolStr, S>, StreamError>>
198where
199    StreamMessage<SmolStr, S>: DeserializeOwned,
200{
201    use crate::websocket::WsMessage;
202
203    match msg_result {
204        Ok(WsMessage::Text(text)) => {
205            Some(S::decode_message::<SmolStr>(text.as_ref()).map_err(StreamError::decode))
206        }
207        Ok(WsMessage::Binary(bytes)) => {
208            #[cfg(feature = "zstd")]
209            {
210                // Try to decompress with zstd first (Jetstream uses zstd compression)
211                match decompress_zstd(&bytes) {
212                    Ok(decompressed) => Some(
213                        S::decode_message::<SmolStr>(&decompressed).map_err(StreamError::decode),
214                    ),
215                    Err(_) => {
216                        // Not zstd-compressed, try direct decode
217                        Some(S::decode_message::<SmolStr>(&bytes).map_err(StreamError::decode))
218                    }
219                }
220            }
221            #[cfg(not(feature = "zstd"))]
222            {
223                Some(S::decode_message::<SmolStr>(&bytes).map_err(StreamError::decode))
224            }
225        }
226        Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
227        Err(e) => Some(Err(e)),
228    }
229}
230
231#[cfg(feature = "zstd")]
232fn decompress_zstd(bytes: &[u8]) -> Result<Vec<u8>, std::io::Error> {
233    use std::sync::OnceLock;
234    use zstd::stream::decode_all;
235
236    static DICTIONARY: OnceLock<Vec<u8>> = OnceLock::new();
237
238    let dict = DICTIONARY.get_or_init(|| include_bytes!("../../zstd_dictionary").to_vec());
239
240    decode_all(std::io::Cursor::new(bytes)).or_else(|_| {
241        // Try with dictionary
242        let mut decoder = zstd::Decoder::with_dictionary(std::io::Cursor::new(bytes), dict)?;
243        let mut result = Vec::new();
244        std::io::Read::read_to_end(&mut decoder, &mut result)?;
245        Ok(result)
246    })
247}
248
249/// Decode CBOR messages from a WebSocket stream
250pub fn decode_cbor_msg<S: SubscriptionResp>(
251    msg_result: Result<crate::websocket::WsMessage, StreamError>,
252) -> Option<Result<StreamMessage<SmolStr, S>, StreamError>>
253where
254    StreamMessage<SmolStr, S>: DeserializeOwned,
255{
256    use crate::websocket::WsMessage;
257
258    match msg_result {
259        Ok(WsMessage::Binary(bytes)) => {
260            Some(S::decode_message::<SmolStr>(&bytes).map_err(StreamError::decode))
261        }
262        Ok(WsMessage::Text(_)) => Some(Err(StreamError::wrong_message_format(
263            "expected binary frame for CBOR, got text",
264        ))),
265        Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
266        Err(e) => Some(Err(e)),
267    }
268}
269
270/// Websocket subscriber-sent control message
271///
272/// Note: this is not meaningful for atproto event stream endpoints as
273/// those do not support control after the fact. Jetstream does, however.
274///
275/// If you wish to control an ongoing Jetstream connection, wrap the [`WsSink`]
276/// returned from one of the `into_*` methods of the [`SubscriptionStream`]
277/// in a [`SubscriptionController`] with the corresponding message implementing
278/// this trait as a generic parameter.
279pub trait SubscriptionControlMessage: Serialize {
280    /// The subscription this is associated with
281    type Subscription: XrpcSubscription;
282
283    /// Encode the control message for transmission
284    ///
285    /// Defaults to json text (matches Jetstream)
286    fn encode(&self) -> Result<WsMessage, StreamError> {
287        Ok(WsMessage::from(
288            serde_json::to_string(&self).map_err(StreamError::encode)?,
289        ))
290    }
291
292    /// Decode the control message
293    fn decode<'de>(frame: &'de [u8]) -> Result<Self, StreamError>
294    where
295        Self: Deserialize<'de>,
296    {
297        Ok(serde_json::from_slice(frame).map_err(StreamError::decode)?)
298    }
299}
300
301/// Control a websocket stream with a given subscription control message
302pub struct SubscriptionController<S: SubscriptionControlMessage> {
303    controller: WsSink,
304    _marker: PhantomData<fn() -> S>,
305}
306
307impl<S: SubscriptionControlMessage> SubscriptionController<S> {
308    /// Create a new subscription controller from a WebSocket sink.
309    pub fn new(controller: WsSink) -> Self {
310        Self {
311            controller,
312            _marker: PhantomData,
313        }
314    }
315
316    /// Configure the upstream connection via the websocket
317    pub async fn configure(&mut self, params: &S) -> Result<(), StreamError> {
318        let message = params.encode()?;
319
320        n0_future::SinkExt::send(self.controller.get_mut(), message)
321            .await
322            .map_err(StreamError::transport)
323    }
324}
325
326/// Typed subscription stream wrapping a WebSocket connection.
327///
328/// Analogous to `Response<R>` for XRPC but for subscription streams.
329/// Automatically decodes messages based on the subscription's encoding format.
330pub struct SubscriptionStream<S: SubscriptionResp> {
331    _marker: PhantomData<fn() -> S>,
332    connection: WebSocketConnection,
333}
334
335impl<S: SubscriptionResp> SubscriptionStream<S> {
336    /// Create a new subscription stream from a WebSocket connection.
337    pub fn new(connection: WebSocketConnection) -> Self {
338        Self {
339            _marker: PhantomData,
340            connection,
341        }
342    }
343
344    /// Get a reference to the underlying WebSocket connection.
345    pub fn connection(&self) -> &WebSocketConnection {
346        &self.connection
347    }
348
349    /// Get a mutable reference to the underlying WebSocket connection.
350    pub fn connection_mut(&mut self) -> &mut WebSocketConnection {
351        &mut self.connection
352    }
353
354    /// Split the connection and decode messages into a typed stream.
355    ///
356    /// Returns a tuple of (sender, typed message stream).
357    /// Messages are decoded according to the subscription's ENCODING.
358    pub fn into_stream(
359        self,
360    ) -> (
361        WsSink,
362        Boxed<Result<StreamMessage<SmolStr, S>, StreamError>>,
363    )
364    where
365        StreamMessage<SmolStr, S>: DeserializeOwned,
366    {
367        use n0_future::StreamExt as _;
368
369        let (tx, rx) = self.connection.split();
370
371        #[cfg(not(target_arch = "wasm32"))]
372        let stream = match S::ENCODING {
373            MessageEncoding::Json => rx
374                .into_inner()
375                .filter_map(|msg| decode_json_msg::<S>(msg))
376                .boxed(),
377            MessageEncoding::DagCbor => rx
378                .into_inner()
379                .filter_map(|msg| decode_cbor_msg::<S>(msg))
380                .boxed(),
381        };
382
383        #[cfg(target_arch = "wasm32")]
384        let stream = match S::ENCODING {
385            MessageEncoding::Json => rx
386                .into_inner()
387                .filter_map(|msg| decode_json_msg::<S>(msg))
388                .boxed_local(),
389            MessageEncoding::DagCbor => rx
390                .into_inner()
391                .filter_map(|msg| decode_cbor_msg::<S>(msg))
392                .boxed_local(),
393        };
394
395        (tx, stream)
396    }
397
398    /// Converts the subscription into a stream of raw atproto data.
399    pub fn into_raw_data_stream(self) -> (WsSink, Boxed<Result<RawData<'static>, StreamError>>) {
400        use n0_future::StreamExt as _;
401
402        let (tx, rx) = self.connection.split();
403
404        fn parse_msg<'a>(bytes: &'a [u8]) -> Result<RawData<'a>, serde_json::Error> {
405            serde_json::from_slice(bytes)
406        }
407        fn parse_cbor<'a>(
408            bytes: &'a [u8],
409        ) -> Result<RawData<'a>, serde_ipld_dagcbor::DecodeError<core::convert::Infallible>>
410        {
411            serde_ipld_dagcbor::from_slice(bytes)
412        }
413
414        #[cfg(not(target_arch = "wasm32"))]
415        let stream = match S::ENCODING {
416            MessageEncoding::Json => rx
417                .into_inner()
418                .filter_map(|msg_result| match msg_result {
419                    Ok(WsMessage::Text(text)) => Some(
420                        parse_msg(text.as_ref())
421                            .map(|v| v.into_static())
422                            .map_err(StreamError::decode),
423                    ),
424                    Ok(WsMessage::Binary(bytes)) => {
425                        #[cfg(feature = "zstd")]
426                        {
427                            match decompress_zstd(&bytes) {
428                                Ok(decompressed) => Some(
429                                    parse_msg(&decompressed)
430                                        .map(|v| v.into_static())
431                                        .map_err(StreamError::decode),
432                                ),
433                                Err(_) => Some(
434                                    parse_msg(&bytes)
435                                        .map(|v| v.into_static())
436                                        .map_err(StreamError::decode),
437                                ),
438                            }
439                        }
440                        #[cfg(not(feature = "zstd"))]
441                        {
442                            Some(
443                                parse_msg(&bytes)
444                                    .map(|v| v.into_static())
445                                    .map_err(StreamError::decode),
446                            )
447                        }
448                    }
449                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
450                    Err(e) => Some(Err(e)),
451                })
452                .boxed(),
453            MessageEncoding::DagCbor => rx
454                .into_inner()
455                .filter_map(|msg_result| match msg_result {
456                    Ok(WsMessage::Binary(bytes)) => Some(
457                        parse_cbor(&bytes)
458                            .map(|v| v.into_static())
459                            .map_err(|e| StreamError::decode(crate::error::DecodeError::from(e))),
460                    ),
461                    Ok(WsMessage::Text(_)) => Some(Err(StreamError::wrong_message_format(
462                        "expected binary frame for CBOR, got text",
463                    ))),
464                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
465                    Err(e) => Some(Err(e)),
466                })
467                .boxed(),
468        };
469
470        #[cfg(target_arch = "wasm32")]
471        let stream = match S::ENCODING {
472            MessageEncoding::Json => rx
473                .into_inner()
474                .filter_map(|msg_result| match msg_result {
475                    Ok(WsMessage::Text(text)) => Some(
476                        parse_msg(text.as_ref())
477                            .map(|v| v.into_static())
478                            .map_err(StreamError::decode),
479                    ),
480                    Ok(WsMessage::Binary(bytes)) => {
481                        #[cfg(feature = "zstd")]
482                        {
483                            match decompress_zstd(&bytes) {
484                                Ok(decompressed) => Some(
485                                    parse_msg(&decompressed)
486                                        .map(|v| v.into_static())
487                                        .map_err(StreamError::decode),
488                                ),
489                                Err(_) => Some(
490                                    parse_msg(&bytes)
491                                        .map(|v| v.into_static())
492                                        .map_err(StreamError::decode),
493                                ),
494                            }
495                        }
496                        #[cfg(not(feature = "zstd"))]
497                        {
498                            Some(
499                                parse_msg(&bytes)
500                                    .map(|v| v.into_static())
501                                    .map_err(StreamError::decode),
502                            )
503                        }
504                    }
505                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
506                    Err(e) => Some(Err(e)),
507                })
508                .boxed_local(),
509            MessageEncoding::DagCbor => rx
510                .into_inner()
511                .filter_map(|msg_result| match msg_result {
512                    Ok(WsMessage::Binary(bytes)) => Some(
513                        parse_cbor(&bytes)
514                            .map(|v| v.into_static())
515                            .map_err(|e| StreamError::decode(crate::error::DecodeError::from(e))),
516                    ),
517                    Ok(WsMessage::Text(_)) => Some(Err(StreamError::wrong_message_format(
518                        "expected binary frame for CBOR, got text",
519                    ))),
520                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
521                    Err(e) => Some(Err(e)),
522                })
523                .boxed_local(),
524        };
525
526        (tx, stream)
527    }
528
529    /// Converts the subscription into a stream of loosely-typed atproto data.
530    pub fn into_data_stream(self) -> (WsSink, Boxed<Result<Data<smol_str::SmolStr>, StreamError>>) {
531        use n0_future::StreamExt as _;
532
533        let (tx, rx) = self.connection.split();
534
535        fn parse_msg(bytes: &[u8]) -> Result<Data<smol_str::SmolStr>, serde_json::Error> {
536            serde_json::from_slice(bytes)
537        }
538        fn parse_cbor(
539            bytes: &[u8],
540        ) -> Result<
541            Data<smol_str::SmolStr>,
542            serde_ipld_dagcbor::DecodeError<core::convert::Infallible>,
543        > {
544            serde_ipld_dagcbor::from_slice(bytes)
545        }
546
547        #[cfg(not(target_arch = "wasm32"))]
548        let stream = match S::ENCODING {
549            MessageEncoding::Json => rx
550                .into_inner()
551                .filter_map(|msg_result| match msg_result {
552                    Ok(WsMessage::Text(text)) => Some(
553                        parse_msg(text.as_ref())
554                            .map(|v| v.into_static())
555                            .map_err(StreamError::decode),
556                    ),
557                    Ok(WsMessage::Binary(bytes)) => {
558                        #[cfg(feature = "zstd")]
559                        {
560                            match decompress_zstd(&bytes) {
561                                Ok(decompressed) => Some(
562                                    parse_msg(&decompressed)
563                                        .map(|v| v.into_static())
564                                        .map_err(StreamError::decode),
565                                ),
566                                Err(_) => Some(
567                                    parse_msg(&bytes)
568                                        .map(|v| v.into_static())
569                                        .map_err(StreamError::decode),
570                                ),
571                            }
572                        }
573                        #[cfg(not(feature = "zstd"))]
574                        {
575                            Some(
576                                parse_msg(&bytes)
577                                    .map(|v| v.into_static())
578                                    .map_err(StreamError::decode),
579                            )
580                        }
581                    }
582                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
583                    Err(e) => Some(Err(e)),
584                })
585                .boxed(),
586            MessageEncoding::DagCbor => rx
587                .into_inner()
588                .filter_map(|msg_result| match msg_result {
589                    Ok(WsMessage::Binary(bytes)) => Some(
590                        parse_cbor(&bytes)
591                            .map(|v| v.into_static())
592                            .map_err(|e| StreamError::decode(crate::error::DecodeError::from(e))),
593                    ),
594                    Ok(WsMessage::Text(_)) => Some(Err(StreamError::wrong_message_format(
595                        "expected binary frame for CBOR, got text",
596                    ))),
597                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
598                    Err(e) => Some(Err(e)),
599                })
600                .boxed(),
601        };
602
603        #[cfg(target_arch = "wasm32")]
604        let stream = match S::ENCODING {
605            MessageEncoding::Json => rx
606                .into_inner()
607                .filter_map(|msg_result| match msg_result {
608                    Ok(WsMessage::Text(text)) => Some(
609                        parse_msg(text.as_ref())
610                            .map(|v| v.into_static())
611                            .map_err(StreamError::decode),
612                    ),
613                    Ok(WsMessage::Binary(bytes)) => {
614                        #[cfg(feature = "zstd")]
615                        {
616                            match decompress_zstd(&bytes) {
617                                Ok(decompressed) => Some(
618                                    parse_msg(&decompressed)
619                                        .map(|v| v.into_static())
620                                        .map_err(StreamError::decode),
621                                ),
622                                Err(_) => Some(
623                                    parse_msg(&bytes)
624                                        .map(|v| v.into_static())
625                                        .map_err(StreamError::decode),
626                                ),
627                            }
628                        }
629                        #[cfg(not(feature = "zstd"))]
630                        {
631                            Some(
632                                parse_msg(&bytes)
633                                    .map(|v| v.into_static())
634                                    .map_err(StreamError::decode),
635                            )
636                        }
637                    }
638                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
639                    Err(e) => Some(Err(e)),
640                })
641                .boxed_local(),
642            MessageEncoding::DagCbor => rx
643                .into_inner()
644                .filter_map(|msg_result| match msg_result {
645                    Ok(WsMessage::Binary(bytes)) => Some(
646                        parse_cbor(&bytes)
647                            .map(|v| v.into_static())
648                            .map_err(|e| StreamError::decode(crate::error::DecodeError::from(e))),
649                    ),
650                    Ok(WsMessage::Text(_)) => Some(Err(StreamError::wrong_message_format(
651                        "expected binary frame for CBOR, got text",
652                    ))),
653                    Ok(WsMessage::Close(_)) => Some(Err(StreamError::closed())),
654                    Err(e) => Some(Err(e)),
655                })
656                .boxed_local(),
657        };
658
659        (tx, stream)
660    }
661
662    /// Consume the stream and return the underlying connection.
663    pub fn into_connection(self) -> WebSocketConnection {
664        self.connection
665    }
666
667    /// Tee the stream, keeping the raw stream in self and returning a typed stream.
668    ///
669    /// Replaces the internal WebSocket stream with one copy and returns a typed decoded
670    /// stream. Both streams receive all messages. Useful for observing raw messages
671    /// while also processing typed messages.
672    pub fn tee(&mut self) -> Boxed<Result<StreamMessage<SmolStr, S>, StreamError>>
673    where
674        StreamMessage<SmolStr, S>: DeserializeOwned,
675    {
676        use n0_future::StreamExt as _;
677
678        let rx = self.connection.receiver_mut();
679        let (raw_rx, typed_rx_source) =
680            core::mem::replace(rx, WsStream::new(n0_future::stream::empty())).tee();
681
682        // Put the raw stream back
683        *rx = raw_rx;
684
685        #[cfg(not(target_arch = "wasm32"))]
686        let stream = match S::ENCODING {
687            MessageEncoding::Json => typed_rx_source
688                .into_inner()
689                .filter_map(|msg| decode_json_msg::<S>(msg))
690                .boxed(),
691            MessageEncoding::DagCbor => typed_rx_source
692                .into_inner()
693                .filter_map(|msg| decode_cbor_msg::<S>(msg))
694                .boxed(),
695        };
696
697        #[cfg(target_arch = "wasm32")]
698        let stream = match S::ENCODING {
699            MessageEncoding::Json => typed_rx_source
700                .into_inner()
701                .filter_map(|msg| decode_json_msg::<S>(msg))
702                .boxed_local(),
703            MessageEncoding::DagCbor => typed_rx_source
704                .into_inner()
705                .filter_map(|msg| decode_cbor_msg::<S>(msg))
706                .boxed_local(),
707        };
708        stream
709    }
710}
711
712type StreamMessage<S, R> = <R as SubscriptionResp>::Message<S>;
713
714/// XRPC subscription endpoint trait (server-side)
715///
716/// Analogous to `XrpcEndpoint` but for WebSocket subscriptions.
717/// Defines the fully-qualified path and associated parameter/stream types.
718///
719/// This exists primarily for server-side frameworks (like Axum) to extract
720/// typed subscription parameters without lifetime issues.
721pub trait SubscriptionEndpoint {
722    /// Fully-qualified path ('/xrpc/{nsid}') where this subscription endpoint lives
723    const PATH: &'static str;
724
725    /// Message encoding (JSON or DAG-CBOR)
726    const ENCODING: MessageEncoding;
727
728    /// Subscription parameters type
729    type Params<S: BosStr>: XrpcSubscription;
730
731    /// Stream response type
732    type Stream: SubscriptionResp;
733}
734
735/// Per-subscription options for WebSocket subscriptions.
736#[derive(Debug, Default, Clone)]
737pub struct SubscriptionOptions<'a> {
738    /// Extra headers to attach to this subscription (e.g., Authorization).
739    pub headers: Vec<(CowStr<'a>, CowStr<'a>)>,
740}
741
742impl IntoStatic for SubscriptionOptions<'_> {
743    type Output = SubscriptionOptions<'static>;
744
745    fn into_static(self) -> Self::Output {
746        SubscriptionOptions {
747            headers: self
748                .headers
749                .into_iter()
750                .map(|(k, v)| (k.into_static(), v.into_static()))
751                .collect(),
752        }
753    }
754}
755
756/// Extension for stateless subscription calls on any `WebSocketClient`.
757///
758/// Provides a builder pattern for establishing WebSocket subscriptions with custom options.
759pub trait SubscriptionExt: WebSocketClient {
760    /// Start building a subscription call for the given base URI.
761    fn subscription<'a>(&'a self, base: Uri<String>) -> SubscriptionCall<'a, Self>
762    where
763        Self: Sized,
764    {
765        SubscriptionCall {
766            client: self,
767            base,
768            opts: SubscriptionOptions::default(),
769        }
770    }
771}
772
773impl<T: WebSocketClient> SubscriptionExt for T {}
774
775/// Build a subscription URI from a base URI, optional custom path, and query parameters.
776///
777/// This is a pure function that constructs the complete subscription WebSocket URI.
778/// It supports both standard NSID-based paths (e.g., `/xrpc/{nsid}`) and custom paths
779/// (e.g., Jetstream's `/subscribe`).
780///
781/// # Arguments
782///
783/// - `base`: The base URI (e.g., `wss://bsky.social`)
784/// - `nsid`: The subscription NSID (e.g., `com.atproto.sync.subscribeRepos`)
785/// - `custom_path`: Optional custom path to use instead of `/xrpc/{nsid}`
786/// - `query_params`: Query parameters as (key, value) pairs
787///
788/// # Returns
789///
790/// A complete subscription URI with scheme, authority, path, and optional query string,
791/// or a parse error if the constructed URI is invalid.
792fn build_subscription_uri(
793    base: &Uri<String>,
794    nsid: &str,
795    custom_path: Option<&str>,
796    query_params: &[(String, String)],
797) -> Result<Uri<String>, ParseError> {
798    let base_path = base.path().as_str().trim_end_matches('/');
799
800    // Build the path: base_path + custom_path or "/xrpc/{nsid}"
801    let mut path = String::with_capacity(base_path.len() + 50);
802    path.push_str(base_path);
803    if let Some(custom_path) = custom_path {
804        path.push_str(custom_path);
805    } else {
806        path.push_str("/xrpc/");
807        path.push_str(nsid);
808    }
809
810    // Build query string from parameters with percent-encoding
811    let query_str = if !query_params.is_empty() {
812        query_params
813            .iter()
814            .map(|(k, v)| {
815                let mut enc_k = EString::<Query>::new();
816                enc_k.encode_str::<EncData>(k.as_str());
817                let mut enc_v = EString::<Query>::new();
818                enc_v.encode_str::<EncData>(v.as_str());
819                alloc::format!("{}={}", enc_k, enc_v)
820            })
821            .collect::<Vec<_>>()
822            .join("&")
823    } else {
824        String::new()
825    };
826
827    // Calculate approximate capacity for the final URI string
828    let capacity = base.scheme().as_str().len()
829        + 3 // "://"
830        + base.authority().map(|a| a.as_str().len()).unwrap_or(0)
831        + path.len()
832        + query_str.len()
833        + if !query_str.is_empty() { 1 } else { 0 }; // "?"
834
835    // Construct the URI using fluent-uri builder pattern
836    let mut uri_str = String::with_capacity(capacity);
837    uri_str.push_str(base.scheme().as_str());
838    uri_str.push_str("://");
839
840    if let Some(authority) = base.authority() {
841        uri_str.push_str(authority.as_str());
842    }
843
844    uri_str.push_str(&path);
845
846    if !query_str.is_empty() {
847        uri_str.push('?');
848        uri_str.push_str(&query_str);
849    }
850
851    Uri::parse(uri_str)
852        .map(|u| u.to_owned())
853        .map_err(|(e, _)| e)
854}
855
856/// Stateless subscription call builder.
857///
858/// Provides methods for adding headers and establishing typed subscriptions.
859pub struct SubscriptionCall<'a, C: WebSocketClient> {
860    pub(crate) client: &'a C,
861    pub(crate) base: Uri<String>,
862    pub(crate) opts: SubscriptionOptions<'a>,
863}
864
865impl<'a, C: WebSocketClient> SubscriptionCall<'a, C> {
866    /// Add an extra header.
867    pub fn header(mut self, name: impl Into<CowStr<'a>>, value: impl Into<CowStr<'a>>) -> Self {
868        self.opts.headers.push((name.into(), value.into()));
869        self
870    }
871
872    /// Replace the builder's options entirely.
873    pub fn with_options(mut self, opts: SubscriptionOptions<'a>) -> Self {
874        self.opts = opts;
875        self
876    }
877
878    /// Subscribe to the given XRPC subscription endpoint.
879    ///
880    /// Builds a WebSocket URI from the base, appends the NSID path,
881    /// encodes query parameters from the subscription type, and connects.
882    /// Returns a typed SubscriptionStream that automatically decodes messages.
883    pub async fn subscribe<Sub>(
884        self,
885        params: &Sub,
886    ) -> Result<SubscriptionStream<Sub::Stream>, C::Error>
887    where
888        Sub: XrpcSubscription + Serialize,
889    {
890        let query_params = params.query_params();
891        let uri = build_subscription_uri(&self.base, Sub::NSID, Sub::CUSTOM_PATH, &query_params)
892            .expect("subscription URI must be valid (base_uri + path always yields a valid URI)");
893
894        let connection = self
895            .client
896            .connect_with_headers(uri.borrow(), self.opts.headers)
897            .await?;
898
899        Ok(SubscriptionStream::new(connection))
900    }
901}
902
903/// Stateful subscription client trait.
904///
905/// Analogous to `XrpcClient` but for WebSocket subscriptions.
906/// Provides a stateful interface for subscribing with configured base URI and options.
907#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
908pub trait SubscriptionClient: WebSocketClient {
909    /// Get the base URI for the client.
910    fn base_uri(&self) -> impl Future<Output = Uri<String>>;
911
912    /// Get the subscription options for the client.
913    fn subscription_opts(&self) -> impl Future<Output = SubscriptionOptions<'_>> {
914        async { SubscriptionOptions::default() }
915    }
916
917    /// Subscribe to an XRPC subscription endpoint using the client's base URI and options.
918    #[cfg(not(target_arch = "wasm32"))]
919    fn subscribe<Sub>(
920        &self,
921        params: &Sub,
922    ) -> impl Future<Output = Result<SubscriptionStream<Sub::Stream>, Self::Error>>
923    where
924        Sub: XrpcSubscription + Serialize + Send + Sync,
925        Self: Sync;
926
927    /// Subscribe to an XRPC subscription endpoint using the client's base URI and options.
928    #[cfg(target_arch = "wasm32")]
929    fn subscribe<Sub>(
930        &self,
931        params: &Sub,
932    ) -> impl Future<Output = Result<SubscriptionStream<Sub::Stream>, Self::Error>>
933    where
934        Sub: XrpcSubscription + Serialize + Send + Sync;
935
936    /// Subscribe with custom options.
937    #[cfg(not(target_arch = "wasm32"))]
938    fn subscribe_with_opts<Sub>(
939        &self,
940        params: &Sub,
941        opts: SubscriptionOptions<'_>,
942    ) -> impl Future<Output = Result<SubscriptionStream<Sub::Stream>, Self::Error>>
943    where
944        Sub: XrpcSubscription + Serialize + Send + Sync,
945        Self: Sync;
946
947    /// Subscribe with custom options.
948    #[cfg(target_arch = "wasm32")]
949    fn subscribe_with_opts<Sub>(
950        &self,
951        params: &Sub,
952        opts: SubscriptionOptions<'_>,
953    ) -> impl Future<Output = Result<SubscriptionStream<Sub::Stream>, Self::Error>>
954    where
955        Sub: XrpcSubscription + Serialize + Send + Sync;
956}
957
958/// Simple stateless subscription client wrapping a WebSocketClient.
959///
960/// Analogous to a basic HTTP client but for WebSocket subscriptions.
961/// Does not manage sessions or authentication - useful for public subscriptions
962/// or when you want to handle auth manually via headers.
963pub struct BasicSubscriptionClient<W: WebSocketClient> {
964    client: W,
965    base_uri: Uri<String>,
966    opts: SubscriptionOptions<'static>,
967}
968
969impl<W: WebSocketClient> BasicSubscriptionClient<W> {
970    /// Create a new basic subscription client with the given WebSocket client and base URI.
971    pub fn new(client: W, base_uri: Uri<String>) -> Self {
972        Self {
973            client,
974            base_uri,
975            opts: SubscriptionOptions::default(),
976        }
977    }
978
979    /// Create with default options.
980    pub fn with_options(mut self, opts: SubscriptionOptions<'_>) -> Self {
981        self.opts = opts.into_static();
982        self
983    }
984
985    /// Get a reference to the inner WebSocket client.
986    pub fn inner(&self) -> &W {
987        &self.client
988    }
989}
990
991impl<W: WebSocketClient> WebSocketClient for BasicSubscriptionClient<W> {
992    type Error = W::Error;
993
994    async fn connect(&self, uri: Uri<&str>) -> Result<WebSocketConnection, Self::Error> {
995        self.client.connect(uri).await
996    }
997
998    async fn connect_with_headers(
999        &self,
1000        uri: Uri<&str>,
1001        headers: Vec<(CowStr<'_>, CowStr<'_>)>,
1002    ) -> Result<WebSocketConnection, Self::Error> {
1003        self.client.connect_with_headers(uri, headers).await
1004    }
1005}
1006
1007impl<W: WebSocketClient> SubscriptionClient for BasicSubscriptionClient<W> {
1008    async fn base_uri(&self) -> Uri<String> {
1009        self.base_uri.clone()
1010    }
1011
1012    async fn subscription_opts(&self) -> SubscriptionOptions<'_> {
1013        self.opts.clone()
1014    }
1015
1016    #[cfg(not(target_arch = "wasm32"))]
1017    async fn subscribe<Sub>(
1018        &self,
1019        params: &Sub,
1020    ) -> Result<SubscriptionStream<Sub::Stream>, Self::Error>
1021    where
1022        Sub: XrpcSubscription + Serialize + Send + Sync,
1023        Self: Sync,
1024    {
1025        let opts = self.subscription_opts().await;
1026        self.subscribe_with_opts(params, opts).await
1027    }
1028
1029    #[cfg(target_arch = "wasm32")]
1030    async fn subscribe<Sub>(
1031        &self,
1032        params: &Sub,
1033    ) -> Result<SubscriptionStream<Sub::Stream>, Self::Error>
1034    where
1035        Sub: XrpcSubscription + Serialize + Send + Sync,
1036    {
1037        let opts = self.subscription_opts().await;
1038        self.subscribe_with_opts(params, opts).await
1039    }
1040
1041    #[cfg(not(target_arch = "wasm32"))]
1042    async fn subscribe_with_opts<Sub>(
1043        &self,
1044        params: &Sub,
1045        opts: SubscriptionOptions<'_>,
1046    ) -> Result<SubscriptionStream<Sub::Stream>, Self::Error>
1047    where
1048        Sub: XrpcSubscription + Serialize + Send + Sync,
1049        Self: Sync,
1050    {
1051        let base = self.base_uri().await;
1052        self.subscription(base)
1053            .with_options(opts)
1054            .subscribe(params)
1055            .await
1056    }
1057
1058    #[cfg(target_arch = "wasm32")]
1059    async fn subscribe_with_opts<Sub>(
1060        &self,
1061        params: &Sub,
1062        opts: SubscriptionOptions<'_>,
1063    ) -> Result<SubscriptionStream<Sub::Stream>, Self::Error>
1064    where
1065        Sub: XrpcSubscription + Serialize + Send + Sync,
1066    {
1067        let base = self.base_uri().await;
1068        self.subscription(base)
1069            .with_options(opts)
1070            .subscribe(params)
1071            .await
1072    }
1073}
1074
1075/// Type alias for a basic subscription client using the default TungsteniteClient.
1076///
1077/// Provides a simple, stateless WebSocket subscription client without session management.
1078/// Useful for public subscriptions or when handling authentication manually.
1079///
1080/// # Example
1081///
1082/// ```no_run
1083/// # use jacquard_common::xrpc::{TungsteniteSubscriptionClient, SubscriptionClient};
1084/// # use jacquard_common::deps::fluent_uri::Uri;
1085/// # #[tokio::main]
1086/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1087/// let base = Uri::parse("wss://bsky.network")?.to_owned();
1088/// let client = TungsteniteSubscriptionClient::from_base_uri(base);
1089/// // let conn = client.subscribe(&params).await?;
1090/// # Ok(())
1091/// # }
1092/// ```
1093pub type TungsteniteSubscriptionClient =
1094    BasicSubscriptionClient<crate::websocket::tungstenite_client::TungsteniteClient>;
1095
1096impl TungsteniteSubscriptionClient {
1097    /// Create a new Tungstenite-backed subscription client with the given base URI.
1098    pub fn from_base_uri(base_uri: Uri<String>) -> Self {
1099        let client = crate::websocket::tungstenite_client::TungsteniteClient::new();
1100        BasicSubscriptionClient::new(client, base_uri)
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107
1108    /// Test uri-and-deps.AC3.1: Subscription URL construction with NSID path.
1109    ///
1110    /// Verifies that the build_subscription_uri() function constructs the correct
1111    /// `/xrpc/{nsid}` path with query parameters properly encoded.
1112    #[test]
1113    fn test_subscription_uri_with_nsid_path() {
1114        let base_uri = Uri::parse("wss://bsky.social/xrpc").unwrap().to_owned();
1115        let nsid = "com.example.subscribe";
1116        let query_params = vec![
1117            ("cursor".to_string(), "abc123".to_string()),
1118            ("filter".to_string(), "like".to_string()),
1119        ];
1120
1121        let uri = build_subscription_uri(&base_uri, nsid, None, &query_params)
1122            .expect("valid base uri and path should produce valid uri");
1123
1124        // Verify the URI contains the correct NSID path
1125        let uri_str = uri.as_str();
1126        assert!(uri_str.contains("/xrpc/com.example.subscribe"));
1127        assert!(uri_str.contains("cursor=abc123"));
1128        assert!(uri_str.contains("filter=like"));
1129        assert!(!uri_str.contains("//xrpc"));
1130    }
1131
1132    /// Test uri-and-deps.AC3.2: Subscription with custom path.
1133    ///
1134    /// Verifies that build_subscription_uri() uses CUSTOM_PATH (e.g., `/subscribe` for Jetstream)
1135    /// instead of the default `/xrpc/{nsid}` path.
1136    #[test]
1137    fn test_subscription_uri_with_custom_path() {
1138        let base_uri = Uri::parse("wss://jetstream.example.com")
1139            .unwrap()
1140            .to_owned();
1141        let custom_path = "/subscribe";
1142
1143        let uri = build_subscription_uri(&base_uri, "com.example.sub", Some(custom_path), &[])
1144            .expect("valid base uri and path should produce valid uri");
1145
1146        // Verify custom path is used instead of /xrpc/{nsid}
1147        let uri_str = uri.as_str();
1148        assert!(uri_str.contains("/subscribe"));
1149        assert!(!uri_str.contains("/xrpc/"));
1150    }
1151
1152    /// Test uri-and-deps.AC3.3: WebSocketClient::connect() accepts Uri<String>.
1153    ///
1154    /// Verifies that the trait signature accepts Uri<String> and that SubscriptionCall
1155    /// correctly passes Uri<String> to the WebSocket client.
1156    #[test]
1157    fn test_subscription_uri_scheme_and_authority() {
1158        let base_uri = Uri::parse("wss://example.com:8080/path")
1159            .unwrap()
1160            .to_owned();
1161        let nsid = "com.example.test";
1162
1163        let uri = build_subscription_uri(&base_uri, nsid, None, &[])
1164            .expect("valid base uri and path should produce valid uri");
1165
1166        // Verify the URI preserves scheme and authority correctly
1167        let uri_str = uri.as_str();
1168        assert!(uri_str.starts_with("wss://example.com:8080"));
1169        assert!(uri_str.contains("/path/xrpc/com.example.test"));
1170    }
1171
1172    /// Test query parameter encoding with multiple parameters.
1173    #[test]
1174    fn test_query_parameters_encoding() {
1175        let base_uri = Uri::parse("wss://example.com").unwrap().to_owned();
1176        let params = vec![
1177            ("cursor".to_string(), "abc123".to_string()),
1178            ("filter".to_string(), "like".to_string()),
1179        ];
1180
1181        let uri = build_subscription_uri(&base_uri, "com.test", None, &params)
1182            .expect("valid base uri and path should produce valid uri");
1183
1184        // Verify query parameters are correctly encoded
1185        let uri_str = uri.as_str();
1186        assert!(uri_str.contains("?"));
1187        assert!(uri_str.contains("cursor=abc123"));
1188        assert!(uri_str.contains("filter=like"));
1189        assert!(uri_str.contains("&"));
1190    }
1191
1192    /// Test URI construction with trailing slash handling.
1193    #[test]
1194    fn test_uri_trailing_slash_handling() {
1195        let base_uri = Uri::parse("wss://example.com/xrpc/").unwrap().to_owned();
1196
1197        let uri = build_subscription_uri(&base_uri, "com.example.test", None, &[])
1198            .expect("valid base uri and path should produce valid uri");
1199
1200        // Verify no double slashes in path
1201        let uri_str = uri.as_str();
1202        assert!(!uri_str.contains("//xrpc"));
1203        assert!(uri_str.contains("/xrpc/com.example.test"));
1204    }
1205
1206    /// Test empty query parameters do not add trailing question mark.
1207    #[test]
1208    fn test_empty_query_parameters() {
1209        let base_uri = Uri::parse("wss://example.com").unwrap().to_owned();
1210
1211        let uri = build_subscription_uri(&base_uri, "com.example.test", None, &[])
1212            .expect("valid base uri and path should produce valid uri");
1213
1214        // Verify no trailing question mark with empty query
1215        let uri_str = uri.as_str();
1216        assert!(!uri_str.contains("?"));
1217        assert!(uri_str.ends_with("com.example.test"));
1218    }
1219}