EventSource
Server-sent events: a one-way stream of text pushed from the server over ordinary HTTP, with reconnection and resume-from-last-id built into the browser rather than into your code.
@inject Bit.Butil.EventSource eventSourceMDN reference
/sse/ticks on the demo server, which emits one event a
second - every third one named heartbeat rather than unnamed, so both listener
kinds are exercised. Each event carries an id, which is what lets a reconnect resume the
sequence instead of restarting it.
Returns true when the runtime exposes EventSource. Supported by every current engine. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.
@inject Bit.Butil.EventSource eventSource
var supported = await eventSource.IsSupported();Named events never reach the default message listener, so anything the server names has to be declared in eventNames to be seen at all. The onError callback's argument distinguishes 'the browser is about to retry' (false) from 'this stream is finished' (true) - the browser reconnects on its own by default, so a transient failure is not something you have to handle.
IAsyncDisposable
Bit.Butil.EventSource eventSource
{
private EventSourceHandle? _stream;
private readonly List<ServerSentEvent> _events = [];
private async Task Open()
{
_stream = await eventSource.Open(
"/sse/ticks",
onMessage: e => InvokeAsync(() =>
{
// e.EventName is "message" for unnamed events, the server's name otherwise
// e.Data is always text - deserialize it yourself
// e.LastEventId is what the browser replays on reconnect
_events.Insert(0, e);
StateHasChanged();
}),
// Named events never reach the default listener, so anything the server names has to be
// declared here to be seen at all.
eventNames: ["heartbeat"],
onOpen: () => InvokeAsync(StateHasChanged),
onError: fatal => InvokeAsync(StateHasChanged));
}
// Closing is what stops the browser reconnecting: an EventSource that is never closed keeps
// retrying on the browser's own schedule for as long as the page lives.
public async ValueTask DisposeAsync()
{
if (_stream is not null) await _stream.DisposeAsync();
}
}// The other end. Server-sent events are ordinary HTTP with a content type and a wire format - there
// is no protocol upgrade and no library involved.
app.MapGet("/sse/ticks", async (HttpContext context, CancellationToken cancellationToken) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
// Proxies that buffer would defeat the whole point; this is the conventional opt-out.
context.Response.Headers["X-Accel-Buffering"] = "no";
// What makes a reconnect resume rather than restart: the browser sends back the last id it saw.
var next = int.TryParse(context.Request.Headers["Last-Event-ID"], out var lastId) ? lastId + 1 : 1;
while (cancellationToken.IsCancellationRequested is false)
{
// Data is text on the wire; JSON is just the convention for putting structure in it.
var payload = JsonSerializer.Serialize(new { tick = next });
await context.Response.WriteAsync($"id: {next}\n", cancellationToken);
// An event with no name arrives on the default message listener; a named one only reaches a
// listener that asked for that name.
if (next % 3 == 0) await context.Response.WriteAsync("event: heartbeat\n", cancellationToken);
// The blank line is the frame delimiter - without it nothing is ever dispatched.
await context.Response.WriteAsync($"data: {payload}\n\n", cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
next++;
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
});Drop the server (or your network) while the stream is open and the browser reconnects by itself, sending the last id it saw as a Last-Event-ID header. The demo endpoint reads that header and carries on counting rather than starting from one - which is the whole point of ids. GetState reports Connecting during the gap, which is a normal mid-stream state rather than a failure.
{
private EventSourceHandle? _stream; // from eventSource.Open
private async Task ReadState()
{
var state = await _stream!.GetState();
// EventSourceState.Connecting - dropped, browser is retrying
// EventSourceState.Open - delivering
// EventSourceState.Closed - finished for good, no retry
}
}EventSource that is never closed keeps reconnecting on the browser's own
schedule for as long as the page lives. Dispose the handle - as a safety net, the
EventSource service closes any leaked stream when its scope is torn down.
API reference
ValueTask<bool> IsSupported()ValueTask<EventSourceHandle?> Open(string url, Action<ServerSentEvent> onMessage, string[]? eventNames = null, Action? onOpen = null, Action<bool>? onError = null, bool withCredentials = false)ValueTask DisposeAsync()ValueTask<EventSourceState> GetState()ValueTask DisposeAsync()record (string EventName, string Data, string LastEventId)