spikard 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +659 -659
- data/ext/spikard_rb/Cargo.toml +17 -17
- data/ext/spikard_rb/extconf.rb +10 -10
- data/ext/spikard_rb/src/lib.rs +6 -6
- data/lib/spikard/app.rb +386 -386
- data/lib/spikard/background.rb +27 -27
- data/lib/spikard/config.rb +396 -396
- data/lib/spikard/converters.rb +13 -13
- data/lib/spikard/handler_wrapper.rb +113 -113
- data/lib/spikard/provide.rb +214 -214
- data/lib/spikard/response.rb +173 -173
- data/lib/spikard/schema.rb +243 -243
- data/lib/spikard/sse.rb +111 -111
- data/lib/spikard/streaming_response.rb +44 -44
- data/lib/spikard/testing.rb +221 -221
- data/lib/spikard/upload_file.rb +131 -131
- data/lib/spikard/version.rb +5 -5
- data/lib/spikard/websocket.rb +59 -59
- data/lib/spikard.rb +43 -43
- data/sig/spikard.rbs +360 -360
- data/vendor/crates/spikard-core/Cargo.toml +40 -40
- data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
- data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
- data/vendor/crates/spikard-core/src/debug.rs +63 -63
- data/vendor/crates/spikard-core/src/di/container.rs +726 -726
- data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
- data/vendor/crates/spikard-core/src/di/error.rs +118 -118
- data/vendor/crates/spikard-core/src/di/factory.rs +538 -538
- data/vendor/crates/spikard-core/src/di/graph.rs +545 -545
- data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
- data/vendor/crates/spikard-core/src/di/resolved.rs +411 -411
- data/vendor/crates/spikard-core/src/di/value.rs +283 -283
- data/vendor/crates/spikard-core/src/errors.rs +39 -39
- data/vendor/crates/spikard-core/src/http.rs +153 -153
- data/vendor/crates/spikard-core/src/lib.rs +29 -29
- data/vendor/crates/spikard-core/src/lifecycle.rs +422 -422
- data/vendor/crates/spikard-core/src/parameters.rs +722 -722
- data/vendor/crates/spikard-core/src/problem.rs +310 -310
- data/vendor/crates/spikard-core/src/request_data.rs +189 -189
- data/vendor/crates/spikard-core/src/router.rs +249 -249
- data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
- data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
- data/vendor/crates/spikard-core/src/validation.rs +699 -699
- data/vendor/crates/spikard-http/Cargo.toml +58 -58
- data/vendor/crates/spikard-http/src/auth.rs +247 -247
- data/vendor/crates/spikard-http/src/background.rs +249 -249
- data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
- data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
- data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
- data/vendor/crates/spikard-http/src/cors.rs +490 -490
- data/vendor/crates/spikard-http/src/debug.rs +63 -63
- data/vendor/crates/spikard-http/src/di_handler.rs +423 -423
- data/vendor/crates/spikard-http/src/handler_response.rs +190 -190
- data/vendor/crates/spikard-http/src/handler_trait.rs +228 -228
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -284
- data/vendor/crates/spikard-http/src/lib.rs +529 -529
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -149
- data/vendor/crates/spikard-http/src/lifecycle.rs +428 -428
- data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -285
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +86 -86
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +147 -147
- data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -287
- data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +190 -190
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +308 -308
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +195 -195
- data/vendor/crates/spikard-http/src/parameters.rs +1 -1
- data/vendor/crates/spikard-http/src/problem.rs +1 -1
- data/vendor/crates/spikard-http/src/query_parser.rs +369 -369
- data/vendor/crates/spikard-http/src/response.rs +399 -399
- data/vendor/crates/spikard-http/src/router.rs +1 -1
- data/vendor/crates/spikard-http/src/schema_registry.rs +1 -1
- data/vendor/crates/spikard-http/src/server/handler.rs +87 -87
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -98
- data/vendor/crates/spikard-http/src/server/mod.rs +805 -805
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +119 -119
- data/vendor/crates/spikard-http/src/sse.rs +447 -447
- data/vendor/crates/spikard-http/src/testing/form.rs +14 -14
- data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -60
- data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -285
- data/vendor/crates/spikard-http/src/testing.rs +377 -377
- data/vendor/crates/spikard-http/src/type_hints.rs +1 -1
- data/vendor/crates/spikard-http/src/validation.rs +1 -1
- data/vendor/crates/spikard-http/src/websocket.rs +324 -324
- data/vendor/crates/spikard-rb/Cargo.toml +42 -42
- data/vendor/crates/spikard-rb/build.rs +8 -8
- data/vendor/crates/spikard-rb/src/background.rs +63 -63
- data/vendor/crates/spikard-rb/src/config.rs +294 -294
- data/vendor/crates/spikard-rb/src/conversion.rs +453 -453
- data/vendor/crates/spikard-rb/src/di.rs +409 -409
- data/vendor/crates/spikard-rb/src/handler.rs +625 -625
- data/vendor/crates/spikard-rb/src/lib.rs +2771 -2771
- data/vendor/crates/spikard-rb/src/lifecycle.rs +274 -274
- data/vendor/crates/spikard-rb/src/server.rs +283 -283
- data/vendor/crates/spikard-rb/src/sse.rs +231 -231
- data/vendor/crates/spikard-rb/src/test_client.rs +404 -404
- data/vendor/crates/spikard-rb/src/test_sse.rs +143 -143
- data/vendor/crates/spikard-rb/src/test_websocket.rs +221 -221
- data/vendor/crates/spikard-rb/src/websocket.rs +233 -233
- data/vendor/spikard-core/Cargo.toml +40 -40
- data/vendor/spikard-core/src/bindings/mod.rs +3 -3
- data/vendor/spikard-core/src/bindings/response.rs +133 -133
- data/vendor/spikard-core/src/debug.rs +63 -63
- data/vendor/spikard-core/src/di/container.rs +726 -726
- data/vendor/spikard-core/src/di/dependency.rs +273 -273
- data/vendor/spikard-core/src/di/error.rs +118 -118
- data/vendor/spikard-core/src/di/factory.rs +538 -538
- data/vendor/spikard-core/src/di/graph.rs +545 -545
- data/vendor/spikard-core/src/di/mod.rs +192 -192
- data/vendor/spikard-core/src/di/resolved.rs +411 -411
- data/vendor/spikard-core/src/di/value.rs +283 -283
- data/vendor/spikard-core/src/http.rs +153 -153
- data/vendor/spikard-core/src/lib.rs +28 -28
- data/vendor/spikard-core/src/lifecycle.rs +422 -422
- data/vendor/spikard-core/src/parameters.rs +719 -719
- data/vendor/spikard-core/src/problem.rs +310 -310
- data/vendor/spikard-core/src/request_data.rs +189 -189
- data/vendor/spikard-core/src/router.rs +249 -249
- data/vendor/spikard-core/src/schema_registry.rs +183 -183
- data/vendor/spikard-core/src/type_hints.rs +304 -304
- data/vendor/spikard-core/src/validation.rs +699 -699
- data/vendor/spikard-http/Cargo.toml +58 -58
- data/vendor/spikard-http/src/auth.rs +247 -247
- data/vendor/spikard-http/src/background.rs +249 -249
- data/vendor/spikard-http/src/bindings/mod.rs +3 -3
- data/vendor/spikard-http/src/bindings/response.rs +1 -1
- data/vendor/spikard-http/src/body_metadata.rs +8 -8
- data/vendor/spikard-http/src/cors.rs +490 -490
- data/vendor/spikard-http/src/debug.rs +63 -63
- data/vendor/spikard-http/src/di_handler.rs +423 -423
- data/vendor/spikard-http/src/handler_response.rs +190 -190
- data/vendor/spikard-http/src/handler_trait.rs +228 -228
- data/vendor/spikard-http/src/handler_trait_tests.rs +284 -284
- data/vendor/spikard-http/src/lib.rs +529 -529
- data/vendor/spikard-http/src/lifecycle/adapter.rs +149 -149
- data/vendor/spikard-http/src/lifecycle.rs +428 -428
- data/vendor/spikard-http/src/middleware/mod.rs +285 -285
- data/vendor/spikard-http/src/middleware/multipart.rs +86 -86
- data/vendor/spikard-http/src/middleware/urlencoded.rs +147 -147
- data/vendor/spikard-http/src/middleware/validation.rs +287 -287
- data/vendor/spikard-http/src/openapi/mod.rs +309 -309
- data/vendor/spikard-http/src/openapi/parameter_extraction.rs +190 -190
- data/vendor/spikard-http/src/openapi/schema_conversion.rs +308 -308
- data/vendor/spikard-http/src/openapi/spec_generation.rs +195 -195
- data/vendor/spikard-http/src/parameters.rs +1 -1
- data/vendor/spikard-http/src/problem.rs +1 -1
- data/vendor/spikard-http/src/query_parser.rs +369 -369
- data/vendor/spikard-http/src/response.rs +399 -399
- data/vendor/spikard-http/src/router.rs +1 -1
- data/vendor/spikard-http/src/schema_registry.rs +1 -1
- data/vendor/spikard-http/src/server/handler.rs +80 -80
- data/vendor/spikard-http/src/server/lifecycle_execution.rs +98 -98
- data/vendor/spikard-http/src/server/mod.rs +805 -805
- data/vendor/spikard-http/src/server/request_extraction.rs +119 -119
- data/vendor/spikard-http/src/sse.rs +447 -447
- data/vendor/spikard-http/src/testing/form.rs +14 -14
- data/vendor/spikard-http/src/testing/multipart.rs +60 -60
- data/vendor/spikard-http/src/testing/test_client.rs +285 -285
- data/vendor/spikard-http/src/testing.rs +377 -377
- data/vendor/spikard-http/src/type_hints.rs +1 -1
- data/vendor/spikard-http/src/validation.rs +1 -1
- data/vendor/spikard-http/src/websocket.rs +324 -324
- data/vendor/spikard-rb/Cargo.toml +42 -42
- data/vendor/spikard-rb/build.rs +8 -8
- data/vendor/spikard-rb/src/background.rs +63 -63
- data/vendor/spikard-rb/src/config.rs +294 -294
- data/vendor/spikard-rb/src/conversion.rs +392 -392
- data/vendor/spikard-rb/src/di.rs +409 -409
- data/vendor/spikard-rb/src/handler.rs +534 -534
- data/vendor/spikard-rb/src/lib.rs +2020 -2020
- data/vendor/spikard-rb/src/lifecycle.rs +267 -267
- data/vendor/spikard-rb/src/server.rs +283 -283
- data/vendor/spikard-rb/src/sse.rs +231 -231
- data/vendor/spikard-rb/src/test_client.rs +404 -404
- data/vendor/spikard-rb/src/test_sse.rs +143 -143
- data/vendor/spikard-rb/src/test_websocket.rs +221 -221
- data/vendor/spikard-rb/src/websocket.rs +233 -233
- metadata +1 -1
|
@@ -1,447 +1,447 @@
|
|
|
1
|
-
//! Server-Sent Events (SSE) support for Spikard
|
|
2
|
-
//!
|
|
3
|
-
//! Provides SSE streaming with event generation and lifecycle management.
|
|
4
|
-
|
|
5
|
-
use axum::{
|
|
6
|
-
extract::State,
|
|
7
|
-
response::{
|
|
8
|
-
IntoResponse,
|
|
9
|
-
sse::{Event, KeepAlive, Sse},
|
|
10
|
-
},
|
|
11
|
-
};
|
|
12
|
-
use futures_util::stream;
|
|
13
|
-
use serde_json::Value;
|
|
14
|
-
use std::{convert::Infallible, sync::Arc, time::Duration};
|
|
15
|
-
use tracing::{debug, error, info};
|
|
16
|
-
|
|
17
|
-
/// SSE event producer trait
|
|
18
|
-
///
|
|
19
|
-
/// Implement this trait to create custom Server-Sent Event (SSE) producers for your application.
|
|
20
|
-
/// The producer generates events that are streamed to connected clients.
|
|
21
|
-
///
|
|
22
|
-
/// # Understanding SSE
|
|
23
|
-
///
|
|
24
|
-
/// Server-Sent Events (SSE) provide one-way communication from server to client over HTTP.
|
|
25
|
-
/// Unlike WebSocket, SSE uses standard HTTP and automatically handles reconnection.
|
|
26
|
-
/// Use SSE when you need to push data to clients without bidirectional communication.
|
|
27
|
-
///
|
|
28
|
-
/// # Implementing the Trait
|
|
29
|
-
///
|
|
30
|
-
/// You must implement the `next_event` method to generate events. The `on_connect` and
|
|
31
|
-
/// `on_disconnect` methods are optional lifecycle hooks.
|
|
32
|
-
///
|
|
33
|
-
/// # Example
|
|
34
|
-
///
|
|
35
|
-
/// ```ignore
|
|
36
|
-
/// use spikard_http::sse::{SseEventProducer, SseEvent};
|
|
37
|
-
/// use serde_json::json;
|
|
38
|
-
/// use std::time::Duration;
|
|
39
|
-
/// use tokio::time::sleep;
|
|
40
|
-
///
|
|
41
|
-
/// struct CounterProducer {
|
|
42
|
-
/// limit: usize,
|
|
43
|
-
/// }
|
|
44
|
-
///
|
|
45
|
-
/// #[async_trait]
|
|
46
|
-
/// impl SseEventProducer for CounterProducer {
|
|
47
|
-
/// async fn next_event(&self) -> Option<SseEvent> {
|
|
48
|
-
/// static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
49
|
-
///
|
|
50
|
-
/// let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
51
|
-
/// if count < self.limit {
|
|
52
|
-
/// Some(SseEvent::new(json!({"count": count})))
|
|
53
|
-
/// } else {
|
|
54
|
-
/// None
|
|
55
|
-
/// }
|
|
56
|
-
/// }
|
|
57
|
-
///
|
|
58
|
-
/// async fn on_connect(&self) {
|
|
59
|
-
/// println!("Client connected");
|
|
60
|
-
/// }
|
|
61
|
-
///
|
|
62
|
-
/// async fn on_disconnect(&self) {
|
|
63
|
-
/// println!("Client disconnected");
|
|
64
|
-
/// }
|
|
65
|
-
/// }
|
|
66
|
-
/// ```
|
|
67
|
-
pub trait SseEventProducer: Send + Sync {
|
|
68
|
-
/// Generate the next event
|
|
69
|
-
///
|
|
70
|
-
/// Called repeatedly to produce the event stream. Should return `Some(event)` when
|
|
71
|
-
/// an event is ready to send, or `None` when the stream should end.
|
|
72
|
-
///
|
|
73
|
-
/// # Returns
|
|
74
|
-
/// * `Some(event)` - Event to send to the client
|
|
75
|
-
/// * `None` - Stream complete, connection will close
|
|
76
|
-
fn next_event(&self) -> impl std::future::Future<Output = Option<SseEvent>> + Send;
|
|
77
|
-
|
|
78
|
-
/// Called when a client connects to the SSE endpoint
|
|
79
|
-
///
|
|
80
|
-
/// Optional lifecycle hook invoked when a new SSE connection is established.
|
|
81
|
-
/// Default implementation does nothing.
|
|
82
|
-
fn on_connect(&self) -> impl std::future::Future<Output = ()> + Send {
|
|
83
|
-
async {}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/// Called when a client disconnects from the SSE endpoint
|
|
87
|
-
///
|
|
88
|
-
/// Optional lifecycle hook invoked when an SSE connection is closed (either by the
|
|
89
|
-
/// client or the stream ending). Default implementation does nothing.
|
|
90
|
-
fn on_disconnect(&self) -> impl std::future::Future<Output = ()> + Send {
|
|
91
|
-
async {}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/// An individual SSE event
|
|
96
|
-
///
|
|
97
|
-
/// Represents a single Server-Sent Event to be sent to a connected client.
|
|
98
|
-
/// Events can have an optional type, ID, and retry timeout for advanced scenarios.
|
|
99
|
-
///
|
|
100
|
-
/// # Fields
|
|
101
|
-
///
|
|
102
|
-
/// * `event_type` - Optional event type string (used for client-side event filtering)
|
|
103
|
-
/// * `data` - JSON data payload to send to the client
|
|
104
|
-
/// * `id` - Optional event ID (clients can use this to resume after disconnect)
|
|
105
|
-
/// * `retry` - Optional retry timeout in milliseconds (tells client when to reconnect)
|
|
106
|
-
///
|
|
107
|
-
/// # SSE Format
|
|
108
|
-
///
|
|
109
|
-
/// Events are serialized to the following text format:
|
|
110
|
-
/// ```text
|
|
111
|
-
/// event: event_type
|
|
112
|
-
/// data: {"json":"value"}
|
|
113
|
-
/// id: event-123
|
|
114
|
-
/// retry: 3000
|
|
115
|
-
/// ```
|
|
116
|
-
#[derive(Debug, Clone)]
|
|
117
|
-
pub struct SseEvent {
|
|
118
|
-
/// Event type (optional)
|
|
119
|
-
pub event_type: Option<String>,
|
|
120
|
-
/// Event data (JSON value)
|
|
121
|
-
pub data: Value,
|
|
122
|
-
/// Event ID (optional, for client-side reconnection)
|
|
123
|
-
pub id: Option<String>,
|
|
124
|
-
/// Retry timeout in milliseconds (optional)
|
|
125
|
-
pub retry: Option<u64>,
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
impl SseEvent {
|
|
129
|
-
/// Create a new SSE event with data only
|
|
130
|
-
///
|
|
131
|
-
/// Creates a minimal event with just the data payload. Use builder methods
|
|
132
|
-
/// to add optional fields.
|
|
133
|
-
///
|
|
134
|
-
/// # Arguments
|
|
135
|
-
/// * `data` - JSON value to send to the client
|
|
136
|
-
///
|
|
137
|
-
/// # Example
|
|
138
|
-
///
|
|
139
|
-
/// ```ignore
|
|
140
|
-
/// use serde_json::json;
|
|
141
|
-
/// use spikard_http::sse::SseEvent;
|
|
142
|
-
///
|
|
143
|
-
/// let event = SseEvent::new(json!({"status": "connected"}));
|
|
144
|
-
/// ```
|
|
145
|
-
pub fn new(data: Value) -> Self {
|
|
146
|
-
Self {
|
|
147
|
-
event_type: None,
|
|
148
|
-
data,
|
|
149
|
-
id: None,
|
|
150
|
-
retry: None,
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/// Create a new SSE event with an event type and data
|
|
155
|
-
///
|
|
156
|
-
/// Creates an event with a type field. Clients can filter events by type
|
|
157
|
-
/// in their event listener.
|
|
158
|
-
///
|
|
159
|
-
/// # Arguments
|
|
160
|
-
/// * `event_type` - String identifying the event type (e.g., "update", "error")
|
|
161
|
-
/// * `data` - JSON value to send to the client
|
|
162
|
-
///
|
|
163
|
-
/// # Example
|
|
164
|
-
///
|
|
165
|
-
/// ```ignore
|
|
166
|
-
/// use serde_json::json;
|
|
167
|
-
/// use spikard_http::sse::SseEvent;
|
|
168
|
-
///
|
|
169
|
-
/// let event = SseEvent::with_type("update", json!({"count": 42}));
|
|
170
|
-
/// // Client can listen with: eventSource.addEventListener("update", ...)
|
|
171
|
-
/// ```
|
|
172
|
-
pub fn with_type(event_type: impl Into<String>, data: Value) -> Self {
|
|
173
|
-
Self {
|
|
174
|
-
event_type: Some(event_type.into()),
|
|
175
|
-
data,
|
|
176
|
-
id: None,
|
|
177
|
-
retry: None,
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/// Set the event ID for client-side reconnection support
|
|
182
|
-
///
|
|
183
|
-
/// Sets an ID that clients can use to resume from this point if they disconnect.
|
|
184
|
-
/// The client sends this ID back in the `Last-Event-ID` header when reconnecting.
|
|
185
|
-
///
|
|
186
|
-
/// # Arguments
|
|
187
|
-
/// * `id` - Unique identifier for this event
|
|
188
|
-
///
|
|
189
|
-
/// # Example
|
|
190
|
-
///
|
|
191
|
-
/// ```ignore
|
|
192
|
-
/// use serde_json::json;
|
|
193
|
-
/// use spikard_http::sse::SseEvent;
|
|
194
|
-
///
|
|
195
|
-
/// let event = SseEvent::new(json!({"count": 1}))
|
|
196
|
-
/// .with_id("event-1");
|
|
197
|
-
/// ```
|
|
198
|
-
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
|
199
|
-
self.id = Some(id.into());
|
|
200
|
-
self
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/// Set the retry timeout for client reconnection
|
|
204
|
-
///
|
|
205
|
-
/// Sets the time in milliseconds clients should wait before attempting to reconnect
|
|
206
|
-
/// if the connection is lost. The client browser will automatically handle reconnection.
|
|
207
|
-
///
|
|
208
|
-
/// # Arguments
|
|
209
|
-
/// * `retry_ms` - Retry timeout in milliseconds
|
|
210
|
-
///
|
|
211
|
-
/// # Example
|
|
212
|
-
///
|
|
213
|
-
/// ```ignore
|
|
214
|
-
/// use serde_json::json;
|
|
215
|
-
/// use spikard_http::sse::SseEvent;
|
|
216
|
-
///
|
|
217
|
-
/// let event = SseEvent::new(json!({"data": "value"}))
|
|
218
|
-
/// .with_retry(5000); // Reconnect after 5 seconds
|
|
219
|
-
/// ```
|
|
220
|
-
pub fn with_retry(mut self, retry_ms: u64) -> Self {
|
|
221
|
-
self.retry = Some(retry_ms);
|
|
222
|
-
self
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/// Convert to Axum's SSE Event
|
|
226
|
-
fn into_axum_event(self) -> Result<Event, serde_json::Error> {
|
|
227
|
-
let json_data = serde_json::to_string(&self.data)?;
|
|
228
|
-
|
|
229
|
-
let mut event = Event::default().data(json_data);
|
|
230
|
-
|
|
231
|
-
if let Some(event_type) = self.event_type {
|
|
232
|
-
event = event.event(event_type);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if let Some(id) = self.id {
|
|
236
|
-
event = event.id(id);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if let Some(retry) = self.retry {
|
|
240
|
-
event = event.retry(Duration::from_millis(retry));
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
Ok(event)
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/// SSE state shared across connections
|
|
248
|
-
///
|
|
249
|
-
/// Contains the event producer and optional JSON schema for validating
|
|
250
|
-
/// events. This state is shared among all connections to the same SSE endpoint.
|
|
251
|
-
pub struct SseState<P: SseEventProducer> {
|
|
252
|
-
/// The event producer implementation
|
|
253
|
-
producer: Arc<P>,
|
|
254
|
-
/// Optional JSON Schema for validating outgoing events
|
|
255
|
-
event_schema: Option<Arc<jsonschema::Validator>>,
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
impl<P: SseEventProducer> Clone for SseState<P> {
|
|
259
|
-
fn clone(&self) -> Self {
|
|
260
|
-
Self {
|
|
261
|
-
producer: Arc::clone(&self.producer),
|
|
262
|
-
event_schema: self.event_schema.clone(),
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
impl<P: SseEventProducer + 'static> SseState<P> {
|
|
268
|
-
/// Create new SSE state with an event producer
|
|
269
|
-
///
|
|
270
|
-
/// Creates a new state without event validation schema.
|
|
271
|
-
/// Events are not validated.
|
|
272
|
-
///
|
|
273
|
-
/// # Arguments
|
|
274
|
-
/// * `producer` - The event producer implementation
|
|
275
|
-
///
|
|
276
|
-
/// # Example
|
|
277
|
-
///
|
|
278
|
-
/// ```ignore
|
|
279
|
-
/// let state = SseState::new(MyProducer);
|
|
280
|
-
/// ```
|
|
281
|
-
pub fn new(producer: P) -> Self {
|
|
282
|
-
Self {
|
|
283
|
-
producer: Arc::new(producer),
|
|
284
|
-
event_schema: None,
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/// Create new SSE state with an event producer and optional event schema
|
|
289
|
-
///
|
|
290
|
-
/// Creates a new state with optional JSON schema for validating outgoing events.
|
|
291
|
-
/// If a schema is provided and an event fails validation, it is silently dropped.
|
|
292
|
-
///
|
|
293
|
-
/// # Arguments
|
|
294
|
-
/// * `producer` - The event producer implementation
|
|
295
|
-
/// * `event_schema` - Optional JSON schema for validating events
|
|
296
|
-
///
|
|
297
|
-
/// # Returns
|
|
298
|
-
/// * `Ok(state)` - Successfully created state
|
|
299
|
-
/// * `Err(msg)` - Invalid schema provided
|
|
300
|
-
///
|
|
301
|
-
/// # Example
|
|
302
|
-
///
|
|
303
|
-
/// ```ignore
|
|
304
|
-
/// use serde_json::json;
|
|
305
|
-
///
|
|
306
|
-
/// let event_schema = json!({
|
|
307
|
-
/// "type": "object",
|
|
308
|
-
/// "properties": {
|
|
309
|
-
/// "count": {"type": "integer"}
|
|
310
|
-
/// }
|
|
311
|
-
/// });
|
|
312
|
-
///
|
|
313
|
-
/// let state = SseState::with_schema(MyProducer, Some(event_schema))?;
|
|
314
|
-
/// ```
|
|
315
|
-
pub fn with_schema(producer: P, event_schema: Option<serde_json::Value>) -> Result<Self, String> {
|
|
316
|
-
let event_validator = if let Some(schema) = event_schema {
|
|
317
|
-
Some(Arc::new(
|
|
318
|
-
jsonschema::validator_for(&schema).map_err(|e| format!("Invalid event schema: {}", e))?,
|
|
319
|
-
))
|
|
320
|
-
} else {
|
|
321
|
-
None
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
Ok(Self {
|
|
325
|
-
producer: Arc::new(producer),
|
|
326
|
-
event_schema: event_validator,
|
|
327
|
-
})
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/// SSE endpoint handler
|
|
332
|
-
///
|
|
333
|
-
/// This is the main entry point for SSE connections. Use this as an Axum route
|
|
334
|
-
/// handler by passing it to an Axum router's `.route()` method with `get()`.
|
|
335
|
-
///
|
|
336
|
-
/// The handler establishes a connection and streams events from the producer to
|
|
337
|
-
/// the client using the Server-Sent Events protocol (text/event-stream).
|
|
338
|
-
///
|
|
339
|
-
/// # Arguments
|
|
340
|
-
/// * `State(state)` - Application state containing the event producer and optional schema
|
|
341
|
-
///
|
|
342
|
-
/// # Returns
|
|
343
|
-
/// A streaming response with the `text/event-stream` content type
|
|
344
|
-
///
|
|
345
|
-
/// # Example
|
|
346
|
-
///
|
|
347
|
-
/// ```ignore
|
|
348
|
-
/// use axum::{Router, routing::get, extract::State};
|
|
349
|
-
///
|
|
350
|
-
/// let state = SseState::new(MyProducer);
|
|
351
|
-
/// let router = Router::new()
|
|
352
|
-
/// .route("/events", get(sse_handler::<MyProducer>))
|
|
353
|
-
/// .with_state(state);
|
|
354
|
-
///
|
|
355
|
-
/// // Client usage:
|
|
356
|
-
/// // const eventSource = new EventSource('/events');
|
|
357
|
-
/// // eventSource.onmessage = (e) => console.log(e.data);
|
|
358
|
-
/// ```
|
|
359
|
-
pub async fn sse_handler<P: SseEventProducer + 'static>(State(state): State<SseState<P>>) -> impl IntoResponse {
|
|
360
|
-
info!("SSE client connected");
|
|
361
|
-
|
|
362
|
-
state.producer.on_connect().await;
|
|
363
|
-
|
|
364
|
-
let producer = Arc::clone(&state.producer);
|
|
365
|
-
let event_schema = state.event_schema.clone();
|
|
366
|
-
let stream = stream::unfold((producer, event_schema), |(producer, event_schema)| async move {
|
|
367
|
-
match producer.next_event().await {
|
|
368
|
-
Some(sse_event) => {
|
|
369
|
-
debug!("Sending SSE event: {:?}", sse_event.event_type);
|
|
370
|
-
|
|
371
|
-
if let Some(validator) = &event_schema
|
|
372
|
-
&& !validator.is_valid(&sse_event.data)
|
|
373
|
-
{
|
|
374
|
-
error!("SSE event validation failed");
|
|
375
|
-
return Some((
|
|
376
|
-
Ok::<_, Infallible>(Event::default().data("validation_error")),
|
|
377
|
-
(producer, event_schema),
|
|
378
|
-
));
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
match sse_event.into_axum_event() {
|
|
382
|
-
Ok(event) => Some((Ok::<_, Infallible>(event), (producer, event_schema))),
|
|
383
|
-
Err(e) => {
|
|
384
|
-
error!("Failed to serialize SSE event: {}", e);
|
|
385
|
-
None
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
None => {
|
|
390
|
-
info!("SSE stream ended");
|
|
391
|
-
None
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
let sse_response =
|
|
397
|
-
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)).text("keep-alive"));
|
|
398
|
-
|
|
399
|
-
sse_response.into_response()
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
#[cfg(test)]
|
|
403
|
-
mod tests {
|
|
404
|
-
use super::*;
|
|
405
|
-
|
|
406
|
-
struct TestProducer {
|
|
407
|
-
count: std::sync::atomic::AtomicUsize,
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
impl SseEventProducer for TestProducer {
|
|
411
|
-
async fn next_event(&self) -> Option<SseEvent> {
|
|
412
|
-
let count = self.count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
413
|
-
if count < 3 {
|
|
414
|
-
Some(SseEvent::new(serde_json::json!({
|
|
415
|
-
"message": format!("Event {}", count)
|
|
416
|
-
})))
|
|
417
|
-
} else {
|
|
418
|
-
None
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
#[test]
|
|
424
|
-
fn test_sse_event_creation() {
|
|
425
|
-
let event = SseEvent::new(serde_json::json!({"test": "data"}));
|
|
426
|
-
assert!(event.event_type.is_none());
|
|
427
|
-
assert!(event.id.is_none());
|
|
428
|
-
assert!(event.retry.is_none());
|
|
429
|
-
|
|
430
|
-
let event = SseEvent::with_type("notification", serde_json::json!({"test": "data"}))
|
|
431
|
-
.with_id("123")
|
|
432
|
-
.with_retry(5000);
|
|
433
|
-
assert_eq!(event.event_type, Some("notification".to_string()));
|
|
434
|
-
assert_eq!(event.id, Some("123".to_string()));
|
|
435
|
-
assert_eq!(event.retry, Some(5000));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
#[test]
|
|
439
|
-
fn test_sse_state_creation() {
|
|
440
|
-
let producer = TestProducer {
|
|
441
|
-
count: std::sync::atomic::AtomicUsize::new(0),
|
|
442
|
-
};
|
|
443
|
-
let state = SseState::new(producer);
|
|
444
|
-
let cloned = state.clone();
|
|
445
|
-
assert!(Arc::ptr_eq(&state.producer, &cloned.producer));
|
|
446
|
-
}
|
|
447
|
-
}
|
|
1
|
+
//! Server-Sent Events (SSE) support for Spikard
|
|
2
|
+
//!
|
|
3
|
+
//! Provides SSE streaming with event generation and lifecycle management.
|
|
4
|
+
|
|
5
|
+
use axum::{
|
|
6
|
+
extract::State,
|
|
7
|
+
response::{
|
|
8
|
+
IntoResponse,
|
|
9
|
+
sse::{Event, KeepAlive, Sse},
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
use futures_util::stream;
|
|
13
|
+
use serde_json::Value;
|
|
14
|
+
use std::{convert::Infallible, sync::Arc, time::Duration};
|
|
15
|
+
use tracing::{debug, error, info};
|
|
16
|
+
|
|
17
|
+
/// SSE event producer trait
|
|
18
|
+
///
|
|
19
|
+
/// Implement this trait to create custom Server-Sent Event (SSE) producers for your application.
|
|
20
|
+
/// The producer generates events that are streamed to connected clients.
|
|
21
|
+
///
|
|
22
|
+
/// # Understanding SSE
|
|
23
|
+
///
|
|
24
|
+
/// Server-Sent Events (SSE) provide one-way communication from server to client over HTTP.
|
|
25
|
+
/// Unlike WebSocket, SSE uses standard HTTP and automatically handles reconnection.
|
|
26
|
+
/// Use SSE when you need to push data to clients without bidirectional communication.
|
|
27
|
+
///
|
|
28
|
+
/// # Implementing the Trait
|
|
29
|
+
///
|
|
30
|
+
/// You must implement the `next_event` method to generate events. The `on_connect` and
|
|
31
|
+
/// `on_disconnect` methods are optional lifecycle hooks.
|
|
32
|
+
///
|
|
33
|
+
/// # Example
|
|
34
|
+
///
|
|
35
|
+
/// ```ignore
|
|
36
|
+
/// use spikard_http::sse::{SseEventProducer, SseEvent};
|
|
37
|
+
/// use serde_json::json;
|
|
38
|
+
/// use std::time::Duration;
|
|
39
|
+
/// use tokio::time::sleep;
|
|
40
|
+
///
|
|
41
|
+
/// struct CounterProducer {
|
|
42
|
+
/// limit: usize,
|
|
43
|
+
/// }
|
|
44
|
+
///
|
|
45
|
+
/// #[async_trait]
|
|
46
|
+
/// impl SseEventProducer for CounterProducer {
|
|
47
|
+
/// async fn next_event(&self) -> Option<SseEvent> {
|
|
48
|
+
/// static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
49
|
+
///
|
|
50
|
+
/// let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
51
|
+
/// if count < self.limit {
|
|
52
|
+
/// Some(SseEvent::new(json!({"count": count})))
|
|
53
|
+
/// } else {
|
|
54
|
+
/// None
|
|
55
|
+
/// }
|
|
56
|
+
/// }
|
|
57
|
+
///
|
|
58
|
+
/// async fn on_connect(&self) {
|
|
59
|
+
/// println!("Client connected");
|
|
60
|
+
/// }
|
|
61
|
+
///
|
|
62
|
+
/// async fn on_disconnect(&self) {
|
|
63
|
+
/// println!("Client disconnected");
|
|
64
|
+
/// }
|
|
65
|
+
/// }
|
|
66
|
+
/// ```
|
|
67
|
+
pub trait SseEventProducer: Send + Sync {
|
|
68
|
+
/// Generate the next event
|
|
69
|
+
///
|
|
70
|
+
/// Called repeatedly to produce the event stream. Should return `Some(event)` when
|
|
71
|
+
/// an event is ready to send, or `None` when the stream should end.
|
|
72
|
+
///
|
|
73
|
+
/// # Returns
|
|
74
|
+
/// * `Some(event)` - Event to send to the client
|
|
75
|
+
/// * `None` - Stream complete, connection will close
|
|
76
|
+
fn next_event(&self) -> impl std::future::Future<Output = Option<SseEvent>> + Send;
|
|
77
|
+
|
|
78
|
+
/// Called when a client connects to the SSE endpoint
|
|
79
|
+
///
|
|
80
|
+
/// Optional lifecycle hook invoked when a new SSE connection is established.
|
|
81
|
+
/// Default implementation does nothing.
|
|
82
|
+
fn on_connect(&self) -> impl std::future::Future<Output = ()> + Send {
|
|
83
|
+
async {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Called when a client disconnects from the SSE endpoint
|
|
87
|
+
///
|
|
88
|
+
/// Optional lifecycle hook invoked when an SSE connection is closed (either by the
|
|
89
|
+
/// client or the stream ending). Default implementation does nothing.
|
|
90
|
+
fn on_disconnect(&self) -> impl std::future::Future<Output = ()> + Send {
|
|
91
|
+
async {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// An individual SSE event
|
|
96
|
+
///
|
|
97
|
+
/// Represents a single Server-Sent Event to be sent to a connected client.
|
|
98
|
+
/// Events can have an optional type, ID, and retry timeout for advanced scenarios.
|
|
99
|
+
///
|
|
100
|
+
/// # Fields
|
|
101
|
+
///
|
|
102
|
+
/// * `event_type` - Optional event type string (used for client-side event filtering)
|
|
103
|
+
/// * `data` - JSON data payload to send to the client
|
|
104
|
+
/// * `id` - Optional event ID (clients can use this to resume after disconnect)
|
|
105
|
+
/// * `retry` - Optional retry timeout in milliseconds (tells client when to reconnect)
|
|
106
|
+
///
|
|
107
|
+
/// # SSE Format
|
|
108
|
+
///
|
|
109
|
+
/// Events are serialized to the following text format:
|
|
110
|
+
/// ```text
|
|
111
|
+
/// event: event_type
|
|
112
|
+
/// data: {"json":"value"}
|
|
113
|
+
/// id: event-123
|
|
114
|
+
/// retry: 3000
|
|
115
|
+
/// ```
|
|
116
|
+
#[derive(Debug, Clone)]
|
|
117
|
+
pub struct SseEvent {
|
|
118
|
+
/// Event type (optional)
|
|
119
|
+
pub event_type: Option<String>,
|
|
120
|
+
/// Event data (JSON value)
|
|
121
|
+
pub data: Value,
|
|
122
|
+
/// Event ID (optional, for client-side reconnection)
|
|
123
|
+
pub id: Option<String>,
|
|
124
|
+
/// Retry timeout in milliseconds (optional)
|
|
125
|
+
pub retry: Option<u64>,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
impl SseEvent {
|
|
129
|
+
/// Create a new SSE event with data only
|
|
130
|
+
///
|
|
131
|
+
/// Creates a minimal event with just the data payload. Use builder methods
|
|
132
|
+
/// to add optional fields.
|
|
133
|
+
///
|
|
134
|
+
/// # Arguments
|
|
135
|
+
/// * `data` - JSON value to send to the client
|
|
136
|
+
///
|
|
137
|
+
/// # Example
|
|
138
|
+
///
|
|
139
|
+
/// ```ignore
|
|
140
|
+
/// use serde_json::json;
|
|
141
|
+
/// use spikard_http::sse::SseEvent;
|
|
142
|
+
///
|
|
143
|
+
/// let event = SseEvent::new(json!({"status": "connected"}));
|
|
144
|
+
/// ```
|
|
145
|
+
pub fn new(data: Value) -> Self {
|
|
146
|
+
Self {
|
|
147
|
+
event_type: None,
|
|
148
|
+
data,
|
|
149
|
+
id: None,
|
|
150
|
+
retry: None,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// Create a new SSE event with an event type and data
|
|
155
|
+
///
|
|
156
|
+
/// Creates an event with a type field. Clients can filter events by type
|
|
157
|
+
/// in their event listener.
|
|
158
|
+
///
|
|
159
|
+
/// # Arguments
|
|
160
|
+
/// * `event_type` - String identifying the event type (e.g., "update", "error")
|
|
161
|
+
/// * `data` - JSON value to send to the client
|
|
162
|
+
///
|
|
163
|
+
/// # Example
|
|
164
|
+
///
|
|
165
|
+
/// ```ignore
|
|
166
|
+
/// use serde_json::json;
|
|
167
|
+
/// use spikard_http::sse::SseEvent;
|
|
168
|
+
///
|
|
169
|
+
/// let event = SseEvent::with_type("update", json!({"count": 42}));
|
|
170
|
+
/// // Client can listen with: eventSource.addEventListener("update", ...)
|
|
171
|
+
/// ```
|
|
172
|
+
pub fn with_type(event_type: impl Into<String>, data: Value) -> Self {
|
|
173
|
+
Self {
|
|
174
|
+
event_type: Some(event_type.into()),
|
|
175
|
+
data,
|
|
176
|
+
id: None,
|
|
177
|
+
retry: None,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Set the event ID for client-side reconnection support
|
|
182
|
+
///
|
|
183
|
+
/// Sets an ID that clients can use to resume from this point if they disconnect.
|
|
184
|
+
/// The client sends this ID back in the `Last-Event-ID` header when reconnecting.
|
|
185
|
+
///
|
|
186
|
+
/// # Arguments
|
|
187
|
+
/// * `id` - Unique identifier for this event
|
|
188
|
+
///
|
|
189
|
+
/// # Example
|
|
190
|
+
///
|
|
191
|
+
/// ```ignore
|
|
192
|
+
/// use serde_json::json;
|
|
193
|
+
/// use spikard_http::sse::SseEvent;
|
|
194
|
+
///
|
|
195
|
+
/// let event = SseEvent::new(json!({"count": 1}))
|
|
196
|
+
/// .with_id("event-1");
|
|
197
|
+
/// ```
|
|
198
|
+
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
|
199
|
+
self.id = Some(id.into());
|
|
200
|
+
self
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Set the retry timeout for client reconnection
|
|
204
|
+
///
|
|
205
|
+
/// Sets the time in milliseconds clients should wait before attempting to reconnect
|
|
206
|
+
/// if the connection is lost. The client browser will automatically handle reconnection.
|
|
207
|
+
///
|
|
208
|
+
/// # Arguments
|
|
209
|
+
/// * `retry_ms` - Retry timeout in milliseconds
|
|
210
|
+
///
|
|
211
|
+
/// # Example
|
|
212
|
+
///
|
|
213
|
+
/// ```ignore
|
|
214
|
+
/// use serde_json::json;
|
|
215
|
+
/// use spikard_http::sse::SseEvent;
|
|
216
|
+
///
|
|
217
|
+
/// let event = SseEvent::new(json!({"data": "value"}))
|
|
218
|
+
/// .with_retry(5000); // Reconnect after 5 seconds
|
|
219
|
+
/// ```
|
|
220
|
+
pub fn with_retry(mut self, retry_ms: u64) -> Self {
|
|
221
|
+
self.retry = Some(retry_ms);
|
|
222
|
+
self
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Convert to Axum's SSE Event
|
|
226
|
+
fn into_axum_event(self) -> Result<Event, serde_json::Error> {
|
|
227
|
+
let json_data = serde_json::to_string(&self.data)?;
|
|
228
|
+
|
|
229
|
+
let mut event = Event::default().data(json_data);
|
|
230
|
+
|
|
231
|
+
if let Some(event_type) = self.event_type {
|
|
232
|
+
event = event.event(event_type);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if let Some(id) = self.id {
|
|
236
|
+
event = event.id(id);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if let Some(retry) = self.retry {
|
|
240
|
+
event = event.retry(Duration::from_millis(retry));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
Ok(event)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// SSE state shared across connections
|
|
248
|
+
///
|
|
249
|
+
/// Contains the event producer and optional JSON schema for validating
|
|
250
|
+
/// events. This state is shared among all connections to the same SSE endpoint.
|
|
251
|
+
pub struct SseState<P: SseEventProducer> {
|
|
252
|
+
/// The event producer implementation
|
|
253
|
+
producer: Arc<P>,
|
|
254
|
+
/// Optional JSON Schema for validating outgoing events
|
|
255
|
+
event_schema: Option<Arc<jsonschema::Validator>>,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
impl<P: SseEventProducer> Clone for SseState<P> {
|
|
259
|
+
fn clone(&self) -> Self {
|
|
260
|
+
Self {
|
|
261
|
+
producer: Arc::clone(&self.producer),
|
|
262
|
+
event_schema: self.event_schema.clone(),
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
impl<P: SseEventProducer + 'static> SseState<P> {
|
|
268
|
+
/// Create new SSE state with an event producer
|
|
269
|
+
///
|
|
270
|
+
/// Creates a new state without event validation schema.
|
|
271
|
+
/// Events are not validated.
|
|
272
|
+
///
|
|
273
|
+
/// # Arguments
|
|
274
|
+
/// * `producer` - The event producer implementation
|
|
275
|
+
///
|
|
276
|
+
/// # Example
|
|
277
|
+
///
|
|
278
|
+
/// ```ignore
|
|
279
|
+
/// let state = SseState::new(MyProducer);
|
|
280
|
+
/// ```
|
|
281
|
+
pub fn new(producer: P) -> Self {
|
|
282
|
+
Self {
|
|
283
|
+
producer: Arc::new(producer),
|
|
284
|
+
event_schema: None,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Create new SSE state with an event producer and optional event schema
|
|
289
|
+
///
|
|
290
|
+
/// Creates a new state with optional JSON schema for validating outgoing events.
|
|
291
|
+
/// If a schema is provided and an event fails validation, it is silently dropped.
|
|
292
|
+
///
|
|
293
|
+
/// # Arguments
|
|
294
|
+
/// * `producer` - The event producer implementation
|
|
295
|
+
/// * `event_schema` - Optional JSON schema for validating events
|
|
296
|
+
///
|
|
297
|
+
/// # Returns
|
|
298
|
+
/// * `Ok(state)` - Successfully created state
|
|
299
|
+
/// * `Err(msg)` - Invalid schema provided
|
|
300
|
+
///
|
|
301
|
+
/// # Example
|
|
302
|
+
///
|
|
303
|
+
/// ```ignore
|
|
304
|
+
/// use serde_json::json;
|
|
305
|
+
///
|
|
306
|
+
/// let event_schema = json!({
|
|
307
|
+
/// "type": "object",
|
|
308
|
+
/// "properties": {
|
|
309
|
+
/// "count": {"type": "integer"}
|
|
310
|
+
/// }
|
|
311
|
+
/// });
|
|
312
|
+
///
|
|
313
|
+
/// let state = SseState::with_schema(MyProducer, Some(event_schema))?;
|
|
314
|
+
/// ```
|
|
315
|
+
pub fn with_schema(producer: P, event_schema: Option<serde_json::Value>) -> Result<Self, String> {
|
|
316
|
+
let event_validator = if let Some(schema) = event_schema {
|
|
317
|
+
Some(Arc::new(
|
|
318
|
+
jsonschema::validator_for(&schema).map_err(|e| format!("Invalid event schema: {}", e))?,
|
|
319
|
+
))
|
|
320
|
+
} else {
|
|
321
|
+
None
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
Ok(Self {
|
|
325
|
+
producer: Arc::new(producer),
|
|
326
|
+
event_schema: event_validator,
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/// SSE endpoint handler
|
|
332
|
+
///
|
|
333
|
+
/// This is the main entry point for SSE connections. Use this as an Axum route
|
|
334
|
+
/// handler by passing it to an Axum router's `.route()` method with `get()`.
|
|
335
|
+
///
|
|
336
|
+
/// The handler establishes a connection and streams events from the producer to
|
|
337
|
+
/// the client using the Server-Sent Events protocol (text/event-stream).
|
|
338
|
+
///
|
|
339
|
+
/// # Arguments
|
|
340
|
+
/// * `State(state)` - Application state containing the event producer and optional schema
|
|
341
|
+
///
|
|
342
|
+
/// # Returns
|
|
343
|
+
/// A streaming response with the `text/event-stream` content type
|
|
344
|
+
///
|
|
345
|
+
/// # Example
|
|
346
|
+
///
|
|
347
|
+
/// ```ignore
|
|
348
|
+
/// use axum::{Router, routing::get, extract::State};
|
|
349
|
+
///
|
|
350
|
+
/// let state = SseState::new(MyProducer);
|
|
351
|
+
/// let router = Router::new()
|
|
352
|
+
/// .route("/events", get(sse_handler::<MyProducer>))
|
|
353
|
+
/// .with_state(state);
|
|
354
|
+
///
|
|
355
|
+
/// // Client usage:
|
|
356
|
+
/// // const eventSource = new EventSource('/events');
|
|
357
|
+
/// // eventSource.onmessage = (e) => console.log(e.data);
|
|
358
|
+
/// ```
|
|
359
|
+
pub async fn sse_handler<P: SseEventProducer + 'static>(State(state): State<SseState<P>>) -> impl IntoResponse {
|
|
360
|
+
info!("SSE client connected");
|
|
361
|
+
|
|
362
|
+
state.producer.on_connect().await;
|
|
363
|
+
|
|
364
|
+
let producer = Arc::clone(&state.producer);
|
|
365
|
+
let event_schema = state.event_schema.clone();
|
|
366
|
+
let stream = stream::unfold((producer, event_schema), |(producer, event_schema)| async move {
|
|
367
|
+
match producer.next_event().await {
|
|
368
|
+
Some(sse_event) => {
|
|
369
|
+
debug!("Sending SSE event: {:?}", sse_event.event_type);
|
|
370
|
+
|
|
371
|
+
if let Some(validator) = &event_schema
|
|
372
|
+
&& !validator.is_valid(&sse_event.data)
|
|
373
|
+
{
|
|
374
|
+
error!("SSE event validation failed");
|
|
375
|
+
return Some((
|
|
376
|
+
Ok::<_, Infallible>(Event::default().data("validation_error")),
|
|
377
|
+
(producer, event_schema),
|
|
378
|
+
));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
match sse_event.into_axum_event() {
|
|
382
|
+
Ok(event) => Some((Ok::<_, Infallible>(event), (producer, event_schema))),
|
|
383
|
+
Err(e) => {
|
|
384
|
+
error!("Failed to serialize SSE event: {}", e);
|
|
385
|
+
None
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
None => {
|
|
390
|
+
info!("SSE stream ended");
|
|
391
|
+
None
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
let sse_response =
|
|
397
|
+
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)).text("keep-alive"));
|
|
398
|
+
|
|
399
|
+
sse_response.into_response()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#[cfg(test)]
|
|
403
|
+
mod tests {
|
|
404
|
+
use super::*;
|
|
405
|
+
|
|
406
|
+
struct TestProducer {
|
|
407
|
+
count: std::sync::atomic::AtomicUsize,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
impl SseEventProducer for TestProducer {
|
|
411
|
+
async fn next_event(&self) -> Option<SseEvent> {
|
|
412
|
+
let count = self.count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
413
|
+
if count < 3 {
|
|
414
|
+
Some(SseEvent::new(serde_json::json!({
|
|
415
|
+
"message": format!("Event {}", count)
|
|
416
|
+
})))
|
|
417
|
+
} else {
|
|
418
|
+
None
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#[test]
|
|
424
|
+
fn test_sse_event_creation() {
|
|
425
|
+
let event = SseEvent::new(serde_json::json!({"test": "data"}));
|
|
426
|
+
assert!(event.event_type.is_none());
|
|
427
|
+
assert!(event.id.is_none());
|
|
428
|
+
assert!(event.retry.is_none());
|
|
429
|
+
|
|
430
|
+
let event = SseEvent::with_type("notification", serde_json::json!({"test": "data"}))
|
|
431
|
+
.with_id("123")
|
|
432
|
+
.with_retry(5000);
|
|
433
|
+
assert_eq!(event.event_type, Some("notification".to_string()));
|
|
434
|
+
assert_eq!(event.id, Some("123".to_string()));
|
|
435
|
+
assert_eq!(event.retry, Some(5000));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[test]
|
|
439
|
+
fn test_sse_state_creation() {
|
|
440
|
+
let producer = TestProducer {
|
|
441
|
+
count: std::sync::atomic::AtomicUsize::new(0),
|
|
442
|
+
};
|
|
443
|
+
let state = SseState::new(producer);
|
|
444
|
+
let cloned = state.clone();
|
|
445
|
+
assert!(Arc::ptr_eq(&state.producer, &cloned.producer));
|
|
446
|
+
}
|
|
447
|
+
}
|