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>
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();
}
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);
}
}
}
Result
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();
}
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);
}
}
ISseHolder.cs
namespace SseSample.SSE;
public interface ISseHolder {
Task AddAsync(HttpContext context);
Task SendMessageAsync(SseMessage message);
}
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();
}
}
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!;
}
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 "";
}
}
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;
}
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;
}
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>
Top comments (1)
Very instructive, thank you :-)