|
@@ -0,0 +1,291 @@
|
|
|
|
|
+using Invercargill;
|
|
|
|
|
+using Invercargill.DataStructures;
|
|
|
|
|
+
|
|
|
|
|
+namespace Astralis {
|
|
|
|
|
+
|
|
|
|
|
+ /// Represents a single Server-Sent Events stream connection.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// SseStream provides the interface for sending events to a specific client
|
|
|
|
|
+ /// connection. Each connected client gets its own SseStream instance that
|
|
|
|
|
+ /// can be used to send targeted events.
|
|
|
|
|
+ public class SseStream : Object {
|
|
|
|
|
+
|
|
|
|
|
+ private AsyncOutput output;
|
|
|
|
|
+ private bool _is_closed = false;
|
|
|
|
|
+ private Mutex close_mutex = Mutex();
|
|
|
|
|
+
|
|
|
|
|
+ /// Signal emitted when the client disconnects.
|
|
|
|
|
+ public signal void disconnected();
|
|
|
|
|
+
|
|
|
|
|
+ /// Indicates whether this stream has been closed.
|
|
|
|
|
+ public bool is_closed {
|
|
|
|
|
+ get {
|
|
|
|
|
+ close_mutex.lock();
|
|
|
|
|
+ var result = _is_closed;
|
|
|
|
|
+ close_mutex.unlock();
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ internal SseStream(AsyncOutput output) {
|
|
|
|
|
+ this.output = output;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Send an SSE event to this stream.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// @param event The SSE event to send
|
|
|
|
|
+ /// @throws Error if the stream is closed or writing fails
|
|
|
|
|
+ public async void send_event(SseEvent event) throws Error {
|
|
|
|
|
+ if (is_closed) {
|
|
|
|
|
+ throw new IOError.CLOSED("Cannot send to closed SSE stream");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!output.connected) {
|
|
|
|
|
+ close();
|
|
|
|
|
+ throw new IOError.CLOSED("Cannot send to closed SSE stream");
|
|
|
|
|
+ }
|
|
|
|
|
+ // Skip writing if the buffer is full (client is slow)
|
|
|
|
|
+ if (output.write_would_block) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ var formatted = event.format();
|
|
|
|
|
+ yield output.write(new ByteBuffer.from_byte_array(formatted.data));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Close this stream. After closing, no more data can be sent.
|
|
|
|
|
+ public void close() {
|
|
|
|
|
+ close_mutex.lock();
|
|
|
|
|
+ var was_closed = _is_closed;
|
|
|
|
|
+ _is_closed = true;
|
|
|
|
|
+ close_mutex.unlock();
|
|
|
|
|
+
|
|
|
|
|
+ if (!was_closed) {
|
|
|
|
|
+ disconnected();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Represents a Server-Sent Events message.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// SseEvent follows the W3C SSE specification with support for
|
|
|
|
|
+ /// id, event type, data, and retry fields.
|
|
|
|
|
+ public class SseEvent : Object {
|
|
|
|
|
+
|
|
|
|
|
+ /// Optional event ID for reconnection tracking
|
|
|
|
|
+ public string? id { get; set; }
|
|
|
|
|
+
|
|
|
|
|
+ /// Optional event type for client-side event listeners
|
|
|
|
|
+ public string? event_type { get; set; }
|
|
|
|
|
+
|
|
|
|
|
+ /// The event data payload
|
|
|
|
|
+ public string data { get; set; }
|
|
|
|
|
+
|
|
|
|
|
+ /// Optional retry interval in milliseconds for reconnection
|
|
|
|
|
+ public int64? retry { get; set; }
|
|
|
|
|
+
|
|
|
|
|
+ /// Create a basic SSE event with just data.
|
|
|
|
|
+ public SseEvent(string data) {
|
|
|
|
|
+ this.data = data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Create an SSE event with an ID and data.
|
|
|
|
|
+ public SseEvent.with_id(string id, string data) {
|
|
|
|
|
+ this.id = id;
|
|
|
|
|
+ this.data = data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Create an SSE event with a type and data.
|
|
|
|
|
+ public SseEvent.with_type(string event_type, string data) {
|
|
|
|
|
+ this.event_type = event_type;
|
|
|
|
|
+ this.data = data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Create a fully-specified SSE event.
|
|
|
|
|
+ public SseEvent.full(string? id, string? event_type, string data, int64? retry = null) {
|
|
|
|
|
+ this.id = id;
|
|
|
|
|
+ this.event_type = event_type;
|
|
|
|
|
+ this.data = data;
|
|
|
|
|
+ this.retry = retry;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Format this event according to the W3C SSE specification.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// @return A properly formatted SSE string ready to be sent
|
|
|
|
|
+ public string format() {
|
|
|
|
|
+ var builder = new StringBuilder();
|
|
|
|
|
+
|
|
|
|
|
+ if (id != null) {
|
|
|
|
|
+ builder.append(@"id: $id\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (event_type != null) {
|
|
|
|
|
+ builder.append(@"event: $event_type\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ // Handle multi-line data by prefixing each line with "data: "
|
|
|
|
|
+ var lines = data.split("\n");
|
|
|
|
|
+ foreach (var line in lines) {
|
|
|
|
|
+ builder.append(@"data: $line\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (retry != null) {
|
|
|
|
|
+ builder.append(@"retry: $retry\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ builder.append("\n");
|
|
|
|
|
+
|
|
|
|
|
+ return builder.str;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// HTTP result type for Server-Sent Events responses.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// HttpSseResult sets the appropriate headers for SSE (Content-Type: text/event-stream,
|
|
|
|
|
+ /// Cache-Control: no-cache) and manages the SSE connection lifecycle.
|
|
|
|
|
+ public class HttpSseResult : HttpResult {
|
|
|
|
|
+
|
|
|
|
|
+ private SseEndpoint endpoint;
|
|
|
|
|
+
|
|
|
|
|
+ /// Create an SSE result for the given endpoint.
|
|
|
|
|
+ internal HttpSseResult(SseEndpoint endpoint) {
|
|
|
|
|
+ base(StatusCode.OK);
|
|
|
|
|
+ this.endpoint = endpoint;
|
|
|
|
|
+
|
|
|
|
|
+ // Set required SSE headers
|
|
|
|
|
+ set_header("Content-Type", "text/event-stream");
|
|
|
|
|
+ set_header("Cache-Control", "no-cache");
|
|
|
|
|
+ set_header("Connection", "keep-alive");
|
|
|
|
|
+
|
|
|
|
|
+ // SSE should not be compressed for real-time delivery
|
|
|
|
|
+ set_flag(HttpResultFlag.DO_NOT_COMPRESS);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public override async void send_body(AsyncOutput output) throws Error {
|
|
|
|
|
+ var stream = new SseStream(output);
|
|
|
|
|
+
|
|
|
|
|
+ // Send retry interval if specified
|
|
|
|
|
+ var retry = endpoint.retry_interval;
|
|
|
|
|
+ if (retry > 0) {
|
|
|
|
|
+ var retry_event = new SseEvent.full(null, null, "", retry);
|
|
|
|
|
+ yield stream.send_event(retry_event);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Register the stream with the endpoint
|
|
|
|
|
+ endpoint.register_stream(stream);
|
|
|
|
|
+
|
|
|
|
|
+ // Notify the endpoint that a new connection was established
|
|
|
|
|
+ endpoint.notify_new_connection(stream);
|
|
|
|
|
+
|
|
|
|
|
+ // Keep the connection alive until the stream is closed
|
|
|
|
|
+ while (!stream.is_closed) {
|
|
|
|
|
+ // Use a longer sleep interval to avoid busy-waiting
|
|
|
|
|
+ // The stream will be closed by the server or by error
|
|
|
|
|
+ Timeout.add(100, send_body.callback);
|
|
|
|
|
+ yield;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Always unregister when done
|
|
|
|
|
+ endpoint.unregister_stream(stream);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Abstract base class for Server-Sent Events endpoints.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// SseEndpoint is designed to be registered as a singleton in the IoC container
|
|
|
|
|
+ /// and provides:
|
|
|
|
|
+ /// - Management of all open SSE streams
|
|
|
|
|
+ /// - Protected access to view all open streams
|
|
|
|
|
+ /// - Protected method to broadcast events to all streams
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// The connection lifecycle is managed internally - implementers only need to
|
|
|
|
|
+ /// override `retry_interval` and optionally `new_connection` to handle new clients.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Example usage:
|
|
|
|
|
+ /// ```vala
|
|
|
|
|
+ /// public class NewsFeedEndpoint : SseEndpoint {
|
|
|
|
|
+ /// public override uint retry_interval { get { return 3000; } }
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// // Called when each client connects
|
|
|
|
|
+ /// public override async void new_connection(SseStream stream) {
|
|
|
|
|
+ /// yield stream.send_event(new SseEvent.with_type("welcome", "Connected to news feed"));
|
|
|
|
|
+ /// }
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// // Call this to broadcast to all clients
|
|
|
|
|
+ /// public async void broadcast_news(string news) {
|
|
|
|
|
+ /// yield broadcast_event(new SseEvent.with_type("news", news));
|
|
|
|
|
+ /// }
|
|
|
|
|
+ /// }
|
|
|
|
|
+ /// ```
|
|
|
|
|
+ public abstract class SseEndpoint : Object, Endpoint {
|
|
|
|
|
+
|
|
|
|
|
+ private Series<SseStream> open_streams = new Series<SseStream>();
|
|
|
|
|
+ private Mutex streams_mutex = Mutex();
|
|
|
|
|
+
|
|
|
|
|
+ /// The retry interval in milliseconds sent to clients for reconnection.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Override this property to specify how long clients should wait before
|
|
|
|
|
+ /// attempting to reconnect if the connection is lost.
|
|
|
|
|
+ public abstract uint retry_interval { get; }
|
|
|
|
|
+
|
|
|
|
|
+ /// Handle the HTTP request and return an SSE result.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// This method is sealed and cannot be overridden. It always returns
|
|
|
|
|
+ /// an HttpSseResult which manages the SSE connection lifecycle.
|
|
|
|
|
+ public sealed async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
|
|
|
|
|
+ return new HttpSseResult(this);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Called when a new client connects.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Override this method to send initial data to the client or set up
|
|
|
|
|
+ /// per-client state. The connection will remain open until the client
|
|
|
|
|
+ /// disconnects or the stream is explicitly closed.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// @param stream The SSE stream for this client connection
|
|
|
|
|
+ public virtual async void new_connection(SseStream stream) {
|
|
|
|
|
+ // Default implementation does nothing
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Get a read-only view of all currently open streams.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// This allows inheritors to iterate over streams for targeted
|
|
|
|
|
+ /// messaging or monitoring purposes.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// @return An immutable collection of open streams
|
|
|
|
|
+ protected ImmutableLot<SseStream> get_open_streams() {
|
|
|
|
|
+ streams_mutex.lock();
|
|
|
|
|
+ var result = open_streams.where(s => !s.is_closed).to_immutable_buffer();
|
|
|
|
|
+ streams_mutex.unlock();
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Send an event to all currently open streams.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// This method will attempt to send to all streams even if some fail.
|
|
|
|
|
+ /// Streams that fail to receive will be closed.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// @param event The SSE event to broadcast
|
|
|
|
|
+ protected async void broadcast_event(SseEvent event) {
|
|
|
|
|
+ var streams = get_open_streams();
|
|
|
|
|
+ foreach (var stream in streams) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ yield stream.send_event(event);
|
|
|
|
|
+ } catch (Error e) {
|
|
|
|
|
+ stream.close();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ internal void register_stream(SseStream stream) {
|
|
|
|
|
+ streams_mutex.lock();
|
|
|
|
|
+ open_streams.add(stream);
|
|
|
|
|
+ streams_mutex.unlock();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ internal void unregister_stream(SseStream stream) {
|
|
|
|
|
+ streams_mutex.lock();
|
|
|
|
|
+ open_streams.remove(stream);
|
|
|
|
|
+ streams_mutex.unlock();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ internal void notify_new_connection(SseStream stream) {
|
|
|
|
|
+ new_connection.begin(stream);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+}
|