DEV Community

Masui Masanori
Masui Masanori

Posted on

22

[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

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

Top comments (1)

Collapse
 
artydev profile image
artydev

Very instructive, thank you :-)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs