DEV Community

Masui Masanori
Masui Masanori

Posted on

[ASP.NET Core] Try Server-Sent Events

Intro

This time, I will try Server-Sent Events(SSE) on ASP.NET Core.

Environments

  • .NET ver.7.0.102
  • Node.js ver.18.15.0

Base Project

The important things to use SSE are to use "EventSource" on the client-side, set response header and wait until the connection is closed on the server-side.

Index.cshtml

<button onclick="Page.connect()">Connect</button>
<button onclick="Page.close()">Close</button>
<div id="received_text_area"></div>
<script src="./js/index.page.js"></script>
Enter fullscreen mode Exit fullscreen mode

index.page.ts

let es: EventSource|null = null;

export function connect() {
    const receivedArea = document.getElementById("received_text_area") as HTMLElement;
    es = new EventSource(`http://localhost:5056/sse/connect`);
    es.onopen = (ev) => {
        console.log(ev);
    };
    es.onmessage = ev => {
        const newText = document.createElement("div");
        newText.textContent = ev.data;
        receivedArea.appendChild(newText);
    };
    es.onerror = ev => {
        console.error(ev);        
    };
}
export function close() {
    es?.close();
}
Enter fullscreen mode Exit fullscreen mode

HomeController.cs

using Microsoft.AspNetCore.Mvc;

namespace SseSample.Controllers;

public class HomeController: Controller
{
    private readonly ILogger<HomeController> logger;
    public HomeController(ILogger<HomeController> logger)
    {
        this.logger = logger;
    }
    [Route("/")]
    public IActionResult Index()
    {
        return View("Views/Index.cshtml");
    }
    [Route("/sse/connect")]
    public async Task ConnectSse()
    {
        Response.Headers.Add("Content-Type", "text/event-stream");
        Response.Headers.Add("Cache-Control", "no-cache");
        Response.Headers.Add("Connection", "keep-alive");
        while(true)
        {
            await Response.WriteAsync($"data: Controller at {DateTime.Now}\r\r");
            await Response.Body.FlushAsync();
            await Task.Delay(1000);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Result

Image description

Storing clients and sending messages

I use "Map" like my WebSocket sample.
And I try storing clients, removing them after disconnecting, and sending messages to other clients.

Server-side

Program.cs

using NLog.Web;
using SseSample.SSE;

var logger = NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
try
{
    var builder = WebApplication.CreateBuilder(args);
    builder.Host.ConfigureLogging(logging =>
    {
        logging.ClearProviders();
        logging.SetMinimumLevel(LogLevel.Trace);
    })
    .UseNLog();
    builder.Services.AddRazorPages();

    builder.Services.AddControllers();
    builder.Services.AddSingleton<ISseHolder, SseHolder>();

    var app = builder.Build();
    app.UseStaticFiles();

    if (builder.Environment.EnvironmentName == "Development")
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseStaticFiles();
    app.UseRouting();
    app.MapSseHolder("/sse/connect");
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
    app.Run();
}
catch (Exception ex)
{
    logger.Error(ex, "Stopped program because of exception");
}
finally
{
    NLog.LogManager.Shutdown();
}
Enter fullscreen mode Exit fullscreen mode

SseMiddleware

namespace SseSample.SSE;
public static class SseHolderMapper
{
    public static IApplicationBuilder MapSseHolder(this IApplicationBuilder app, PathString path)
    {
        return app.Map(path, (app) => app.UseMiddleware<SseMiddleware>());
    }
}
public class SseMiddleware
{
    private readonly RequestDelegate next;
    private readonly ISseHolder sse;
    public SseMiddleware(RequestDelegate next,
        ISseHolder sse)
    {
        this.next = next;
        this.sse = sse;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        await sse.AddAsync(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

ISseHolder.cs

namespace SseSample.SSE;
public interface ISseHolder {
    Task AddAsync(HttpContext context);
    Task SendMessageAsync(SseMessage message);
}
Enter fullscreen mode Exit fullscreen mode

SseHolder.cs

using System.Collections.Concurrent;
using System.Text.Json;

namespace SseSample.SSE;

public record SseClient(HttpResponse Response, CancellationTokenSource Cancel);
public class SseHolder: ISseHolder {
    private readonly ILogger<SseHolder> logger;
    private readonly ConcurrentDictionary<string, SseClient> clients = new ();

    public SseHolder(ILogger<SseHolder> logger,
        IHostApplicationLifetime applicationLifetime)
    {
        this.logger = logger;
        applicationLifetime.ApplicationStopping.Register(OnShutdown);
    }
    public async Task AddAsync(HttpContext context)
    {
        var clientId = CreateId();
        var cancel = new CancellationTokenSource();
        var client = new SseClient(Response: context.Response, Cancel: cancel);
        if(clients.TryAdd(clientId, client))
        {
            EchoAsync(clientId, client);
            context.RequestAborted.WaitHandle.WaitOne();
            RemoveClient(clientId);
            await Task.FromResult(true);
        }
    }
    public async Task SendMessageAsync(SseMessage message)
    {
        foreach(var c in clients)
        {
            if(c.Key == message.Id)
            {
                continue;
            }
            var messageJson = JsonSerializer.Serialize(message);
            await c.Value.Response.WriteAsync($"data: {messageJson}\r\r", c.Value.Cancel.Token);
            await c.Value.Response.Body.FlushAsync(c.Value.Cancel.Token);
        }
    }
    private async void EchoAsync(string clientId, SseClient client)
    {
        try
        {
            var clientIdJson = JsonSerializer.Serialize(new SseClientId { ClientId = clientId });
            client.Response.Headers.Add("Content-Type", "text/event-stream");
            client.Response.Headers.Add("Cache-Control", "no-cache");
            client.Response.Headers.Add("Connection", "keep-alive");
            // Send ID to client-side after connecting
            await client.Response.WriteAsync($"data: {clientIdJson}\r\r", client.Cancel.Token);
            await client.Response.Body.FlushAsync(client.Cancel.Token);
        }
        catch(OperationCanceledException ex)
        {
            logger.LogError($"Exception {ex.Message}");
        }

    }
    private void OnShutdown()
    {
        var tmpClients = new List<KeyValuePair<string, SseClient>>();
        foreach(var c in clients)
        {
            c.Value.Cancel.Cancel();
            tmpClients.Add(c);
        }
        foreach(var c in tmpClients)
        {
            clients.TryRemove(c);
        }
    }
    public void RemoveClient(string id)
    {
        var target = clients.FirstOrDefault(c => c.Key == id);
        if(string.IsNullOrEmpty(target.Key))
        {
            return;
        }
        target.Value.Cancel.Cancel();
        clients.TryRemove(target);
    }
    private string CreateId()
    {
        return Guid.NewGuid().ToString();
    }
}
Enter fullscreen mode Exit fullscreen mode

SseMessage.cs

using System.Text.Json.Serialization;

namespace SseSample.SSE;

public record SseMessage
{
    [JsonPropertyName("id")]
    public string Id { get; init; } = null!;
    [JsonPropertyName("message")]
    public string Message { get; init; } = null!;
}
public record SseClientId
{
    [JsonPropertyName("clientId")]
    public string ClientId { get; init; } = null!;
}
Enter fullscreen mode Exit fullscreen mode

HomeController.cs

using Microsoft.AspNetCore.Mvc;
using SseSample.SSE;

namespace SseSample.Controllers;

public class HomeController: Controller
{
    private readonly ILogger<HomeController> logger;
    private readonly ISseHolder sse;
...
    [Route("/sse/message")]
    public async Task<string> SendMessage([FromBody] SseMessage? message)
    {
        if(string.IsNullOrEmpty(message?.Id) ||
            string.IsNullOrEmpty(message?.Message))
        {
            return "No messages";
        }
        await this.sse.SendMessageAsync(message);
        return "";
    }
}
Enter fullscreen mode Exit fullscreen mode

Client-side

sse.type.ts

export type SseMessage = {
    id: string,
    message: string,
};
export function checkIsSseMessage(value: any): value is SseMessage {
    if(value == null) {
        return false;
    }
    if("id" in value &&
        "message" in value &&
        typeof value["id"] === "string" &&
        typeof value["message"] === "string") {
        return true;
    }
    return false;
}
export type SseClientId = {
    clientId: string,
};
export function checkIsSseClientId(value: any): value is SseClientId {
    if(value == null) {
        return false;
    }
    if("clientId" in value &&
        typeof value["clientId"] === "string") {
        return true;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

index.page.ts

import { checkIsSseClientId, checkIsSseMessage } from "./sse.type";

let es: EventSource|null = null;
let clientId = "";
export function connect() {
    es = new EventSource(`http://localhost:5056/sse/connect`);
    es.onmessage = ev => handleReceivedMessage(ev.data);
    es.onerror = ev => {
        console.error(ev);        
    };
}
export function send() {
    if(hasAnyTexts(clientId) === false) {
        return;
    }
    const messageInput = document.getElementById("send_text_input") as HTMLInputElement;
    const message = messageInput.value;
    if(hasAnyTexts(message) === false) {
        return;
    }
    fetch(`http://localhost:5056/sse/message`, {
        method: "POST",
        mode: "cors",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            id: clientId,
            message
        })
    }).then(res => console.log(res))
    .catch(err => console.error(err));
}
export function close() {
    es?.close();
}
function handleReceivedMessage(message: any) {
    if(typeof message !== "string") {
        console.log(message);        
        return;
    }
    try {
        const jsonValue = JSON.parse(message);
        if(checkIsSseClientId(jsonValue)) {
            clientId = jsonValue.clientId;
        } else if(checkIsSseMessage(jsonValue)) {
            const receivedArea = document.getElementById("received_text_area") as HTMLElement;
            const newText = document.createElement("div");
            newText.textContent = `ID: ${jsonValue.id} Message: ${jsonValue.message}`
            receivedArea.appendChild(newText);
        }
    }catch(err) {
        console.error(err);
    }
}
function hasAnyTexts(value: string|null|undefined): value is string {
    if(value == null) {
        return false;
    }
    if(value.length <= 0) {
        return false;
    }
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Index.cshtml

<button onclick="Page.connect()">Connect</button>
<input type="text" id="send_text_input">
<button onclick="Page.send()">Send</button>
<button onclick="Page.close()">Close</button>
<div id="received_text_area"></div>
<script src="./js/index.page.js"></script>
Enter fullscreen mode Exit fullscreen mode

Resources

Top comments (1)

Collapse
 
artydev profile image
artydev

Very instructive, thank you :-)