Skip to main content

opencode_codes/
sse.rs

1//! Server-Sent Events stream handling for `GET /event`.
2//!
3//! opencode publishes all server activity — incremental message parts, tool
4//! calls, permission requests, session lifecycle — on a single long-lived SSE
5//! stream at `GET /event`. This module wraps that stream, decoding each `data:`
6//! frame into the generated [`Event`] union and exposing the result as an async
7//! [`Stream`] (and an inherent [`EventStream::next`] for consumers that would
8//! rather not pull in `futures`/`tokio-stream`).
9//!
10//! # Endpoints
11//!
12//! - [`EVENT_PATH`] (`/event`) — events scoped to the server's working
13//!   directory. Accepts optional `directory` and `workspace` query parameters;
14//!   attach them with [`EventStream::from_request`] if you need them.
15//! - [`GLOBAL_EVENT_PATH`] (`/global/event`) — events from *every* directory the
16//!   opencode process manages, each wrapped in a
17//!   [`GlobalEvent`](crate::protocol_generated::types::GlobalEvent) envelope that
18//!   carries `directory`/`workspace` context. That envelope is a different wire
19//!   shape than the bare [`Event`] this stream decodes, so global frames arrive
20//!   as [`StreamEvent::Unknown`] here; decode them yourself with
21//!   `serde_json::from_value::<GlobalEvent>` off the raw [`serde_json::Value`].
22//!
23//! # Reconnection
24//!
25//! The underlying [`reqwest_eventsource::EventSource`] reconnects on its own with
26//! a capped exponential backoff (configurable via [`RetryConfig`]) and replays
27//! the `Last-Event-ID` header so the server can resume. Each successful (re)open
28//! surfaces as [`StreamEvent::Connected`]. Transient failures that the backoff
29//! will retry are swallowed; the stream yields an `Err` only when the retry
30//! budget is exhausted ([`RetryConfig::max_retries`]), after which it ends.
31//!
32//! Because [`EventStream::connect`] performs no network I/O — it only prepares
33//! the request — it returns `Ok` even against an unreachable or permanently-dead
34//! server. The *initial* connection failure (connection refused, DNS error) is
35//! treated as just another transient error: with the default
36//! [`RetryConfig::max_retries`] of `None` it is retried forever and never
37//! surfaces. A naive `while let Some(ev) = stream.next().await` loop against a
38//! server that never comes up therefore blocks indefinitely, yielding neither an
39//! event nor an `Err`. Guard against this by giving [`RetryConfig::max_retries`]
40//! a bound (so the stream ends with an `Err` once the budget is spent) and/or by
41//! keeping the periodic-poll safety net shown below, which stays live regardless
42//! of the stream's connection state.
43//!
44//! # Reconcile, don't trust
45//!
46//! SSE is best-effort: frames can be dropped across a reconnect, and the window
47//! between your prompt landing and your subscription opening is not covered by
48//! the stream. Treat [`GET /session/{sessionID}/message`](crate::http) as the
49//! source of truth and the stream as a low-latency hint. The idiomatic shape is
50//! to `select!` the event stream against a periodic poll, and to force a poll
51//! every time [`StreamEvent::Connected`] arrives (a reconnect means you may have
52//! missed frames):
53//!
54//! ```ignore
55//! use opencode_codes::sse::{EventStream, StreamEvent};
56//!
57//! let mut events = EventStream::connect("http://127.0.0.1:41999")?;
58//! let mut poll = tokio::time::interval(std::time::Duration::from_secs(2));
59//!
60//! loop {
61//!     tokio::select! {
62//!         item = events.next() => match item {
63//!             Some(Ok(StreamEvent::Connected)) => {
64//!                 // (Re)connected — the stream may have gaps; reconcile now.
65//!                 reconcile(&client, &session_id).await?;
66//!             }
67//!             Some(Ok(StreamEvent::Event(ev))) => handle(ev),
68//!             Some(Ok(StreamEvent::Unknown(raw))) => log_unknown(raw),
69//!             Some(Err(e)) => return Err(e),   // retry budget exhausted
70//!             None => break,                   // stream closed
71//!         },
72//!         _ = poll.tick() => {
73//!             // Periodic safety net independent of the stream.
74//!             reconcile(&client, &session_id).await?;
75//!         }
76//!     }
77//! }
78//! ```
79
80use crate::error::{Error, Result};
81use crate::protocol_generated::types::Event;
82use futures_core::Stream;
83use reqwest::{Client, RequestBuilder};
84use reqwest_eventsource::retry::ExponentialBackoff;
85use reqwest_eventsource::{Event as EsEvent, EventSource, ReadyState};
86use std::pin::Pin;
87use std::task::{Context, Poll};
88use std::time::Duration;
89
90/// Path of the directory-scoped event stream (`GET /event`).
91pub const EVENT_PATH: &str = "/event";
92
93/// Path of the cross-directory event stream (`GET /global/event`).
94pub const GLOBAL_EVENT_PATH: &str = "/global/event";
95
96/// Reconnection backoff for the SSE stream.
97///
98/// Maps directly onto [`reqwest_eventsource`]'s exponential backoff retry
99/// policy: the first reconnect waits [`initial_interval`](Self::initial_interval),
100/// each subsequent attempt multiplies by [`factor`](Self::factor), and the delay
101/// is clamped to [`max_interval`](Self::max_interval). The backoff resets after
102/// any successful reopen.
103#[derive(Clone, Copy, Debug, PartialEq)]
104pub struct RetryConfig {
105    /// Delay before the first reconnect attempt.
106    pub initial_interval: Duration,
107    /// Ceiling applied to every reconnect delay.
108    pub max_interval: Duration,
109    /// Multiplier applied to the delay after each failed attempt.
110    pub factor: f64,
111    /// Maximum number of consecutive reconnect attempts before the stream ends.
112    ///
113    /// `None` (the default) retries indefinitely, which is what a long-lived
114    /// subscription usually wants. Note that the *initial* connect counts as a
115    /// reconnect here: with `None`, a server that is unreachable from the start
116    /// is retried forever, so the stream never yields an `Err` and a bare
117    /// `next()` loop hangs. Set a bound if you need an unreachable server to
118    /// surface as a terminal `Err` rather than silence.
119    pub max_retries: Option<usize>,
120}
121
122impl Default for RetryConfig {
123    fn default() -> Self {
124        Self {
125            initial_interval: Duration::from_millis(500),
126            max_interval: Duration::from_secs(30),
127            factor: 2.0,
128            max_retries: None,
129        }
130    }
131}
132
133impl RetryConfig {
134    fn into_policy(self) -> ExponentialBackoff {
135        ExponentialBackoff::new(
136            self.initial_interval,
137            self.factor,
138            Some(self.max_interval),
139            self.max_retries,
140        )
141    }
142}
143
144/// A single item yielded by an [`EventStream`].
145#[derive(Debug, Clone)]
146#[non_exhaustive]
147pub enum StreamEvent {
148    /// The SSE connection opened, or reopened after a reconnect.
149    ///
150    /// Because a reconnect can straddle dropped frames, treat every `Connected`
151    /// as a cue to reconcile against `GET /session/{sessionID}/message`.
152    Connected,
153    /// A frame that decoded cleanly into the generated event union.
154    ///
155    /// Boxed because [`Event`] is a large union; keeping the variant small keeps
156    /// `StreamEvent` (and `Result<StreamEvent>`) cheap to move.
157    Event(Box<Event>),
158    /// A frame whose `type` matched no known [`Event`] variant (or whose payload
159    /// did not fit the expected shape).
160    ///
161    /// The raw JSON is preserved so callers can inspect newer server events
162    /// without this crate erroring out. Only genuinely non-JSON frames surface
163    /// as an `Err` instead.
164    Unknown(serde_json::Value),
165}
166
167impl StreamEvent {
168    /// The decoded [`Event`], if this item is [`StreamEvent::Event`].
169    #[must_use]
170    pub fn as_event(&self) -> Option<&Event> {
171        match self {
172            Self::Event(ev) => Some(ev.as_ref()),
173            _ => None,
174        }
175    }
176
177    /// Whether this item marks a fresh (re)connection.
178    #[must_use]
179    pub fn is_connected(&self) -> bool {
180        matches!(self, Self::Connected)
181    }
182}
183
184/// A decoded, self-reconnecting subscription to an opencode event stream.
185///
186/// Construct one with [`EventStream::connect`] (or [`EventStream::from_request`]
187/// when you need custom headers, query parameters, or authentication) and pull
188/// items with [`EventStream::next`] or via its [`Stream`] implementation.
189pub struct EventStream {
190    inner: EventSource,
191}
192
193impl EventStream {
194    /// Subscribe to `GET {base_url}/event` with the default [`RetryConfig`],
195    /// against a **no-auth** server.
196    ///
197    /// No credentials are attached and the environment is never consulted. For
198    /// an authenticated server, prefer
199    /// [`OpencodeClient::event_stream`](crate::client_async::OpencodeClient::event_stream),
200    /// which reuses the client's configured auth, or build the request yourself
201    /// and use [`EventStream::from_request`].
202    ///
203    /// This prepares the request but performs no network I/O, so it returns `Ok`
204    /// even if the server is down. With the default [`RetryConfig`]
205    /// ([`max_retries`](RetryConfig::max_retries) = `None`) an unreachable server
206    /// is retried forever and never surfaces an `Err`; see the [module
207    /// docs](self#reconnection) for how to avoid a silent hang.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`Error`] if the request cannot be prepared for streaming.
212    pub fn connect(base_url: &str) -> Result<Self> {
213        Self::connect_with(
214            &Client::builder().build()?,
215            base_url,
216            RetryConfig::default(),
217        )
218    }
219
220    /// Subscribe to `GET {base_url}/event` on a caller-supplied [`Client`] with
221    /// an explicit [`RetryConfig`], reusing the client's connection pool.
222    ///
223    /// Like [`EventStream::connect`], this attaches no auth and never reads the
224    /// environment. Consistent with the REST transport, `OPENCODE_SERVER_PASSWORD`
225    /// is only consulted through an explicit opt-in
226    /// ([`BasicAuth::from_env`](crate::http::BasicAuth::from_env) /
227    /// [`OpencodeClientBuilder::auth_from_env`](crate::client_async::OpencodeClientBuilder::auth_from_env)),
228    /// not on this connect path.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`Error`] if the request cannot be prepared for streaming.
233    pub fn connect_with(client: &Client, base_url: &str, retry: RetryConfig) -> Result<Self> {
234        let url = format!("{}{EVENT_PATH}", base_url.trim_end_matches('/'));
235        Self::from_request(client.get(url), retry)
236    }
237
238    /// Wrap a fully-prepared [`RequestBuilder`] (with whatever headers, query
239    /// parameters, or auth you need) as a decoded event stream.
240    ///
241    /// The request should target an SSE endpoint — [`EVENT_PATH`] or
242    /// [`GLOBAL_EVENT_PATH`]. The `Accept: text/event-stream` header is added for
243    /// you.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`Error`] if the request cannot be cloned for reconnects (only
248    /// possible for requests carrying a non-replayable streaming body, which the
249    /// `GET` event endpoints never do).
250    pub fn from_request(builder: RequestBuilder, retry: RetryConfig) -> Result<Self> {
251        let mut source = EventSource::new(builder).map_err(|e| Error::Http {
252            status: 0,
253            body: format!("cannot prepare SSE request: {e}"),
254        })?;
255        source.set_retry_policy(Box::new(retry.into_policy()));
256        Ok(Self { inner: source })
257    }
258
259    /// Await the next decoded item, or `None` once the stream has closed.
260    ///
261    /// This is the [`Stream`] contract as an inherent async method so callers
262    /// need no `StreamExt` import.
263    pub async fn next(&mut self) -> Option<Result<StreamEvent>> {
264        std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await
265    }
266
267    /// The current connection state of the underlying event source.
268    #[must_use]
269    pub fn ready_state(&self) -> ReadyState {
270        self.inner.ready_state()
271    }
272
273    /// Stop the stream and its reconnection attempts. The next poll yields
274    /// `None`. Dropping the [`EventStream`] does the same.
275    pub fn close(&mut self) {
276        self.inner.close();
277    }
278}
279
280fn decode_frame(data: &str) -> Result<StreamEvent> {
281    let value: serde_json::Value = serde_json::from_str(data)?;
282    match serde_json::from_value::<Event>(value.clone()) {
283        Ok(event) => Ok(StreamEvent::Event(Box::new(event))),
284        Err(_) => Ok(StreamEvent::Unknown(value)),
285    }
286}
287
288impl Stream for EventStream {
289    type Item = Result<StreamEvent>;
290
291    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
292        let this = self.get_mut();
293        loop {
294            match Pin::new(&mut this.inner).poll_next(cx) {
295                Poll::Ready(Some(Ok(EsEvent::Open))) => {
296                    return Poll::Ready(Some(Ok(StreamEvent::Connected)));
297                }
298                Poll::Ready(Some(Ok(EsEvent::Message(message)))) => {
299                    return Poll::Ready(Some(decode_frame(&message.data)));
300                }
301                Poll::Ready(Some(Err(e))) => {
302                    if this.inner.ready_state() == ReadyState::Closed {
303                        return Poll::Ready(Some(Err(e.into())));
304                    }
305                    // Transient: the EventSource has scheduled a backoff retry.
306                    // Poll again so its delay registers a waker (yielding
307                    // Pending) instead of surfacing reconnect churn to callers.
308                    continue;
309                }
310                Poll::Ready(None) => return Poll::Ready(None),
311                Poll::Pending => return Poll::Pending,
312            }
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn retry_config_default_is_capped_and_unbounded() {
323        let cfg = RetryConfig::default();
324        assert_eq!(cfg.initial_interval, Duration::from_millis(500));
325        assert_eq!(cfg.max_interval, Duration::from_secs(30));
326        assert_eq!(cfg.max_retries, None);
327    }
328
329    #[test]
330    fn decode_known_event_frame() {
331        let frame = r#"{"type":"session.idle","id":"evt_1","properties":{"sessionID":"ses_123"}}"#;
332        let decoded = decode_frame(frame).expect("valid json");
333        match decoded {
334            StreamEvent::Event(ev) if matches!(*ev, Event::SessionIdle(_)) => {}
335            other => panic!("expected SessionIdle event, got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn decode_unknown_type_becomes_unknown_variant() {
341        let frame = r#"{"type":"totally.new.event.from.the.future","properties":{"x":1}}"#;
342        let decoded = decode_frame(frame).expect("valid json");
343        match decoded {
344            StreamEvent::Unknown(value) => {
345                assert_eq!(value["type"], "totally.new.event.from.the.future");
346            }
347            other => panic!("expected Unknown, got {other:?}"),
348        }
349    }
350
351    #[test]
352    fn decode_known_type_wrong_shape_becomes_unknown_not_error() {
353        // Recognized discriminant, but the payload does not match the variant.
354        let frame = r#"{"type":"session.idle","id":"evt_1","properties":{"sessionID":42}}"#;
355        let decoded = decode_frame(frame).expect("valid json");
356        assert!(matches!(decoded, StreamEvent::Unknown(_)));
357    }
358
359    #[test]
360    fn decode_non_json_frame_is_error() {
361        assert!(decode_frame("not json at all").is_err());
362    }
363
364    #[test]
365    fn stream_event_accessors() {
366        assert!(StreamEvent::Connected.is_connected());
367        assert!(StreamEvent::Connected.as_event().is_none());
368        let ev =
369            decode_frame(r#"{"type":"session.idle","id":"evt_1","properties":{"sessionID":"s"}}"#)
370                .unwrap();
371        assert!(ev.as_event().is_some());
372        assert!(!ev.is_connected());
373    }
374
375    // Live tests: require a running opencode server. Run with:
376    //   cargo test -p opencode-codes --features integration-tests -- --nocapture
377    #[cfg(feature = "integration-tests")]
378    mod live {
379        use super::*;
380
381        const BASE_URL: &str = "http://127.0.0.1:41999";
382
383        async fn next_item(stream: &mut EventStream) -> Option<Result<StreamEvent>> {
384            stream.next().await
385        }
386
387        #[tokio::test]
388        async fn connects_and_receives_real_frames() {
389            let client = Client::new();
390            let mut stream =
391                EventStream::connect_with(&client, BASE_URL, RetryConfig::default()).unwrap();
392
393            // First item off a healthy server is the connection-open marker.
394            let first = tokio::time::timeout(Duration::from_secs(5), next_item(&mut stream))
395                .await
396                .expect("server did not open the SSE stream in time")
397                .expect("stream closed immediately")
398                .expect("connection error");
399            assert!(first.is_connected(), "expected Connected, got {first:?}");
400
401            // Trigger activity so at least one event flows.
402            let created = client
403                .post(format!("{BASE_URL}/session"))
404                .json(&serde_json::json!({}))
405                .send()
406                .await
407                .expect("create session request failed");
408            assert!(
409                created.status().is_success(),
410                "POST /session failed: {}",
411                created.status()
412            );
413
414            let mut saw_event = false;
415            for _ in 0..50 {
416                match tokio::time::timeout(Duration::from_secs(10), next_item(&mut stream)).await {
417                    Ok(Some(Ok(StreamEvent::Event(_) | StreamEvent::Unknown(_)))) => {
418                        saw_event = true;
419                        break;
420                    }
421                    Ok(Some(Ok(StreamEvent::Connected))) => continue,
422                    Ok(Some(Err(e))) => panic!("stream error: {e}"),
423                    Ok(None) => panic!("stream closed unexpectedly"),
424                    Err(_) => panic!("timed out waiting for an event frame"),
425                }
426            }
427            assert!(saw_event, "no event frames observed after POST /session");
428        }
429
430        #[tokio::test]
431        async fn event_stream_is_a_futures_stream() {
432            fn assert_stream<S: Stream>(_: &S) {}
433            let stream = EventStream::connect(BASE_URL).unwrap();
434            assert_stream(&stream);
435        }
436    }
437}