Server-Sent Events (SSE) Support for ASP.NET Core
In this article, we look at how using Server-Sent Events (SSE) in our ASP.NET Core code, can help create better HTTP pipelines than web sockets.
Join the DZone community and get the full member experience.
Join For FreeThe web socket protocol is currently the most popular method for pushing data to browsers, however, it's not the only one. The Server-Sent Events (SSE) is a very interesting alternative which can provide better performance for specific use cases.
What Is SSE
The Server-Sent Events is a unidirectional (server to browser) protocol for streaming events. The protocol delivers text-based messages over a long-lived HTTP connection. It also has built-in support for events identification, auto-reconnection (with tracking of last received event), and notifications through DOM events. Its biggest advantage is high performance as events can be pushed immediately with minimum overhead (there is an already open HTTP connection waiting, which, thanks to text-based messages, can utilize HTTP compression mechanisms). A considerable limitation is a general lack of support for binary streaming (but JSON or XML will work nicely).
Why Use SSE
In general, web sockets can do everything that Server-Sent Events can and more, as they provide bidirectional communication. There is also broader browser support (93%) for web sockets. So why would one consider the SSE (assuming bidirectional isn't a requirement, or the client to server communication is occasional and can be done in a REST style)? The fact that it runs over a long-lived HTTP connection is a game changer here. In the case of web sockets, we are talking about custom TCP-based protocols which need to be supported by the server and an entire infrastructure (proxies, firewalls, etc.); any legacy element along the way may cause an issue. There are no such issues for SSE, anything that speaks HTTP will speak SSE and the aspect of browser support (87%) can be addressed with polyfills. Taking this into consideration, and notably lower latency, Server-Sent Events are a very compelling choice for scenarios like stock tickers or notifications.
Bringing SSE to ASP.NET Core
One of the key concepts behind ASP.NET Core is the modular HTTP request pipeline which can be extended through middlewares, so I'm going to create one for Server-Sent Events. But first, some prerequisites are needed.
The middleware will require an abstraction for representing a client. As previously stated, SSE runs over a long-lived HTTP connection, which means that channel for communication with a client is HttpResponse
instance. The abstraction will simply wrap around it.
public class ServerSentEventsClient
{
private readonly HttpResponse _response;
internal ServerSentEventsClient(HttpResponse response)
{
_response = response;
}
}
Also, there is a need for some kind of service which will serve as a bridge between the middleware and the rest of the application. Its primary goal will be managing the collection of connected clients. Below is a simple implementation based on a ConcurrentDictionary
.
public class ServerSentEventsService
{
private readonly ConcurrentDictionary<Guid, ServerSentEventsClient> _clients = new ConcurrentDictionary<Guid, ServerSentEventsClient>();
internal Guid AddClient(ServerSentEventsClient client)
{
Guid clientId = Guid.NewGuid();
_clients.TryAdd(clientId, client);
return clientId;
}
internal void RemoveClient(Guid clientId)
{
ServerSentEventsClient client;
_clients.TryRemove(clientId, out client);
}
}
With those elements in place, the middleware can be created. It will have two responsibilities: establishing the connection and cleaning up when the client closes the connection.
In order to establish the connection, the middleware should inspect the Accept header of the incoming request, if its value is text/event-stream
it means that client is attempting to open an SSE connection. In such cases, the Content-Type response header should be set to text/event-stream
, headers should be sent, and connection needs to be kept open.
The cleanup part requires detecting that a client has closed the connection. This can be done by waiting on the CancellationToken
available through the HttpContext.RequestAborted
property. An important thing to note here is that a closed connection can only be detected when sending a new event. This limitation is often being solved by sending a dedicated heartbeat event which the client should simply ignore.
public class ServerSentEventsMiddleware
{
private readonly RequestDelegate _next;
private readonly ServerSentEventsService _serverSentEventsService;
public ServerSentEventsMiddleware(RequestDelegate next, ServerSentEventsService serverSentEventsService)
{
_next = next;
_serverSentEventsService = serverSentEventsService;
}
public Task Invoke(HttpContext context)
{
if (context.Request.Headers["Accept"] == "text/event-stream")
{
context.Response.ContentType = "text/event-stream";
context.Response.Body.Flush();
ServerSentEventsClient client = new ServerSentEventsClient(context.Response);
Guid clientId = _serverSentEventsService.AddClient(client);
context.RequestAborted.WaitHandle.WaitOne();
_serverSentEventsService.RemoveClient(clientId);
return Task.FromResult(true);
}
else
{
return _next(context);
}
}
}
With the connection management part in place, the sending part can be added. The message format in SSE is a very simple one. The basic building blocks of every message are fields which have a general format that looks like this: <FieldName>: <FieldValue>\n
. There are three types of fields (well, in fact, four as there is an additional one for controlling the client reconnect interval):
id
- The identifier of the event.event
- The type of the event.data
- A single line of data (the entire payload of the message is represented by one or more adjacentdata
fields).
Only the data
field is required and the entire message is being terminated by an additional new line (\n
).
public class ServerSentEvent
{
public string Id { get; set; }
public string Type { get; set; }
public IList<string> Data { get; set; }
}
internal static class ServerSentEventsHelper
{
internal static async Task WriteSseEventAsync(this HttpResponse response, ServerSentEvent serverSentEvent)
{
if (!String.IsNullOrWhiteSpace(serverSentEvent.Id))
await response.WriteSseEventFieldAsync("id", serverSentEvent.Id);
if (!String.IsNullOrWhiteSpace(serverSentEvent.Type))
await response.WriteSseEventFieldAsync("event", serverSentEvent.Type);
if (serverSentEvent.Data != null)
{
foreach(string data in serverSentEvent.Data)
await response.WriteSseEventFieldAsync("data", data);
}
await response.WriteSseEventBoundaryAsync();
response.Body.Flush();
}
private static Task WriteSseEventFieldAsync(this HttpResponse response, string field, string data)
{
return response.WriteAsync($"{field}: {data}\n");
}
private static Task WriteSseEventBoundaryAsync(this HttpResponse response)
{
return response.WriteAsync("\n");
}
}
The above helper can be used in order to expose the send method on the client abstraction.
public class ServerSentEventsClient
{
...
public Task SendEventAsync(ServerSentEvent serverSentEvent)
{
return _response.WriteSseEventAsync(serverSentEvent);
}
}
The last step is exposing the send method at the service level - it should perform a send for all connected clients.
public class ServerSentEventsService
{
...
public Task SendEventAsync(ServerSentEvent serverSentEvent)
{
List<Task> clientsTasks = new List<Task>();
foreach (ServerSentEventsClient client in _clients.Values)
{
clientsTasks.Add(client.SendEventAsync(serverSentEvent));
}
return Task.WhenAll(clientsTasks);
}
}
We can say that this gives us what project managers like to call a minimum viable product. After extending the pipeline with the middleware and adding a service to the services collection (as \ singleton) we can send events from any desired place in the application. In the case of a need for exposing more than one endpoint, a derived service can be created, added to the services collection, and passed to the respective middlewares during initialization.
I've made an extended version (support for the reconnect interval, extensibility point for the auto-reconnect, and extensions for service and middleware registrations) available on GitHub and as a NuGet package.
SSE at Work
I've also created a demo application which utilizes the above components, it can be found here. The application exposes two SSE endpoints:
/see-heartbeat
which can be "listened" by navigating to/sse-heartbeat-receiver.html
. It sends an event every 5s and is implemented through an ugly background thread./sse-notifications
which can be "listened" by navigating to/notifications/sse-notifications-receiver
. Sending events to this endpoint can be done by navigating to/notifications/sse-notifications-sender
.
It might be a good starting point for those who would like to play with what I've shown here.
Published at DZone with permission of Tomasz Pęczek. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments