Skip to main content

iota_sdk_graphql_client/
subscription.rs

1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Live `events` / `transactions` streams backed by GraphQL subscriptions over
5//! a WebSocket (`graphql-transport-ws`).
6//!
7//! Unlike the paginated `events` / `transactions` page methods, these stream
8//! data as it arrives and never terminate on their own. The stream
9//! transparently reconnects on disconnect, resuming from the last item it
10//! delivered via the subscription's `startAfter` cursor.
11
12use std::{future::Future, time::Duration};
13
14use cynic::SubscriptionBuilder;
15use futures::{Stream, StreamExt};
16use iota_types::{SignedTransaction, TransactionDigest};
17use reqwest::Url;
18
19use crate::{
20    GraphQLClient,
21    client::response_to_result,
22    error::{GraphQLError, GraphQLResult, query_error},
23    query_types::{
24        Event, EventSubscriptionPayload, EventsSubscription, EventsSubscriptionArgs,
25        SubscriptionEventFilter, SubscriptionTransactionFilter,
26        TransactionBlockSubscriptionPayload, TransactionsSubscription,
27        TransactionsSubscriptionArgs,
28    },
29};
30
31/// Backoff applied before the first reconnect attempt, doubled on each
32/// consecutive failure up to [`MAX_BACKOFF`] and reset once an item is
33/// received.
34const INITIAL_BACKOFF: Duration = Duration::from_millis(250);
35/// Upper bound on the reconnect backoff.
36const MAX_BACKOFF: Duration = Duration::from_secs(5);
37/// The WebSocket subprotocol subscriptions are served over.
38const WS_PROTOCOL: &str = "graphql-transport-ws";
39
40/// The result of decoding a single subscription payload.
41enum Outcome<T> {
42    /// A delivered item, along with the resume cursor to use should the
43    /// connection drop after this item (`None` leaves the cursor unchanged).
44    Item { value: T, cursor: Option<String> },
45    /// The server dropped `count` payloads before this one.
46    Lagged(i32),
47    /// A payload that carries nothing to yield (unknown union variant or an
48    /// empty response).
49    Skip,
50}
51
52/// Convert a subscription response to a `Result`, surfacing any `errors` as a
53/// query error. The server sends no error extensions on subscriptions, so the
54/// errors carry no `code`.
55fn subscription_response_to_result<T>(response: cynic::GraphQlResponse<T>) -> GraphQLResult<T> {
56    response_to_result(cynic::GraphQlResponse {
57        data: response.data,
58        errors: response
59            .errors
60            .map(|errors| errors.into_iter().map(query_error).collect()),
61    })
62}
63
64/// Subscription for [`GraphQLClient::events_subscription`]. Call
65/// [`subscribe`](Self::subscribe) to open it.
66#[must_use]
67pub struct EventsSubscriptionBuilder {
68    client: GraphQLClient,
69    filter: Option<SubscriptionEventFilter>,
70    start_after: Option<String>,
71}
72
73impl EventsSubscriptionBuilder {
74    /// Only stream the events that match `filter`.
75    pub fn filter(mut self, filter: SubscriptionEventFilter) -> Self {
76        self.filter = Some(filter);
77        self
78    }
79
80    /// Resume from the transaction immediately following the given
81    /// transaction digest; thereafter the stream tracks its own resume point.
82    pub fn start_after(mut self, start_after: TransactionDigest) -> Self {
83        self.start_after = Some(start_after.to_string());
84        self
85    }
86
87    /// Open the subscription. The stream yields events as they arrive and
88    /// reconnects automatically on disconnect.
89    pub fn subscribe(self) -> impl Stream<Item = GraphQLResult<Event>> + Unpin {
90        let Self {
91            client,
92            filter,
93            start_after,
94        } = self;
95        reconnecting_subscription(
96            move |cursor| {
97                let filter = filter.clone().map(Into::into);
98                let client = client.clone();
99                async move {
100                    let operation = EventsSubscription::build(EventsSubscriptionArgs {
101                        start_after: cursor,
102                        filter,
103                    });
104                    let subscription = client.open_subscription(operation).await?;
105
106                    // Events from a single transaction arrive contiguously, so a transaction is
107                    // only fully received once an event from the next one shows up. Advance the
108                    // resume cursor to the previous transaction's digest only when the digest
109                    // changes.
110                    let mut current_tx: Option<String> = None;
111                    let mapped = subscription.map(move |item| -> GraphQLResult<Outcome<Event>> {
112                        let data = subscription_response_to_result(
113                            item.map_err(GraphQLError::subscription)?,
114                        )?;
115                        Ok(match data.events {
116                            EventSubscriptionPayload::Event(event) => {
117                                let digest = event.transaction_digest();
118                                let mut cursor = None;
119                                if let Some(new) = &digest
120                                    && current_tx.as_deref() != Some(new.as_str())
121                                {
122                                    cursor = current_tx.take();
123                                    current_tx = Some(new.clone());
124                                }
125                                Outcome::Item {
126                                    value: Event::from(*event),
127                                    cursor,
128                                }
129                            }
130                            EventSubscriptionPayload::Lagged(lagged) => {
131                                Outcome::Lagged(lagged.count)
132                            }
133                            EventSubscriptionPayload::Unknown => Outcome::Skip,
134                        })
135                    });
136                    Ok(mapped.boxed())
137                }
138            },
139            start_after,
140        )
141    }
142}
143
144/// Subscription for [`GraphQLClient::transactions_subscription`]. Call
145/// [`subscribe`](Self::subscribe) to open it.
146#[must_use]
147pub struct TransactionsSubscriptionBuilder {
148    client: GraphQLClient,
149    filter: Option<SubscriptionTransactionFilter>,
150    start_after: Option<String>,
151}
152
153impl TransactionsSubscriptionBuilder {
154    /// Only stream the transactions that match `filter`.
155    pub fn filter(mut self, filter: SubscriptionTransactionFilter) -> Self {
156        self.filter = Some(filter);
157        self
158    }
159
160    /// Resume from the transaction immediately following the given digest;
161    /// thereafter the stream tracks its own resume point.
162    pub fn start_after(mut self, start_after: TransactionDigest) -> Self {
163        self.start_after = Some(start_after.to_string());
164        self
165    }
166
167    /// Open the subscription. The stream yields transactions as they arrive
168    /// and reconnects automatically on disconnect.
169    pub fn subscribe(self) -> impl Stream<Item = GraphQLResult<SignedTransaction>> + Unpin {
170        let Self {
171            client,
172            filter,
173            start_after,
174        } = self;
175        reconnecting_subscription(
176            move |cursor| {
177                let filter = filter.clone().map(Into::into);
178                let client = client.clone();
179                async move {
180                    let operation = TransactionsSubscription::build(TransactionsSubscriptionArgs {
181                        start_after: cursor,
182                        filter,
183                    });
184                    let subscription = client.open_subscription(operation).await?;
185
186                    let mapped =
187                        subscription.map(|item| -> GraphQLResult<Outcome<SignedTransaction>> {
188                            let data = subscription_response_to_result(
189                                item.map_err(GraphQLError::subscription)?,
190                            )?;
191                            Ok(match data.transactions {
192                                TransactionBlockSubscriptionPayload::TransactionBlock(block) => {
193                                    let cursor = block.digest.clone();
194                                    Outcome::Item {
195                                        value: SignedTransaction::try_from(block)?,
196                                        cursor,
197                                    }
198                                }
199                                TransactionBlockSubscriptionPayload::Lagged(lagged) => {
200                                    Outcome::Lagged(lagged.count)
201                                }
202                                TransactionBlockSubscriptionPayload::Unknown => Outcome::Skip,
203                            })
204                        });
205                    Ok(mapped.boxed())
206                }
207            },
208            start_after,
209        )
210    }
211}
212
213impl GraphQLClient {
214    /// Subscribe to a live stream of events.
215    ///
216    /// Note: subscriptions are served over a WebSocket, which the node has to
217    /// have enabled: `serviceConfig.enabledFeatures` includes `SUBSCRIPTIONS`
218    /// when it is available.
219    pub fn events_subscription(&self) -> EventsSubscriptionBuilder {
220        EventsSubscriptionBuilder {
221            client: self.clone(),
222            filter: None,
223            start_after: None,
224        }
225    }
226
227    /// Subscribe to a live stream of transactions.
228    ///
229    /// Note: subscriptions are served over a WebSocket, which the node has to
230    /// have enabled: `serviceConfig.enabledFeatures` includes `SUBSCRIPTIONS`
231    /// when it is available.
232    pub fn transactions_subscription(&self) -> TransactionsSubscriptionBuilder {
233        TransactionsSubscriptionBuilder {
234            client: self.clone(),
235            filter: None,
236            start_after: None,
237        }
238    }
239
240    /// Derive the WebSocket URL for subscriptions from the configured RPC URL,
241    /// upgrading the scheme (`http` → `ws`, `https` → `wss`).
242    fn ws_url(&self) -> GraphQLResult<Url> {
243        let mut url = self.rpc.clone();
244        match url.scheme() {
245            "https" => url.set_scheme("wss"),
246            "http" => url.set_scheme("ws"),
247            "ws" | "wss" => Ok(()),
248            other => {
249                return Err(GraphQLError::UnsupportedSubscriptionScheme(
250                    other.to_owned(),
251                ));
252            }
253        }
254        .map_err(|_| GraphQLError::subscription("failed to derive the WebSocket URL"))?;
255        url.set_path("/subscriptions");
256        Ok(url)
257    }
258
259    /// Open a WebSocket and start `operation`, returning the response stream.
260    async fn open_subscription<Operation>(
261        &self,
262        operation: Operation,
263    ) -> GraphQLResult<graphql_ws_client::Subscription<Operation>>
264    where
265        Operation: graphql_ws_client::graphql::GraphqlOperation + Unpin + Send + 'static,
266    {
267        let connection = connect(&self.ws_url()?).await?;
268        graphql_ws_client::Client::build(connection)
269            .subscribe(operation)
270            .await
271            .map_err(GraphQLError::subscription)
272    }
273}
274
275/// Open a WebSocket to `url`, negotiating the `graphql-transport-ws`
276/// subprotocol.
277///
278/// The two transports negotiate it differently: tungstenite sets it as a header
279/// on the upgrade request, while a browser `WebSocket` rejects arbitrary
280/// headers and takes the subprotocol as a constructor argument instead.
281#[cfg(not(target_arch = "wasm32"))]
282async fn connect(url: &Url) -> GraphQLResult<impl graphql_ws_client::Connection + Send + 'static> {
283    use tokio_tungstenite::tungstenite::{client::IntoClientRequest, http::HeaderValue};
284
285    let mut request = url
286        .as_str()
287        .into_client_request()
288        .map_err(GraphQLError::subscription)?;
289    request.headers_mut().insert(
290        "Sec-WebSocket-Protocol",
291        HeaderValue::from_static(WS_PROTOCOL),
292    );
293    let (connection, _response) = tokio_tungstenite::connect_async(request)
294        .await
295        .map_err(GraphQLError::subscription)?;
296    Ok(connection)
297}
298
299#[cfg(target_arch = "wasm32")]
300async fn connect(url: &Url) -> GraphQLResult<impl graphql_ws_client::Connection + Send + 'static> {
301    let connection = ws_stream_wasm::WsMeta::connect(url.as_str(), Some(vec![WS_PROTOCOL]))
302        .await
303        .map_err(GraphQLError::subscription)?;
304    Ok(graphql_ws_client::ws_stream_wasm::Connection::new(connection).await)
305}
306
307/// Wrap a connect-and-subscribe closure in an auto-reconnecting stream.
308///
309/// `connect` is called with the current resume cursor on every (re)connection
310/// and must yield a stream of decoded [`Outcome`]s. Connection and transport
311/// errors are surfaced to the consumer and then trigger a backed-off reconnect;
312/// the stream itself never terminates.
313fn reconnecting_subscription<'a, T, C, Fut, S>(
314    connect: C,
315    initial_cursor: Option<String>,
316) -> impl Stream<Item = GraphQLResult<T>> + Unpin + 'a
317where
318    T: 'a,
319    C: Fn(Option<String>) -> Fut + 'a,
320    Fut: Future<Output = GraphQLResult<S>> + 'a,
321    S: Stream<Item = GraphQLResult<Outcome<T>>> + Unpin + 'a,
322{
323    Box::pin(async_stream::stream! {
324        let mut cursor = initial_cursor;
325        let mut backoff = INITIAL_BACKOFF;
326        loop {
327            match connect(cursor.clone()).await {
328                Ok(mut subscription) => {
329                    while let Some(item) = subscription.next().await {
330                        match item {
331                            Ok(Outcome::Item { value, cursor: next }) => {
332                                if next.is_some() {
333                                    cursor = next;
334                                }
335                                backoff = INITIAL_BACKOFF;
336                                yield Ok(value);
337                            }
338                            // A negative count cannot happen; report 0
339                            // rather than its magnitude.
340                            Ok(Outcome::Lagged(count)) => yield Err(GraphQLError::Lagged {
341                                count: u32::try_from(count).unwrap_or(0),
342                            }),
343                            Ok(Outcome::Skip) => {}
344                            Err(error) => {
345                                yield Err(error);
346                                break;
347                            }
348                        }
349                    }
350                }
351                Err(error) => yield Err(error),
352            }
353            crate::wait::sleep(backoff).await;
354            backoff = (backoff * 2).min(MAX_BACKOFF);
355        }
356    })
357}
358
359#[cfg(all(test, not(target_arch = "wasm32")))]
360mod tests {
361    use cynic::{GraphQlError as CynicError, GraphQlErrorPathSegment, GraphQlResponse};
362
363    use super::*;
364
365    #[test]
366    fn errors_surface_as_query_errors_without_a_code() {
367        let response = GraphQlResponse {
368            data: None::<()>,
369            errors: Some(vec![CynicError::new(
370                "boom".to_owned(),
371                None,
372                Some(vec![GraphQlErrorPathSegment::Field("events".to_owned())]),
373                Some(Default::default()),
374            )]),
375        };
376
377        let GraphQLError::Query(errors) = subscription_response_to_result(response).unwrap_err()
378        else {
379            panic!("expected GraphQLError::Query");
380        };
381        assert_eq!(errors.len(), 1);
382        assert_eq!(errors[0].message, "boom");
383        assert_eq!(
384            errors[0].path,
385            Some(vec![GraphQlErrorPathSegment::Field("events".to_owned())])
386        );
387        assert_eq!(errors[0].extensions, None);
388    }
389
390    #[test]
391    fn data_without_errors_is_returned() {
392        let response = GraphQlResponse {
393            data: Some(1),
394            errors: None,
395        };
396        assert_eq!(subscription_response_to_result(response).unwrap(), 1);
397    }
398}