Built-in support for Server Sent Events in .NET 9

Β· 1112 words Β· 6 minutes to read

Many years ago I wrote a book about ASP.NET Web API. One of the chapters in that book was dedicated to supporting push communication between the server and the client, and one of the covered techniques was the niche technology called Server-Sent Events (SSE). At the time, SSE was not widely supported by browsers, however, it was super simple and effective way to push data from the server to the client, and it was a great fit for scenarios where you needed to push data from the server to the client in a one-way fashion without much ceremony.

Over the years, SSE has not really gained much traction, and WebSockets have become the de-facto standard for push communication. However, in recent years a certain OpenAI came out with their API that uses SSE for streaming responses from their Large Language Model, and, pretty much overnight, SSE became cool again.

In .NET 9, SSE finally is getting first-class client-side support and the first preview was released this week with .NET Preview 9.

Overview πŸ”—

There is now a new package called System.Net.ServerSentEvents, whose versioning matches that of the .NET runtime, which explains why its versioning started with 9.0.0-preview.6.24327.7. The package provides a simple API for working with Server-Sent Events in .NET applications on the client side, and is available for all platforms that .NET supports.

At the time of writing, the package only has 122 downloads (I assume about half of those are from me!), so it is very fresh and not widely known yet.

Server side πŸ”—

Let’s build up a simple example of how to use the new library. First, we need the server component which will generate some SSE events. For this, we can use the following simple ASP.NET Core app, based on the default template and its weather forecast endpoint.

using Microsoft.Net.Http.Headers;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseHttpsRedirection();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", async (HttpContext ctx) =>
{
    // todo: generate random weather forecast as SSE events
});

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary);

We will fill in the missing implementation with some random weather forecast generation and SSE event generation. The SSE events are sent as a stream of text events, with each event separated by a newline character. The event itself is a JSON object with a data field, which contains the actual event data.

app.MapGet("/weatherforecast", async (HttpContext ctx) =>
{
    ctx.Response.Headers.Append(HeaderNames.ContentType, "text/event-stream");
    while (!ctx.RequestAborted.IsCancellationRequested)
    {
        var forecast = new WeatherForecast
            (
                DateOnly.FromDateTime(DateTime.Now.AddDays(Random.Shared.Next(0, 8))),
                Random.Shared.Next(-40, 50),
                summaries[Random.Shared.Next(summaries.Length)]
            );

        await ctx.Response.WriteAsync($"data: ");
        await JsonSerializer.SerializeAsync(ctx.Response.Body, forecast);
        await ctx.Response.WriteAsync($"\n\n");
        await ctx.Response.Body.FlushAsync();

        // some artificial delay to not overwhelm the client
        await Task.Delay(2000);
    }
});

This code generates a new random weather forecast every 2 seconds and sends it as an SSE event (text/event-stream) to the client. The event does not have a type, which is the basic variant allowed by the specification. The client can now consume these events using the new System.Net.ServerSentEvents library.

Client side πŸ”—

The client app will be a simple console application, which installs the package and uses the new API to deserialize the SSE events. The simplest approach is to read them as an async stream of strings.

using var client = new HttpClient();
using var stream = await client.GetStreamAsync("http://localhost:5068/weatherforecast");
await foreach (SseItem<string> item in SseParser.Create(stream).EnumerateAsync())
{
    Console.WriteLine(item.Data);
}

This should produce an output similar to the following:

{"Date":"2024-07-12","TemperatureC":23,"Summary":"Hot"}
{"Date":"2024-07-16","TemperatureC":-27,"Summary":"Freezing"}
{"Date":"2024-07-19","TemperatureC":-9,"Summary":"Balmy"}
{"Date":"2024-07-18","TemperatureC":-27,"Summary":"Freezing"}
{"Date":"2024-07-14","TemperatureC":10,"Summary":"Mild"}

It is also possible to consume the incoming Server Sent Events as strongly typed objects, by providing a custom deserializer. The SseItem is typed - and its Data property generic. In the previous example, it was a string, but it can be any type that can be deserialized from the incoming event data. In the example below, we deserialize the incoming event data as a WeatherForecast object.s

using var client = new HttpClient();
using var stream = await client.GetStreamAsync("http://localhost:5068/weatherforecast");
await foreach (SseItem<WeatherForecast?> item in SseParser.Create(stream, (eventType, bytes) => 
    JsonSerializer.Deserialize<WeatherForecast>(bytes)).EnumerateAsync())
{
    if (item.Data != null)
    {
        Console.WriteLine($"Date: {item.Data.Date}, Temperature (in C): {item.Data.TemperatureC}, Summary: {item.Data.Summary}");
    }
    else
    {
        Console.WriteLine("Couldn't deserialize the response");
    }
}

The output would now look like this:

Date: 19.07.2024, Temperature (in C): -31, Summary: Balmy
Date: 17.07.2024, Temperature (in C): 20, Summary: Freezing
Date: 15.07.2024, Temperature (in C): 27, Summary: Hot
Date: 18.07.2024, Temperature (in C): 48, Summary: Scorching
Date: 18.07.2024, Temperature (in C): 36, Summary: Balmy
Date: 18.07.2024, Temperature (in C): -7, Summary: Sweltering
Date: 16.07.2024, Temperature (in C): 1, Summary: Sweltering

Multiple event types πŸ”—

Finally, a common case is to use various event types in the same stream. To illustrate this, let’s update our server side to issue two types of events - one for the weather forecast and one for the sport scores.

app.MapGet("/weatherforecast-or-sportscore", async (HttpContext ctx) =>
{
    ctx.Response.Headers.Append(HeaderNames.ContentType, "text/event-stream");
    while (!ctx.RequestAborted.IsCancellationRequested)
    {
        if (Random.Shared.Next(2) == 0)
        {
            var forecast = new WeatherForecast
                (
                    DateOnly.FromDateTime(DateTime.Now.AddDays(Random.Shared.Next(8))),
                    Random.Shared.Next(-40, 50),
                    summaries[Random.Shared.Next(summaries.Length)]
                );

            await WriteEvent(ctx, "WeatherForecast", forecast);
        } 
        else
        {
            var score = new SportScore(Random.Shared.Next(10), Random.Shared.Next(10));
            await WriteEvent(ctx, "SportScore", score);
        }

        await Task.Delay(2000);
    }
});

async Task WriteEvent<T>(HttpContext ctx, string eventName, T data)
{
    await ctx.Response.WriteAsync($"event: {eventName}\n");
    await ctx.Response.WriteAsync($"data: ");
    await JsonSerializer.SerializeAsync(ctx.Response.Body, data);
    await ctx.Response.WriteAsync($"\n\n");
    await ctx.Response.Body.FlushAsync();
}

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary);
record SportScore(int Team1Score, int Team2Score);

The server now randomly sends either a weather forecast or a sport score event. The client can now consume these events and differentiate between them based on the event type, as defined by the SSE specification.

using var client = new HttpClient();
using var stream = await client.GetStreamAsync("http://localhost:5068/weatherforecast-or-sportscore");
await foreach (var item in SseParser.Create(stream, (eventType, bytes) => eventType switch
{
    "WeatherForecast" => JsonSerializer.Deserialize<WeatherForecast>(bytes),
    "SportScore" => JsonSerializer.Deserialize<SportScore>(bytes) as object,
    _ => null
}).EnumerateAsync())
{
    switch (item.Data)
    {
        case WeatherForecast weatherForecast:
            Console.WriteLine($"Date: {weatherForecast.Date}, Temperature (in C): {weatherForecast.TemperatureC}, Summary: {weatherForecast.Summary}");
            break;
        case SportScore sportScore:
            Console.WriteLine($"Team 1 vs Team 2 {sportScore.Team1Score}:{sportScore.Team2Score}");
            break;
        default:
            Console.WriteLine("Couldn't deserialize the response");
            break;
    }
}

The output of this client would look like this:

Date: 19.07.2024, Temperature (in C): 43, Summary: Cool
Team 1 vs Team 2 0:8
Team 1 vs Team 2 4:5
Team 1 vs Team 2 8:5
Date: 16.07.2024, Temperature (in C): 36, Summary: Sweltering
Team 1 vs Team 2 2:6
Team 1 vs Team 2 5:1

Conclusion πŸ”—

The new System.Net.ServerSentEvents package provides a simple and effective way to consume Server-Sent Events in .NET applications. The package is still in preview, but has already been adopted in the official OpenAI client for .NET. It is a great addition to the .NET ecosystem and will be a welcome little tool for developers who need to consume SSE events in their applications.

The source code for this article is available on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP