Server-Sent Events and ASP.NET Core - Disconnecting a Client

One of the common requests for my Server-Sent Events library is the ability to disconnect clients from a server. My usual answer was that this is best to be implemented as a logical operation where the server sends an event with a specific type and clients to react to it by closing the connection. I was avoiding putting disconnect functionality into the library because it's not fully defined by the protocol.

Disconnecting a Client in Server-Sent Events

Disconnecting a client from a server is a little bit tricky when it comes to Server-Sent Events. The reason for that is automatic reconnect. If the server simply closes the connection, the client will wait a defined period of time and reconnect. The only way to prevent the client from reconnecting is to respond with a 204 No Content status code. So complete flow of disconnecting a client should look like on the diagram below.

Server-Sent Events Client Disconnect Flow Diagram

There is just one challenge here - the server needs to be able to tell that it is the same client trying to reconnect.

Identifying Clients in Server-Sent Events

Server-Sent Events doesn't provide any specific way of identifying clients. No dedicated session mechanism. This is a place where choices and opinions are starting, a place where a library should try to stay generic. I've given a lot of thought to whether I want it to include any specific implementation with the library and I've decided to provide only the contract and a no-op implementation.

public interface IServerSentEventsClientIdProvider
{
    Guid AcquireClientId(HttpContext context);

    void ReleaseClientId(Guid clientId, HttpContext context);
}

This gives consumers full freedom (and responsibility) to implement this aspect in whichever way they prefer. For example, below is a simple cookie-based implementation. In the case of a production scenario, you probably want to think a little bit more about cookie options, if the value should be protected, etc. Also, remember that cookies bear legal requirements.

internal class CookieBasedServerSentEventsClientIdProvider : IServerSentEventsClientIdProvider
{
    private const string COOKIE_NAME = ".ServerSentEvents.Guid";

    public Guid AcquireClientId(HttpContext context)
    {
        Guid clientId;

        string cookieValue = context.Request.Cookies[COOKIE_NAME];
        if (String.IsNullOrWhiteSpace(cookieValue) || !Guid.TryParse(cookieValue, out clientId))
        {
            clientId = Guid.NewGuid();

            context.Response.Cookies.Append(COOKIE_NAME, clientId.ToString());
        }

        return clientId;
    }

    public void ReleaseClientId(Guid clientId, HttpContext context)
    {
        context.Response.Cookies.Delete(COOKIE_NAME);
    }
}

Tracking Disconnected Server-Sent Events Clients

Being able to identify a client is only the first step. The second required thing is tracking the disconnected clients, so when they attempt to reconnect the server can respond with 204 No Content.

public interface IServerSentEventsNoReconnectClientsIdsStore
{
    Task AddClientId(Guid clientId);

    Task<bool> ContainsClientId(Guid clientId);

    Task RemoveClientId(Guid clientId);
}

This part doesn't seem to be complicated until you consider scaling out. When there are multiple instances behind a load balancer, there is a possibility that reconnect attempt will reach a different instance than the one to which the client has been previously connected. This is why I've decided to include two implementations of the above store in the library. One is simply keeping the identifiers in memory, while the second is backed by distributed cache.

Putting Things Together

The high-level flow which library currently performs to handle Server-Sent Events requests looks like below.

public class ServerSentEventsMiddleware<TServerSentEventsService> ...
{
    ...

    public async Task Invoke(HttpContext context, IPolicyEvaluator policyEvaluator)
    {
        if (CheckAcceptHeader(context.Request.Headers))
        {
            if (!await AuthorizeAsync(context, policyEvaluator))
            {
                return;
            }

            ...

            await context.Response.AcceptAsync(_serverSentEventsOptions.OnPrepareAccept);

            ServerSentEventsClient client = new ServerSentEventsClient(clientId, context.User, context.Response, _clientDisconnectServicesAvailable);

            ...

            await ConnectClientAsync(context.Request, client);

            await context.RequestAborted.WaitAsync();

            await DisconnectClientAsync(context.Request, client);
        }
        else
        {
            await _next(context);
        }
    }

    ...
}

To enable the disconnect capability, this flow needs to be adjusted to acquire the client identifier as soon as possible and prevent connection (by responding with 204) if the identifier represents a client which shouldn't be allowed to reconnect.

public class ServerSentEventsMiddleware<TServerSentEventsService> ...
{
    ...

    public async Task Invoke(HttpContext context, IPolicyEvaluator policyEvaluator)
    {
        if (CheckAcceptHeader(context.Request.Headers))
        {
            if (!await AuthorizeAsync(context, policyEvaluator))
            {
                return;
            }

            Guid clientId = _serverSentEventsClientIdProvider.AcquireClientId(context);

            if (await PreventReconnectAsync(clientId, context))
            {
                return;
            }

            ...

            await context.Response.AcceptAsync(_serverSentEventsOptions.OnPrepareAccept);

            ...

            await DisconnectClientAsync(context.Request, client);
        }
        else
        {
            await _next(context);
        }
    }

    ...

    private async Task PreventReconnectAsync(Guid clientId, HttpContext context)
    {
        if (!await _serverSentEventsNoReconnectClientsIdsStore.ContainsClientIdAsync(clientId))
        {
            return false;
        }

        response.StatusCode = StatusCodes.Status204NoContent;

        _serverSentEventsClientIdProvider.ReleaseClientId(clientId, context);

        await _serverSentEventsNoReconnectClientsIdsStore.RemoveClientIdAsync(clientId);

        return true;
    }

    ...
}

You can also see that as part of reconnecting prevention I'm releasing the client identifier and removing it from the "no reconnect" identifiers store. The goal is to allow any underlying implementations to clear any data and make sure that a stale identifier doesn't "stick" with the client.

One more thing which requires adjustment are operations performed when a client is being disconnected. If the client is being disconnected by a server, its identifier should be added to the store. If the disconnect happens on the client-side, the client identifier should be released to avoid staleness.

public class ServerSentEventsMiddleware<TServerSentEventsService> ...
{
    ...

    private async Task DisconnectClientAsync(HttpRequest request, ServerSentEventsClient client)
    {
        ...

        if (client.PreventReconnect)
        {
            await _serverSentEventsNoReconnectClientsIdsStore.AddClientIdAsync(client.Id);
        }
        else
        {
            _serverSentEventsClientIdProvider.ReleaseClientId(client.Id, request.HttpContext);
        }

        ...
    }

    ...
}

Coming Soon to a NuGet Feed Near You

The code is ready and I'm going to push it out with the next release of Lib.AspNetCore.ServerSentEvents. So, if you have been waiting for disconnect capabilities in the library they soon will be there. That said, it still leaves some decisions and implementation to consumers. By sharing parts of my thought process and some implementation details I wanted to make clear why it is like this.