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}