diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a4ab999..ba7d169 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -28,7 +28,7 @@ jobs:
dotnet-version: '9.0'
- name: Build
- run: dotnet build Yllibed.HttpServer.sln /p:Configuration=Release
+ run: dotnet build Yllibed.HttpServer.slnx /p:Configuration=Release
- name: Collect NuGet packages
shell: pwsh
@@ -46,8 +46,7 @@ jobs:
if-no-files-found: error
- name: Test
- #run: dotnet test Yllibed.HttpServer.sln /p:Configuration=Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover --no-build
- run: dotnet test Yllibed.HttpServer.sln /p:Configuration=Release --no-build
+ run: dotnet test Yllibed.HttpServer.slnx /p:Configuration=Release --no-build
publish:
if: startsWith(github.ref, 'refs/heads/master')
diff --git a/Directory.Build.props b/Directory.Build.props
index e6d3630..a6f6745 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,6 +14,13 @@
https://github.com/carldebilly/Yllibed.HttpServer
git
MIT
+ true
+ https://github.com/carldebilly/Yllibed.HttpServer
+ Yllibed.png
+ false
+ http server lightweight self-contained sse iot desktop tools diagnostics
+ true
+ snupkg
enable
@@ -21,17 +28,26 @@
portable
true
12
+ true
+ true
+
+ true
+ true
+
+
+
+
-
-
+
+
all
runtime; build; native; contentfiles; analyzers
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..19923cb
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index bf876cc..e7c8327 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,25 @@
# Yllibed HttpServer
-This is a versatile http server designed to be used in mobile/UWP applications and any applications which need to expose a simple web server.
+
-## Packages and NuGet Statistics
+A small, self-contained HTTP server for desktop, mobile, and embedded apps that need to expose a simple web endpoint.
-| Package | Downloads | Stable Version | Pre-release Version |
-|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
-| [**HttpServer**](https://www.nuget.org/packages/Yllibed.HttpServer/) |  |  |  |
-| [**HttpServer.Json**](https://www.nuget.org/packages/Yllibed.HttpServer.Json/) |  |  |  |
+- Lightweight, no ASP.NET dependency
+- Great for OAuth2 redirect URIs, diagnostics, and local tooling
+- IPv4/IPv6, HTTP/1.1, custom handlers, static files, and SSE
-## Quick start-up
+---
+
+## Packages and NuGet
+
+| Package | Downloads | Stable | Pre-release |
+|---|---|---|---|
+| [Yllibed.HttpServer](https://www.nuget.org/packages/Yllibed.HttpServer/) |  |  |  |
+| [Yllibed.HttpServer.Json](https://www.nuget.org/packages/Yllibed.HttpServer.Json/) |  |  |  |
+
+---
+
+## Quick start
1. First install nuget package:
```shell
@@ -49,36 +59,44 @@ This is a versatile http server designed to be used in mobile/UWP applications a
```
## What it is
-* Simple web server which can be extended using custom code
-* No dependencies on ASP.NET or other frameworks, self-contained
+
+- Simple web server that can be extended with custom code
+- No dependencies on ASP.NET or other frameworks; fully self-contained
+- Intended for small apps and utilities (e.g., OAuth2 redirect URL from an external browser)
## What it is not
-* This HTTP server is not designed for performance or high capacity
-* It's perfect for small applications, or small need, like to act as _return url_ for OAuth2 authentication using external browser.
+
+- NOT designed for high performance or high concurrency
+- NOT appropriate for public-facing web services
+- NOT a full-featured web framework (no MVC, no Razor, no routing, etc.)
+- NOT a replacement for ASP.NET Core or Kestrel
## Features
-* Simple, lightweight, self-contained HTTP server
-* Supports IPv4 and IPv6
-* Supports HTTP 1.1 (limited: no keep-alive, no chunked encoding)
-* Supports GET, POST, PUT, DELETE, HEAD, OPTIONS, TRACE, PATCH - even custom methods
-* Supports static files
-* Supports custom headers
-* Supports custom status codes
-* Supports custom content types
-* Supports custom content encodings
-* Supports dependency injection and configuration via `IOptions`
-* Configurable bind addresses and hostnames for IPv4/IPv6
-* Supports dynamic port assignment
+
+- Simple, lightweight, self-contained HTTP server
+- Supports IPv4 and IPv6
+- Supports HTTP 1.1 (limited: no keep-alive, no chunked encoding)
+- Allows any HTTP method (GET, POST, PUT, DELETE, HEAD, OPTIONS, TRACE, PATCH, custom). Handlers decide how to handle them.
+- Simple static responses via StaticHandler (no built-in file/directory serving)
+- Supports custom headers
+- Supports custom status codes
+- Supports custom content types
+- Arbitrary response headers (incl. Content-Encoding); no automatic compression/encoding
+- Supports dependency injection and configuration via `IOptions`
+- Configurable bind addresses and hostnames for IPv4/IPv6
+- Supports dynamic port assignment
## Common use cases
-* Return URL for OAuth2 authentication using external browser
-* Remote diagnostics/monitoring on your app
-* Building a headless Windows IoT app (for SSDP discovery or simply end-user configuration)
-* Any other use case where you need to expose a simple web server
+
+- Return URL for OAuth2 authentication using external browser
+- Remote diagnostics/monitoring on your app
+- Building a headless Windows IoT app (for SSDP discovery or simply end-user configuration)
+- Any other use case where you need to expose a simple web server
## Limitations
-* There is no support for HTTP 2.0+ (yet) or WebSockets
-* There is no support for HTTPS (TLS)
+
+- There is no support for HTTP/2+ (yet) or WebSockets
+- There is no support for HTTPS (TLS)
## Security and Intended Use (No TLS)
This server uses plain HTTP with no transport encryption. It is primarily intended for:
@@ -260,3 +278,106 @@ var serverOptions = new ServerOptions
Hostname6 = "::1" // IPv6 loopback
};
```
+
+
+## Server-Sent Events (SSE)
+SSE lets your server push a continuous stream of text events over a single HTTP response. This project now provides a minimal SSE path without chunked encoding: headers are sent, then the connection stays open while your code writes events; closing the connection ends the stream.
+
+- Content-Type: text/event-stream
+- Cache-Control: no-cache is added by default
+- Connection: close is still set by the server; the connection remains open until your writer completes
+
+Quick example (application code):
+
+```csharp
+// Register a handler for /sse (very basic example)
+public sealed class SseDemoHandler : IHttpHandler
+{
+ public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath)
+ {
+ if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)) return Task.CompletedTask;
+ if (!string.Equals(relativePath, "/sse", StringComparison.Ordinal)) return Task.CompletedTask;
+
+ request.StartSseSession(RunSseSession,
+ headers: new Dictionary>
+ {
+ ["Access-Control-Allow-Origin"] = new[] { "*" } // if you need CORS
+ },
+ options: new SseOptions
+ {
+ HeartbeatInterval = TimeSpan.FromSeconds(30),
+ HeartbeatComment = "keepalive",
+ AutoFlush = true
+ });
+ return Task.CompletedTask;
+ }
+
+ private async Task RunSseSession(ISseSession sse, CancellationToken ct)
+ {
+ // Optional: initial comment
+ await sse.SendCommentAsync("start", ct);
+
+ var i = 0;
+ while (!ct.IsCancellationRequested && i < 10)
+ {
+ // Write an event every second
+ await sse.SendEventAsync($"{DateTimeOffset.UtcNow:O}", eventName: "tick", id: i.ToString(), ct: ct);
+ await Task.Delay(TimeSpan.FromSeconds(1), ct);
+ i++;
+ }
+ }
+}
+
+// Usage during startup
+var server = new Server();
+var ssePath = new RelativePathHandler("/updates");
+ssePath.RegisterHandler(new SseDemoHandler());
+server.RegisterHandler(ssePath);
+var (uri4, _) = server.Start();
+Console.WriteLine($"SSE endpoint: {uri4}/updates/sse");
+```
+
+SseHandler convenience base class:
+```csharp
+public sealed class MySseHandler : SseHandler
+{
+ protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
+ => base.ShouldHandle(request, relativePath) && relativePath is "/sse";
+
+ protected override Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ => RunSseSession(sse, ct); // Reuse the same private method as above
+}
+
+// Registration
+var server = new Server();
+var ssePath = new RelativePathHandler("/updates");
+ssePath.RegisterHandler(new MySseHandler());
+server.RegisterHandler(ssePath);
+```
+
+Client-side (browser):
+```html
+
+```
+
+Notes:
+- Heartbeats: send a comment frame (`: keepalive\n\n`) every 15–30s to prevent proxy timeouts.
+- Long-running streams: handle CancellationToken to stop cleanly when the client disconnects.
+- Browser connection limits: most browsers cap concurrent HTTP connections per hostname (often 6–15). Without HTTP/2 multiplexing, a single client cannot keep many SSE connections in parallel; this server is not intended for a large number of per-client connections.
+- Public exposure: there is no TLS; prefer localhost or internal networks, or place behind a TLS-terminating reverse proxy.
+
+
+### SSE Spec and Interop Notes
+- Accept negotiation: If a client sends an Accept header that explicitly excludes SSE (text/event-stream), the default SseHandler will reply 406 Not Acceptable. The following values are considered acceptable: text/event-stream, text/*, or */*. If no Accept header is present, requests are accepted. You can override this behavior by overriding ValidateHeaders in your handler (ShouldHandle is for method/path filtering).
+- Last-Event-ID: When a client reconnects, browsers may send a Last-Event-ID header. It is exposed via ISseSession.LastEventId so you can resume from the last delivered event. Set the id parameter in SendEventAsync to help clients keep position.
+- Heartbeats: You can configure periodic comment frames via SseOptions.HeartBeatInterval; this keeps intermediaries from timing out idle connections.
+- Framing: The server uses CRLF (\r\n) in headers and LF (\n) in the SSE body as recommended by typical SSE implementations. Data payloads are normalized to LF before framing each data: line. Each event ends with a blank line.
+- Connection and length: The server does not send Content-Length for streaming SSE responses and relies on connection close to delimit the body (HTTP/1.1 close-delimited). The response header includes Connection: close.
+- Caching: Cache-Control: no-cache is added by default for SSE responses unless you override it via headers.
+- Retry: The SSE spec allows the server to send a retry: field to suggest a reconnection delay. This helper does not currently provide a dedicated API for retry frames. Most clients also implement their own backoff. If you need this, you can write raw lines through a custom handler or open an issue.
+- CORS: If you need cross-origin access, add appropriate headers (e.g., Access-Control-Allow-Origin) via the headers parameter when starting the SSE session.
diff --git a/Yllibed-small.png b/Yllibed-small.png
new file mode 100644
index 0000000..50af5fd
Binary files /dev/null and b/Yllibed-small.png differ
diff --git a/Yllibed.HttpServer.Json.Tests/FixtureBase.cs b/Yllibed.HttpServer.Json.Tests/FixtureBase.cs
index e83d4fb..2c28889 100644
--- a/Yllibed.HttpServer.Json.Tests/FixtureBase.cs
+++ b/Yllibed.HttpServer.Json.Tests/FixtureBase.cs
@@ -1,8 +1,5 @@
#nullable disable
-using System;
using System.Diagnostics;
-using System.Threading;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Yllibed.HttpServer.Json.Tests;
diff --git a/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs b/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..14fc03a
--- /dev/null
+++ b/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs
@@ -0,0 +1,7 @@
+global using AwesomeAssertions;
+global using Microsoft.VisualStudio.TestTools.UnitTesting;
+global using System;
+global using System.Net;
+global using System.Net.Http;
+global using System.Threading;
+global using Yllibed.HttpServer.Sse;
diff --git a/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs b/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs
index 7f0b442..d41b3de 100644
--- a/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs
+++ b/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs
@@ -1,11 +1,6 @@
using System.Collections.Generic;
using System.Linq;
-using System.Net;
-using System.Net.Http;
-using System.Threading;
using System.Threading.Tasks;
-using FluentAssertions;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using Yllibed.HttpServer.Json;
diff --git a/Yllibed.HttpServer.Json.Tests/SseJsonFixture.cs b/Yllibed.HttpServer.Json.Tests/SseJsonFixture.cs
new file mode 100644
index 0000000..fa96194
--- /dev/null
+++ b/Yllibed.HttpServer.Json.Tests/SseJsonFixture.cs
@@ -0,0 +1,53 @@
+using System.Text;
+using System.Threading.Tasks;
+using System.IO;
+using Yllibed.HttpServer.Handlers;
+using Yllibed.HttpServer.Tests;
+
+namespace Yllibed.HttpServer.Json.Tests;
+
+[TestClass]
+public sealed class SseJsonFixture : FixtureBase
+{
+ private sealed class JsonSseHandler : SseHandler
+ {
+ protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
+ => base.ShouldHandle(request, relativePath) && relativePath is "/js";
+
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ var payload = new { A = 1, B = "x" };
+ await sse.SendJsonEventAsync("obj", payload, id: "j1", ct: ct);
+ }
+ }
+
+ [TestMethod]
+ public async Task Sse_SendJson_WritesCompactJsonInData()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse-json");
+ route.RegisterHandler(new JsonSseHandler());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse-json/js");
+
+ await using var conn = await SseTestClient.ConnectAsync(requestUri, CT);
+ conn.Response.StatusCode.Should().Be(HttpStatusCode.OK);
+ conn.Response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
+
+ SseTestClient.ServerSentEvent? first = null;
+ await foreach (var ev in conn.ReadEventsAsync(CT))
+ {
+ first = ev;
+ break;
+ }
+ first.Should().NotBeNull();
+ first!.Event.Should().Be("obj");
+ first.Id.Should().Be("j1");
+ first.Data.Should().Be("""{"A":1,"B":"x"}""");
+ }
+}
diff --git a/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj b/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj
index f393dae..0d45916 100644
--- a/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj
+++ b/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj
@@ -1,33 +1,30 @@
-
+
- net9.0
+ net9.0
true
- false
- False
- false
+ enable
-
-
-
-
-
-
-
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/Yllibed.HttpServer.Json/README.md b/Yllibed.HttpServer.Json/README.md
index 845fcd6..4f22221 100644
--- a/Yllibed.HttpServer.Json/README.md
+++ b/Yllibed.HttpServer.Json/README.md
@@ -1,3 +1,92 @@
-# Yllibed Http Server - JSON extension
+# Yllibed Http Server – JSON Adapter
-This is a simple extension to the Yllibed.HttpServer which allows to serve JSON content, using Newtonsoft's Json.NET library.
+Helpers for building JSON endpoints and JSON SSE (Server‑Sent Events) with Yllibed.HttpServer, powered by Newtonsoft.Json.
+
+## What it provides
+- JsonHandlerBase: base class to implement JSON endpoints quickly (sets content-type, serializes response, handles errors)
+- Query string parsing helper built‑in to the base class (multi‑value aware)
+- JSON serialization using Newtonsoft.Json
+- SSE helpers: SendJsonAsync and SendJsonEventAsync to push compact JSON over text/event-stream
+
+## Quick start – JSON endpoint
+Implement a small handler that returns an object. The adapter serializes it to application/json and writes the proper status code.
+
+```csharp
+using Yllibed.HttpServer;
+using Yllibed.HttpServer.Json;
+
+public sealed class MyResult
+{
+ public string? A { get; set; }
+ public string? B { get; set; }
+}
+
+public sealed class MyHandler : JsonHandlerBase
+{
+ public MyHandler() : base("GET", "/api/echo") { }
+
+ protected override async Task<(MyResult result, ushort statusCode)> ProcessRequest(
+ CancellationToken ct,
+ string relativePath,
+ IDictionary query)
+ {
+ await Task.Yield();
+ query.TryGetValue("a", out var a);
+ query.TryGetValue("b", out var b);
+ return (new MyResult { A = a?.FirstOrDefault(), B = b?.FirstOrDefault() }, 200);
+ }
+}
+
+var server = new Server();
+server.RegisterHandler(new MyHandler());
+var (uri4, _) = server.Start();
+Console.WriteLine(uri4 + "api/echo?a=1&b=2");
+```
+
+Response body:
+```json
+{
+ "A": "1",
+ "B": "2"
+}
+```
+
+## Quick start – JSON over SSE
+Send JSON directly as SSE data using the extension methods.
+
+```csharp
+using Yllibed.HttpServer.Sse;
+using Yllibed.HttpServer.Json;
+
+public sealed class PricesSse : SseHandler
+{
+ protected override bool ShouldHandle(IHttpServerRequest req, string path)
+ => base.ShouldHandle(req, path) && path == "/prices";
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ await sse.SendJsonEventAsync("tick", new { Bid = 1.2345, Ask = 1.2347 }, id: "1", ct: ct);
+ }
+}
+```
+
+The JSON is serialized compact (no whitespace) and placed in the SSE data field.
+
+## Behavior and details
+- Content type: application/json for JsonHandlerBase responses
+- Serializer: Newtonsoft.Json with Formatting.Indented for HTTP responses; Formatting.None for SSE
+- Errors in your handler are caught and a 500 text/plain response is emitted. Log output uses Microsoft.Extensions.Logging via the server’s logger
+- Paths can be passed with or without leading slash; base class normalizes them
+- Method matching is case‑insensitive
+
+## When to use
+- Build small JSON APIs without bringing a full web framework
+- Add real‑time JSON updates over SSE easily
+
+## Package info
+- Package: Yllibed.HttpServer.Json
+- Depends on: Yllibed.HttpServer, Newtonsoft.Json
+- Targets: see solution TargetFrameworks
+
+## See also
+For server setup, routing helpers, SSE basics, and DI, check the main project README: ../Yllibed.HttpServer/README.md or the repository root README.
diff --git a/Yllibed.HttpServer.Json/SseJsonExtensions.cs b/Yllibed.HttpServer.Json/SseJsonExtensions.cs
new file mode 100644
index 0000000..c75aa78
--- /dev/null
+++ b/Yllibed.HttpServer.Json/SseJsonExtensions.cs
@@ -0,0 +1,24 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Yllibed.HttpServer.Sse;
+
+namespace Yllibed.HttpServer.Json;
+
+public static class SseJsonExtensions
+{
+ ///
+ /// Serializes the payload as JSON and sends it as an SSE message data.
+ ///
+ public static Task SendJsonAsync(this ISseSession sse, object? payload, string? eventName = null, string? id = null, CancellationToken ct = default)
+ {
+ var json = JsonConvert.SerializeObject(payload, Formatting.None);
+ return sse.SendEventAsync(json, eventName: eventName, id: id, ct: ct);
+ }
+
+ ///
+ /// Helper alias for SendJsonAsync to emphasize event name parameter first.
+ ///
+ public static Task SendJsonEventAsync(this ISseSession sse, string eventName, object? payload, string? id = null, CancellationToken ct = default)
+ => SendJsonAsync(sse, payload, eventName: eventName, id: id, ct: ct);
+}
diff --git a/Yllibed.HttpServer.Json/Yllibed.HttpServer.Json.csproj b/Yllibed.HttpServer.Json/Yllibed.HttpServer.Json.csproj
index 69491e6..19a8bd1 100644
--- a/Yllibed.HttpServer.Json/Yllibed.HttpServer.Json.csproj
+++ b/Yllibed.HttpServer.Json/Yllibed.HttpServer.Json.csproj
@@ -1,20 +1,21 @@
+ Yllibed.HttpServer.Json
+ Yllibed HttpServer Json Adapter
Yllibed HttpServer Json Adapter
- Json adapter for Yllibed Versatile Http Server using Newtownsoft JSON.NET
-
+ JSON adapter for Yllibed.HttpServer using Newtonsoft.Json
+ yllibed httpserver json newtonsoft adapter
true
README.md
-
-
+
-
\ No newline at end of file
+
diff --git a/Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs b/Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs
new file mode 100644
index 0000000..1caca54
--- /dev/null
+++ b/Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs
@@ -0,0 +1,69 @@
+namespace Yllibed.HttpServer.Tests;
+
+[TestClass]
+public class AcceptHeaderHelperFixture
+{
+ private static bool IsAccepted(string? accept, string mediaType) => new FakeRequest(accept).ValidateAccept(mediaType);
+
+ private sealed class FakeRequest : IHttpServerRequest
+ {
+ public FakeRequest(string? accept) { Accept = accept; }
+ public string Method => "GET";
+ public string Path => "/";
+ public string? Http => "HTTP/1.1";
+ public string? Host => "localhost";
+ public string? HostName => "localhost";
+ public int Port => 80;
+ public string? Referer => null;
+ public string? UserAgent => "UnitTest";
+ public Uri Url => new Uri("http://localhost/");
+ public int? ContentLength => null;
+ public string? ContentType => null;
+ public string? Body => null;
+ public string? Accept { get; }
+ public IReadOnlyDictionary>? Headers => null;
+ public void SetResponse(string contentType, Func> streamFactory, uint resultCode = 200, string resultText = "OK", IReadOnlyDictionary>? headers = null) => throw new NotSupportedException();
+ public void SetResponse(string contentType, string content, uint resultCode = 200, string resultText = "OK", IReadOnlyDictionary>? headers = null) => throw new NotSupportedException();
+ public void SetStreamingResponse(string contentType, Func writer, uint resultCode = 200, string resultText = "OK", IReadOnlyDictionary>? headers = null) => throw new NotSupportedException();
+ }
+
+ [TestMethod]
+ // No Accept header means no constraint
+ [DataRow(null, "text/html", true)]
+ [DataRow("", "text/html", true)]
+ // Wildcard */*
+ [DataRow("*/*", "text/html", true)]
+ [DataRow("*/*;q=1", "text/html", true)]
+ [DataRow("*/*;q=0", "text/html", false)]
+ // Type wildcard
+ [DataRow("text/*", "text/html", true)]
+ [DataRow("text/*;q=0", "text/html", false)]
+ [DataRow("application/*", "text/html", false)]
+ [DataRow("application/*, text/*;q=0", "text/html", false)]
+ // Exact matches (case-insensitive)
+ [DataRow("text/html", "text/html", true)]
+ [DataRow("text/HTML", "text/html", true)]
+ [DataRow("TEXT/HTML", "text/html", true)]
+ [DataRow("text/html;q=0", "text/html", false)]
+ [DataRow("text/html;level=1", "text/html", true)]
+ [DataRow("text/html;q=abc", "text/html", true)]
+ // Multiple entries and precedence
+ [DataRow("text/*;q=0, text/html;q=0.8", "text/html", true)]
+ [DataRow("text/*;q=0, */*;q=0", "text/html", false)]
+ [DataRow("application/json;q=0, text/html", "text/html", true)]
+ [DataRow("application/json;q=0, text/html;q=0", "text/html", false)]
+ [DataRow("application/json;q=0, text/*;q=0, */*;q=1", "text/html", true)]
+ // Malformed/edge tokens
+ [DataRow("texthtml", "text/html", false)] // no slash, not exact match
+ [DataRow(",,, text/html", "text/html", true)]
+ [DataRow("text/*; q= 0.5", "text/plain", true)]
+ [DataRow("text/* ; q = 0 ", "text/plain", false)]
+ // Order independence
+ [DataRow("text/html;q=0, */*;q=1", "text/html", true)]
+ [DataRow("*/*;q=0, text/html;q=1", "text/html", true)]
+ public void IsAccepted_VariousCases_ShouldMatchExpectation(string? accept, string mediaType, bool expected)
+ {
+ var result = IsAccepted(accept, mediaType);
+ result.Should().Be(expected, $"Accept='{accept}' should{(expected ? string.Empty : " not")} accept '{mediaType}'");
+ }
+}
diff --git a/Yllibed.HttpServer.Tests/DiFixture.cs b/Yllibed.HttpServer.Tests/DiFixture.cs
index 5a3561e..11d5271 100644
--- a/Yllibed.HttpServer.Tests/DiFixture.cs
+++ b/Yllibed.HttpServer.Tests/DiFixture.cs
@@ -1,9 +1,4 @@
-using System.Net.Http;
-using System.Threading.Tasks;
-using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-using Yllibed.HttpServer.Extensions;
namespace Yllibed.HttpServer.Tests;
@@ -21,8 +16,8 @@ public async Task Server_CanBeResolvedFromDI_WithConfigureOptions()
opts.Port = 0; // Use dynamic port for test
opts.Hostname4 = "127.0.0.1";
opts.Hostname6 = "::1";
- opts.BindAddress4 = System.Net.IPAddress.Loopback;
- opts.BindAddress6 = System.Net.IPAddress.IPv6Loopback;
+ opts.BindAddress4 = IPAddress.Loopback;
+ opts.BindAddress6 = IPAddress.IPv6Loopback;
});
// Register Server explicitly via factory to avoid ambiguous constructor selection
@@ -37,7 +32,7 @@ public async Task Server_CanBeResolvedFromDI_WithConfigureOptions()
using var client = new HttpClient();
var response = await client.GetAsync(uri4, CT).ConfigureAwait(false);
- response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
@@ -51,8 +46,8 @@ public async Task Server_CanBeResolvedFromDI_WithActivatorUtilitiesConstructor()
opts.Port = 0;
opts.Hostname4 = "127.0.0.1";
opts.Hostname6 = "::1";
- opts.BindAddress4 = System.Net.IPAddress.Loopback;
- opts.BindAddress6 = System.Net.IPAddress.IPv6Loopback;
+ opts.BindAddress4 = IPAddress.Loopback;
+ opts.BindAddress6 = IPAddress.IPv6Loopback;
});
// This should now work without explicit factory thanks to [ActivatorUtilitiesConstructor]
@@ -67,7 +62,7 @@ public async Task Server_CanBeResolvedFromDI_WithActivatorUtilitiesConstructor()
using var client = new HttpClient();
var response = await client.GetAsync(uri4, CT).ConfigureAwait(false);
- response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
@@ -82,8 +77,8 @@ public async Task Server_CanBeRegisteredWithExtensionMethod()
opts.Port = 0;
opts.Hostname4 = "127.0.0.1";
opts.Hostname6 = "::1";
- opts.BindAddress4 = System.Net.IPAddress.Loopback;
- opts.BindAddress6 = System.Net.IPAddress.IPv6Loopback;
+ opts.BindAddress4 = IPAddress.Loopback;
+ opts.BindAddress6 = IPAddress.IPv6Loopback;
});
var sp = services.BuildServiceProvider();
@@ -95,7 +90,7 @@ public async Task Server_CanBeRegisteredWithExtensionMethod()
using var client = new HttpClient();
var response = await client.GetAsync(uri4, CT).ConfigureAwait(false);
- response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
@@ -114,7 +109,7 @@ public async Task README_Example_Works()
var sp = services.BuildServiceProvider();
var server = sp.GetRequiredService();
- server.RegisterHandler(new Yllibed.HttpServer.Handlers.StaticHandler("/", "text/plain", "Hello, world!"));
+ server.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!"));
using (server)
{
@@ -122,7 +117,7 @@ public async Task README_Example_Works()
using var client = new HttpClient();
var response = await client.GetAsync(uri4, CT).ConfigureAwait(false);
- response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false);
content.Should().Be("Hello, world!");
diff --git a/Yllibed.HttpServer.Tests/FixtureBase.cs b/Yllibed.HttpServer.Tests/FixtureBase.cs
index a43a315..7419bc1 100644
--- a/Yllibed.HttpServer.Tests/FixtureBase.cs
+++ b/Yllibed.HttpServer.Tests/FixtureBase.cs
@@ -1,8 +1,5 @@
#nullable disable
-using System;
using System.Diagnostics;
-using System.Threading;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Yllibed.HttpServer.Tests;
diff --git a/Yllibed.HttpServer.Tests/GlobalUsings.cs b/Yllibed.HttpServer.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..ea4e991
--- /dev/null
+++ b/Yllibed.HttpServer.Tests/GlobalUsings.cs
@@ -0,0 +1,6 @@
+global using AwesomeAssertions;
+global using Microsoft.VisualStudio.TestTools.UnitTesting;
+global using System.Net;
+global using System.Net.Http;
+global using Yllibed.HttpServer.Extensions;
+global using Yllibed.HttpServer.Handlers;
diff --git a/Yllibed.HttpServer.Tests/HttpServerFixture.cs b/Yllibed.HttpServer.Tests/HttpServerFixture.cs
index 1e84600..bea8c5a 100644
--- a/Yllibed.HttpServer.Tests/HttpServerFixture.cs
+++ b/Yllibed.HttpServer.Tests/HttpServerFixture.cs
@@ -1,14 +1,4 @@
-using System;
-using System.ComponentModel.Design;
-using System.Net;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using FluentAssertions;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-using Yllibed.HttpServer.Handlers;
-
-namespace Yllibed.HttpServer.Tests;
+namespace Yllibed.HttpServer.Tests;
[TestClass]
public class HttpServerFixture : FixtureBase
diff --git a/Yllibed.HttpServer.Tests/ServerOptionsFixture.cs b/Yllibed.HttpServer.Tests/ServerOptionsFixture.cs
index 31aa085..8e2d0d1 100644
--- a/Yllibed.HttpServer.Tests/ServerOptionsFixture.cs
+++ b/Yllibed.HttpServer.Tests/ServerOptionsFixture.cs
@@ -1,10 +1,3 @@
-using System;
-using System.Net;
-using System.Net.Http;
-using System.Threading.Tasks;
-using FluentAssertions;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-
namespace Yllibed.HttpServer.Tests;
[TestClass]
@@ -49,10 +42,10 @@ public async Task ServerOptions_CustomHostnames_CanAcceptConnections()
using var client = new HttpClient();
var response4 = await client.GetAsync(uri4).ConfigureAwait(false);
- response4.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
+ response4.StatusCode.Should().Be(HttpStatusCode.NotFound);
using var client6 = new HttpClient();
var response6 = await client6.GetAsync(uri6).ConfigureAwait(false);
- response6.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
+ response6.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
diff --git a/Yllibed.HttpServer.Tests/SseFixture.cs b/Yllibed.HttpServer.Tests/SseFixture.cs
new file mode 100644
index 0000000..6d2e365
--- /dev/null
+++ b/Yllibed.HttpServer.Tests/SseFixture.cs
@@ -0,0 +1,242 @@
+using System.Globalization;
+using Yllibed.HttpServer.Sse;
+
+namespace Yllibed.HttpServer.Tests;
+
+[TestClass]
+public sealed class SseFixture : FixtureBase
+{
+ private sealed class TestSseHandler : SseHandler
+ {
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions
+ {
+ AutoFlush = true,
+ HeartbeatInterval = TimeSpan.Zero // keep test deterministic
+ };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ await sse.SendCommentAsync("start", ct);
+ await sse.SendEventAsync("hello\nworld", eventName: "greet", id: "1", ct: ct);
+ await sse.SendEventAsync("bye", id: "2", ct: ct);
+ }
+ }
+
+ [TestMethod]
+ public async Task Sse_BasicEvents_AreReceived()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse");
+ route.RegisterHandler(new TestSseHandler());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse");
+
+ await using var conn = await SseTestClient.ConnectAsync(requestUri, CT);
+ var resp = conn.Response;
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ resp.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
+ resp.Headers.CacheControl.Should().NotBeNull();
+ resp.Headers.CacheControl!.NoCache.Should().BeTrue();
+
+ var received = new List();
+ await foreach (var ev in conn.ReadEventsAsync(CT))
+ {
+ received.Add(ev);
+ if (received.Count >= 2) break; // we expect two events then end the session
+ }
+
+ received.Count.Should().Be(2);
+ received[0].Event.Should().Be("greet");
+ received[0].Id.Should().Be("1");
+ received[0].Data.Should().Be("hello\nworld");
+
+ received[1].Event.Should().BeNull(); // default event name is 'message' but we keep null in helper
+ received[1].Id.Should().Be("2");
+ received[1].Data.Should().Be("bye");
+ }
+}
+
+
+[TestClass]
+public sealed class SseLifecycleFixture : FixtureBase
+{
+ private sealed class TestSseHandler2 : SseHandler
+ {
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ await sse.SendEventAsync("hello\nworld", eventName: "greet", id: "1", ct: ct);
+ await sse.SendEventAsync("bye", id: "2", ct: ct);
+ }
+ }
+
+ [TestMethod]
+ public async Task Sse_StreamEnds_WhenHandlerCompletes()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse2");
+ route.RegisterHandler(new TestSseHandler2());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse2");
+
+ await using var conn = await SseTestClient.ConnectAsync(requestUri, CT);
+ var resp = conn.Response;
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ resp.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
+
+ var received = new List();
+ await foreach (var ev in conn.ReadEventsAsync(CT))
+ {
+ received.Add(ev);
+ }
+
+ received.Count.Should().Be(2, "stream should close when handler completes after sending two events");
+ }
+
+ private sealed class LoopingSseHandler : SseHandler
+ {
+ private readonly TaskCompletionSource _disconnected;
+ public LoopingSseHandler(TaskCompletionSource disconnected) => _disconnected = disconnected;
+
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ try
+ {
+ var i = 0;
+ while (!ct.IsCancellationRequested)
+ {
+ var idStr = i.ToString(CultureInfo.InvariantCulture);
+ await sse.SendEventAsync("tick-" + idStr, eventName: "tick", id: idStr, ct: ct);
+ await Task.Delay(10, ct);
+ i++;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when client disconnects or server cancels
+ }
+ finally
+ {
+ _disconnected.TrySetResult(true);
+ }
+ }
+ }
+
+ [TestMethod]
+ public async Task Sse_HandlerCancels_WhenClientDisconnects()
+ {
+ using var sut = new Server();
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var route = new RelativePathHandler("sse3");
+ route.RegisterHandler(new LoopingSseHandler(tcs));
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse3");
+
+ await using (var conn = await SseTestClient.ConnectAsync(requestUri, CT))
+ {
+ conn.Response.StatusCode.Should().Be(HttpStatusCode.OK);
+ // Read a single event then disconnect
+ await foreach (var _ in conn.ReadEventsAsync(CT))
+ {
+ break;
+ }
+ // Disposing the connection should close the TCP stream
+ }
+
+ var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(5), CT));
+ completed.Should().Be(tcs.Task, "handler should complete when client disconnects");
+ ( await tcs.Task ).Should().BeTrue();
+ }
+}
+
+
+[TestClass]
+public sealed class SseMultiHandlersFixture : FixtureBase
+{
+ private sealed class SseHandlerA : SseHandler
+ {
+ protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
+ => base.ShouldHandle(request, relativePath)
+ && string.Equals(relativePath, "/a", StringComparison.Ordinal);
+
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ await sse.SendEventAsync("a1", eventName: "alpha", id: "A", ct: ct);
+ }
+ }
+
+ private sealed class SseHandlerB : SseHandler
+ {
+ protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
+ => base.ShouldHandle(request, relativePath)
+ && string.Equals(relativePath, "/b", StringComparison.Ordinal);
+
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ await sse.SendEventAsync("b1", eventName: "beta", id: "B", ct: ct);
+ }
+ }
+
+ [TestMethod]
+ public async Task Sse_MultipleHandlers_DifferentStreams()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse-multi");
+ route.RegisterHandler(new SseHandlerA());
+ route.RegisterHandler(new SseHandlerB());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var uriA = new Uri(uri4, "sse-multi/a");
+ var uriB = new Uri(uri4, "sse-multi/b");
+
+ await using var connA = await SseTestClient.ConnectAsync(uriA, CT);
+ await using var connB = await SseTestClient.ConnectAsync(uriB, CT);
+
+ connA.Response.StatusCode.Should().Be(HttpStatusCode.OK);
+ connB.Response.StatusCode.Should().Be(HttpStatusCode.OK);
+ connA.Response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
+ connB.Response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
+
+ SseTestClient.ServerSentEvent? firstA = null;
+ SseTestClient.ServerSentEvent? firstB = null;
+
+ await foreach (var ev in connA.ReadEventsAsync(CT))
+ {
+ firstA = ev;
+ break;
+ }
+ await foreach (var ev in connB.ReadEventsAsync(CT))
+ {
+ firstB = ev;
+ break;
+ }
+
+ firstA.Should().NotBeNull();
+ firstB.Should().NotBeNull();
+ firstA!.Event.Should().Be("alpha");
+ firstA.Id.Should().Be("A");
+ firstA.Data.Should().Be("a1");
+ firstB!.Event.Should().Be("beta");
+ firstB.Id.Should().Be("B");
+ firstB.Data.Should().Be("b1");
+ }
+}
diff --git a/Yllibed.HttpServer.Tests/SseNegotiationFixture.cs b/Yllibed.HttpServer.Tests/SseNegotiationFixture.cs
new file mode 100644
index 0000000..27e2e6e
--- /dev/null
+++ b/Yllibed.HttpServer.Tests/SseNegotiationFixture.cs
@@ -0,0 +1,108 @@
+using Yllibed.HttpServer.Sse;
+
+namespace Yllibed.HttpServer.Tests;
+
+[TestClass]
+public sealed class SseNegotiationFixture : FixtureBase
+{
+ private sealed class AcceptCheckedSseHandler : SseHandler
+ {
+ protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
+ => base.ShouldHandle(request, relativePath) && relativePath is "/accept";
+
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ await sse.SendEventAsync("ok", ct: ct);
+ }
+ }
+
+ [TestMethod]
+ public async Task Sse_Returns406_When_Accept_DoesNotAllowEventStream()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse-accept");
+ route.RegisterHandler(new AcceptCheckedSseHandler());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse-accept/accept");
+
+ await using var conn = await SseTestClient.ConnectAsync(requestUri, CT, accept: "text/plain");
+ conn.Response.StatusCode.Should().Be(HttpStatusCode.NotAcceptable);
+ }
+
+ [TestMethod]
+ public async Task Sse_Returns406_When_Accept_EventStream_Q0()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse-accept");
+ route.RegisterHandler(new AcceptCheckedSseHandler());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse-accept/accept");
+
+ await using var conn = await SseTestClient.ConnectAsync(requestUri, CT, accept: "text/event-stream;q=0");
+ conn.Response.StatusCode.Should().Be(HttpStatusCode.NotAcceptable);
+ }
+
+ [TestMethod]
+ public async Task Sse_Returns406_When_Accept_TextWildcard_Q0()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse-accept");
+ route.RegisterHandler(new AcceptCheckedSseHandler());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse-accept/accept");
+
+ await using var conn = await SseTestClient.ConnectAsync(requestUri, CT, accept: "text/*;q=0");
+ conn.Response.StatusCode.Should().Be(HttpStatusCode.NotAcceptable);
+ }
+
+ private sealed class LastEventIdEchoHandler : SseHandler
+ {
+ protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
+ => base.ShouldHandle(request, relativePath) && relativePath is "/echo";
+
+ protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
+ => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ var lastId = sse.LastEventId ?? "";
+ await sse.SendEventAsync(lastId, eventName: "lastid", id: lastId, ct: ct);
+ }
+ }
+
+ [TestMethod]
+ public async Task Sse_LastEventId_IsExposed_ToHandler()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("sse-lastid");
+ route.RegisterHandler(new LastEventIdEchoHandler());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "sse-lastid/echo");
+
+ await using var conn = await SseTestClient.ConnectAsync(requestUri, CT, lastEventId: "42");
+ conn.Response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ SseTestClient.ServerSentEvent? first = null;
+ await foreach (var ev in conn.ReadEventsAsync(CT))
+ {
+ first = ev;
+ break;
+ }
+
+ first.Should().NotBeNull();
+ first!.Event.Should().Be("lastid");
+ first.Id.Should().Be("42");
+ first.Data.Should().Be("42");
+ }
+}
diff --git a/Yllibed.HttpServer.Tests/SseTestClient.cs b/Yllibed.HttpServer.Tests/SseTestClient.cs
new file mode 100644
index 0000000..d07cade
--- /dev/null
+++ b/Yllibed.HttpServer.Tests/SseTestClient.cs
@@ -0,0 +1,145 @@
+#pragma warning disable MA0001 // IndexOf StringComparison analyzer not applicable for char overloads here
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+
+namespace Yllibed.HttpServer.Tests;
+
+internal static class SseTestClient
+{
+ internal sealed record ServerSentEvent(string? Event, string? Id, string Data);
+
+ internal sealed class SseConnection : IAsyncDisposable, IDisposable
+ {
+ private readonly HttpClient _client;
+ private readonly HttpResponseMessage _response;
+ private Stream? _stream;
+
+ public SseConnection(HttpClient client, HttpResponseMessage response)
+ {
+ _client = client;
+ _response = response;
+ }
+
+ public HttpResponseMessage Response => _response;
+
+ public async IAsyncEnumerable ReadEventsAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
+ {
+ _stream ??= await _response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ await foreach (var ev in ReadFromStreamAsync(_stream, ct))
+ {
+ yield return ev;
+ }
+ }
+
+ public void Dispose()
+ {
+ _stream?.Dispose();
+ _response.Dispose();
+ _client.Dispose();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_stream is IAsyncDisposable ad)
+ {
+ await ad.DisposeAsync().ConfigureAwait(false);
+ }
+ else
+ {
+ _stream?.Dispose();
+ }
+ _response.Dispose();
+ _client.Dispose();
+ }
+ }
+
+ public static async Task ConnectAsync(Uri uri, CancellationToken ct, string? accept = null, string? lastEventId = null)
+ {
+ var client = new HttpClient();
+ var req = new HttpRequestMessage(HttpMethod.Get, uri);
+ if (!string.IsNullOrEmpty(accept))
+ {
+ req.Headers.Accept.Clear();
+ req.Headers.Accept.ParseAdd(accept);
+ }
+ if (!string.IsNullOrEmpty(lastEventId))
+ {
+ req.Headers.TryAddWithoutValidation("Last-Event-ID", lastEventId);
+ }
+ var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ return new SseConnection(client, resp);
+ }
+
+ private static async IAsyncEnumerable ReadFromStreamAsync(
+ Stream stream,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
+ {
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
+
+ string? ev = null;
+ string? id = null;
+ var dataBuilder = new StringBuilder();
+
+ while (!reader.EndOfStream && !ct.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(ct).ConfigureAwait(false) ?? string.Empty;
+
+ if (line.Length == 0)
+ {
+ if (dataBuilder.Length > 0)
+ {
+ var data = dataBuilder.ToString().TrimEnd('\n');
+ yield return new ServerSentEvent(ev, id, data);
+ dataBuilder.Clear();
+ ev = null; // id is sticky per spec; do not clear id here
+ }
+ continue;
+ }
+
+ if (line[0] == ':')
+ {
+ // comment: ignore for now
+ continue;
+ }
+
+ var idx = -1;
+ for (var i = 0; i < line.Length; i++)
+ {
+ if (line[i] == ':')
+ {
+ idx = i; break;
+ }
+ }
+ string field, value;
+ if (idx == -1)
+ {
+ field = line;
+ value = string.Empty;
+ }
+ else
+ {
+ field = line[..idx];
+ value = (idx + 1 < line.Length && line[idx + 1] == ' ')
+ ? line[(idx + 2)..]
+ : line[(idx + 1)..];
+ }
+
+ switch (field)
+ {
+ case "event":
+ ev = value;
+ break;
+ case "data":
+ dataBuilder.Append(value).Append('\n');
+ break;
+ case "id":
+ id = value; // sticky across events
+ break;
+ case "retry":
+ // ignore in tests
+ break;
+ }
+ }
+ }
+}
diff --git a/Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs b/Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs
new file mode 100644
index 0000000..3c8bb2c
--- /dev/null
+++ b/Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs
@@ -0,0 +1,133 @@
+namespace Yllibed.HttpServer.Tests;
+
+[TestClass]
+public sealed class StreamingLifecycleFixture : FixtureBase
+{
+ private sealed class FiniteStreamingHandler : IHttpHandler
+ {
+ public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath)
+ {
+ if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(relativePath, "/finite", StringComparison.Ordinal))
+ {
+ return Task.CompletedTask;
+ }
+
+ request.SetStreamingResponse("text/plain", async (writer, wct) =>
+ {
+ for (var i = 0; i < 5; i++)
+ {
+ await writer.WriteLineAsync("line-" + i.ToString(System.Globalization.CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ await writer.FlushAsync(wct).ConfigureAwait(false);
+ }
+ }, headers: null);
+
+ return Task.CompletedTask;
+ }
+ }
+
+ [TestMethod]
+ public async Task Streaming_StreamEnds_WhenWriterCompletes()
+ {
+ using var sut = new Server();
+ var route = new RelativePathHandler("stream");
+ route.RegisterHandler(new FiniteStreamingHandler());
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "stream/finite");
+
+ using var client = new HttpClient();
+ using var resp = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, CT);
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ await using var stream = await resp.Content.ReadAsStreamAsync(CT);
+ using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
+ var lines = new List();
+ while (true)
+ {
+ var line = await reader.ReadLineAsync(CT).ConfigureAwait(false);
+ if (line is null) break; // EOF
+ if (line.Length == 0) continue; // skip blank
+ lines.Add(line);
+ }
+
+ lines.Should().ContainInOrder("line-0", "line-1", "line-2", "line-3", "line-4");
+ lines.Should().HaveCount(5);
+ }
+
+ private sealed class InfiniteStreamingHandler : IHttpHandler
+ {
+ private readonly TaskCompletionSource _tcs;
+ public InfiniteStreamingHandler(TaskCompletionSource tcs) => _tcs = tcs;
+
+ public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath)
+ {
+ if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(relativePath, "/infinite", StringComparison.Ordinal))
+ {
+ return Task.CompletedTask;
+ }
+
+ request.SetStreamingResponse("text/plain", async (writer, wct) =>
+ {
+ try
+ {
+ var i = 0;
+ while (true)
+ {
+ await writer.WriteLineAsync("tick-" + i.ToString(System.Globalization.CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ await writer.FlushAsync(wct).ConfigureAwait(false);
+ await Task.Delay(10, wct).ConfigureAwait(false);
+ i++;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Cancellation propagated by server or delay
+ }
+ catch (IOException)
+ {
+ // Expected when client disconnects
+ }
+ catch (ObjectDisposedException)
+ {
+ // Also fine on disconnect
+ }
+ finally
+ {
+ _tcs.TrySetResult(true);
+ }
+ }, headers: null);
+
+ return Task.CompletedTask;
+ }
+ }
+
+ [TestMethod]
+ public async Task Streaming_HandlerStops_OnClientDisconnect()
+ {
+ using var sut = new Server();
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var route = new RelativePathHandler("stream");
+ route.RegisterHandler(new InfiniteStreamingHandler(tcs));
+ sut.RegisterHandler(route);
+
+ var (uri4, _) = sut.Start();
+ var requestUri = new Uri(uri4, "stream/infinite");
+
+ using var client = new HttpClient();
+ using (var resp = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, CT))
+ {
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ await using var stream = await resp.Content.ReadAsStreamAsync(CT);
+ using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
+ // Read a single line, then drop connection
+ _ = await reader.ReadLineAsync(CT).ConfigureAwait(false);
+ }
+
+ var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(5), CT));
+ completed.Should().Be(tcs.Task, "handler should observe disconnect and stop promptly");
+ (await tcs.Task).Should().BeTrue();
+ }
+}
diff --git a/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj b/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj
index ebd7799..e18dcf8 100644
--- a/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj
+++ b/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj
@@ -1,10 +1,7 @@
-
+
- net9.0
- true
- false
- False
- false
+ net9.0
+ enable
@@ -12,21 +9,17 @@
-
-
-
-
-
-
-
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
-
\ No newline at end of file
+
diff --git a/Yllibed.HttpServer.sln b/Yllibed.HttpServer.sln
deleted file mode 100644
index cfe86ff..0000000
--- a/Yllibed.HttpServer.sln
+++ /dev/null
@@ -1,49 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.12.35514.174 d17.12
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Yllibed.HttpServer", "Yllibed.HttpServer\Yllibed.HttpServer.csproj", "{8D21CD92-C0E8-478A-867C-2D07A02879D5}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Yllibed.HttpServer.Tests", "Yllibed.HttpServer.Tests\Yllibed.HttpServer.Tests.csproj", "{D558F35B-2335-4DA1-BC07-338EEA7EA4AF}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0A711B14-A668-4751-AEBF-0BB97641C432}"
- ProjectSection(SolutionItems) = preProject
- Directory.Build.props = Directory.Build.props
- README.md = README.md
- .editorconfig = .editorconfig
- .github\workflows\build.yml = .github\workflows\build.yml
- version.json = version.json
- EndProjectSection
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yllibed.HttpServer.Json", "Yllibed.HttpServer.Json\Yllibed.HttpServer.Json.csproj", "{460C757A-BCD7-4EA1-91DA-3EE252F39C83}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yllibed.HttpServer.Json.Tests", "Yllibed.HttpServer.Json.Tests\Yllibed.HttpServer.Json.Tests.csproj", "{5B5E5C0C-3590-4A4A-821E-9D83D9F1B628}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {8D21CD92-C0E8-478A-867C-2D07A02879D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {8D21CD92-C0E8-478A-867C-2D07A02879D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {8D21CD92-C0E8-478A-867C-2D07A02879D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {8D21CD92-C0E8-478A-867C-2D07A02879D5}.Release|Any CPU.Build.0 = Release|Any CPU
- {D558F35B-2335-4DA1-BC07-338EEA7EA4AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D558F35B-2335-4DA1-BC07-338EEA7EA4AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D558F35B-2335-4DA1-BC07-338EEA7EA4AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D558F35B-2335-4DA1-BC07-338EEA7EA4AF}.Release|Any CPU.Build.0 = Release|Any CPU
- {460C757A-BCD7-4EA1-91DA-3EE252F39C83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {460C757A-BCD7-4EA1-91DA-3EE252F39C83}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {460C757A-BCD7-4EA1-91DA-3EE252F39C83}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {460C757A-BCD7-4EA1-91DA-3EE252F39C83}.Release|Any CPU.Build.0 = Release|Any CPU
- {5B5E5C0C-3590-4A4A-821E-9D83D9F1B628}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {5B5E5C0C-3590-4A4A-821E-9D83D9F1B628}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {5B5E5C0C-3590-4A4A-821E-9D83D9F1B628}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {5B5E5C0C-3590-4A4A-821E-9D83D9F1B628}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
-EndGlobal
diff --git a/Yllibed.HttpServer.slnx b/Yllibed.HttpServer.slnx
new file mode 100644
index 0000000..a832032
--- /dev/null
+++ b/Yllibed.HttpServer.slnx
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Yllibed.HttpServer/Extensions/Disposable.cs b/Yllibed.HttpServer/Extensions/Disposable.cs
index 9f2944a..2ef964e 100644
--- a/Yllibed.HttpServer/Extensions/Disposable.cs
+++ b/Yllibed.HttpServer/Extensions/Disposable.cs
@@ -1,5 +1,3 @@
-using System;
-
namespace Yllibed.HttpServer.Extensions;
internal static class Disposable
diff --git a/Yllibed.HttpServer/Extensions/HttpServerRequestAcceptExtensions.cs b/Yllibed.HttpServer/Extensions/HttpServerRequestAcceptExtensions.cs
new file mode 100644
index 0000000..d42185a
--- /dev/null
+++ b/Yllibed.HttpServer/Extensions/HttpServerRequestAcceptExtensions.cs
@@ -0,0 +1,14 @@
+using System;
+using Yllibed.HttpServer.Helpers;
+#pragma warning disable MA0001
+
+namespace Yllibed.HttpServer.Extensions;
+
+public static class HttpServerRequestAcceptExtensions
+{
+ ///
+ /// Validates if the request's Accept header allows the specified media type.
+ ///
+ public static bool ValidateAccept(this IHttpServerRequest request, string mediaType)
+ => AcceptHeaderHelper.IsAccepted(request.Accept, mediaType);
+}
diff --git a/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs b/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs
index b82cf7d..3d301f5 100644
--- a/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs
+++ b/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs
@@ -1,4 +1,3 @@
-using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
diff --git a/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs b/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs
index 4bf551c..01b0534 100644
--- a/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs
+++ b/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs
@@ -1,6 +1,5 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
+using System.Globalization;
+using System.Runtime.CompilerServices;
namespace Yllibed.HttpServer.Extensions;
@@ -8,11 +7,21 @@ namespace Yllibed.HttpServer.Extensions;
internal static class TextWriterExtensions
{
- public static Task WriteFormattedLineAsync(this TextWriter writer, FormattableString str) => writer.WriteLineAsync(str.ToString(writer.FormatProvider));
+ public static Task WriteFormattedLineAsync(this TextWriter writer, FormattableString str) => writer.WriteLineAsync(str.ToString(CultureInfo.InvariantCulture));
- public static Task WriteFormattedAsync(this TextWriter writer, FormattableString str) => writer.WriteAsync(str.ToString(writer.FormatProvider));
+ public static Task WriteFormattedAsync(this TextWriter writer, FormattableString str) => writer.WriteAsync(str.ToString(CultureInfo.InvariantCulture));
- public static void WriteFormattedLine(this TextWriter writer, FormattableString str) => writer.WriteLine(str.ToString(writer.FormatProvider));
+ public static void WriteFormattedLine(this TextWriter writer, FormattableString str) => writer.WriteLine(str.ToString(CultureInfo.InvariantCulture));
- public static void WriteFormatted(this TextWriter writer, FormattableString str) => writer.Write(str.ToString(writer.FormatProvider));
+ public static void WriteFormatted(this TextWriter writer, FormattableString str) => writer.Write(str.ToString(CultureInfo.InvariantCulture));
+
+#if NETSTANDARD2_0
+#pragma warning disable MA0040
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Task FlushAsync(this TextWriter writer, CancellationToken ct = default) => writer.FlushAsync();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Task ReadLineAsync(this TextReader reader, CancellationToken ct = default) => reader.ReadLineAsync();
+#endif
}
diff --git a/Yllibed.HttpServer/GlobalUsings.cs b/Yllibed.HttpServer/GlobalUsings.cs
new file mode 100644
index 0000000..5d894fe
--- /dev/null
+++ b/Yllibed.HttpServer/GlobalUsings.cs
@@ -0,0 +1,7 @@
+global using Microsoft.Extensions.Logging;
+global using System;
+global using System.IO;
+global using System.Text;
+global using System.Threading;
+global using System.Threading.Tasks;
+global using Yllibed.HttpServer.Extensions;
diff --git a/Yllibed.HttpServer/Handlers/IHttpHandler.cs b/Yllibed.HttpServer/Handlers/IHttpHandler.cs
index 26d80b7..aeae446 100644
--- a/Yllibed.HttpServer/Handlers/IHttpHandler.cs
+++ b/Yllibed.HttpServer/Handlers/IHttpHandler.cs
@@ -1,11 +1,7 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-
namespace Yllibed.HttpServer.Handlers;
///
-/// This is the interface who should be implemented by a Http Server Handler.
+/// This is the interface that should be implemented by a Http Server Handler.
///
///
/// The handler should check if the request is "interesting" and produce a result.
diff --git a/Yllibed.HttpServer/Handlers/RelativePathHandler.cs b/Yllibed.HttpServer/Handlers/RelativePathHandler.cs
index b847c2d..e33b501 100644
--- a/Yllibed.HttpServer/Handlers/RelativePathHandler.cs
+++ b/Yllibed.HttpServer/Handlers/RelativePathHandler.cs
@@ -1,9 +1,5 @@
-using System;
-using System.Collections.Immutable;
+using System.Collections.Immutable;
using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Yllibed.HttpServer.Extensions;
namespace Yllibed.HttpServer.Handlers;
@@ -29,7 +25,7 @@ public RelativePathHandler(string path)
private ImmutableList _handlers = ImmutableList.Empty;
///
- /// Create a handler for this relative path
+ /// Create a handler for this relative path
///
///
/// Disposing the return value will remove the unregister the handler.
@@ -60,7 +56,7 @@ async Task IHttpHandler.HandleRequest(CancellationToken ct, IHttpServerRequest r
if (relativePath.StartsWith(_path, StringComparison.OrdinalIgnoreCase))
{
- var subPath = relativePath.Substring(_path.Length);
+ var subPath = relativePath[_path.Length..];
if (!subPath.StartsWith("/", StringComparison.Ordinal))
{
subPath = "/" + subPath;
diff --git a/Yllibed.HttpServer/Handlers/SseHandler.cs b/Yllibed.HttpServer/Handlers/SseHandler.cs
new file mode 100644
index 0000000..433a704
--- /dev/null
+++ b/Yllibed.HttpServer/Handlers/SseHandler.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using Yllibed.HttpServer.Sse;
+
+namespace Yllibed.HttpServer.Handlers;
+
+///
+/// Base handler for Server-Sent Events (SSE) endpoints.
+/// Implement and optionally override , , .
+///
+public abstract class SseHandler : IHttpHandler
+{
+ ///
+ /// Determines whether this handler should take ownership of the request and start an SSE session.
+ /// Default filters to GET requests only.
+ ///
+ protected virtual bool ShouldHandle(IHttpServerRequest request, string relativePath)
+ => string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Validates the request's headers. Default checks that the Accept header allows "text/event-stream" (via Accept negotiation).
+ /// Override this to customize Accept/content-type validation; ShouldHandle is intended for method/path filtering.
+ ///
+ protected virtual bool ValidateHeaders(IHttpServerRequest request) => request.ValidateAccept("text/event-stream");
+
+ ///
+ /// Optional extra headers for the SSE response (Content-Type and Connection are controlled; Cache-Control: no-cache is added unless overridden).
+ ///
+ protected virtual IReadOnlyDictionary>? GetHeaders(IHttpServerRequest request, string relativePath) => null;
+
+ ///
+ /// Optional SSE options (auto-heartbeat, auto-flush, etc.).
+ ///
+ protected virtual SseOptions? GetOptions(IHttpServerRequest request, string relativePath) => null;
+
+ ///
+ /// Implement the logic of your SSE session here. Use to send events/comments.
+ ///
+ protected abstract Task HandleSseSession(ISseSession sse, CancellationToken ct);
+
+ public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath)
+ {
+ if (!ShouldHandle(request, relativePath))
+ {
+ return Task.CompletedTask;
+ }
+
+ if (!ValidateHeaders(request))
+ {
+ request.SetResponse("text/plain", "Not Acceptable", 406, "Not Acceptable");
+ return Task.CompletedTask;
+ }
+
+ request.StartSseSession(HandleSseSession, headers: GetHeaders(request, relativePath), options: GetOptions(request, relativePath));
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Yllibed.HttpServer/Handlers/StaticHandler.cs b/Yllibed.HttpServer/Handlers/StaticHandler.cs
index bfb863b..9d7df78 100644
--- a/Yllibed.HttpServer/Handlers/StaticHandler.cs
+++ b/Yllibed.HttpServer/Handlers/StaticHandler.cs
@@ -1,10 +1,4 @@
-using System;
-using System.IO;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-
-#pragma warning disable 1998
+#pragma warning disable 1998
namespace Yllibed.HttpServer.Handlers;
diff --git a/Yllibed.HttpServer/Helpers/AcceptHeaderHelper.cs b/Yllibed.HttpServer/Helpers/AcceptHeaderHelper.cs
new file mode 100644
index 0000000..a14db0d
--- /dev/null
+++ b/Yllibed.HttpServer/Helpers/AcceptHeaderHelper.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Globalization;
+
+namespace Yllibed.HttpServer.Helpers;
+
+internal static class AcceptHeaderHelper
+{
+ ///
+ /// Validates if the provided Accept header allows the specified media type.
+ ///
+ ///
+ /// Accepts if:
+ /// - Accept header is missing or empty;
+ /// - */* is present with q>0;
+ /// - type/* matches the media type's type with q>0;
+ /// - exact media type matches (case-insensitive) with q>0.
+ /// q-parameter default is 1 when omitted; q=0 means "not acceptable" for that media range.
+ ///
+ /// Specification reference:
+ /// - RFC 7231 section 5.3.2 (Accept): https://tools.ietf.org/html/rfc7231#section-5.3.2
+ /// - RFC 7231 section 5.3.1 (Quality Values): https://tools.ietf.org/html/rfc7231#section-5.3.1
+ ///
+ public static bool IsAccepted(string? acceptHeader, string mediaType)
+ {
+ if (string.IsNullOrWhiteSpace(mediaType))
+ {
+ throw new ArgumentNullException(nameof(mediaType));
+ }
+
+ if (string.IsNullOrWhiteSpace(acceptHeader))
+ {
+ return true; // no constraint
+ }
+
+ // Split mediaType into type/subtype (manual scan to avoid analyzer complaints)
+ var slashIdx = -1;
+ for (var i = 0; i < mediaType.Length; i++)
+ {
+ if (mediaType[i] == '/')
+ {
+ slashIdx = i; break;
+ }
+ }
+ var typePart = slashIdx > 0 ? mediaType[..slashIdx] : mediaType;
+
+ var header = acceptHeader ?? string.Empty;
+ foreach (var part in header.Split(','))
+ {
+ var token = part.Trim();
+ if (token.Length == 0) continue;
+
+ // Parse parameters (e.g., ;q=0.9)
+ var q = 1.0; // default quality
+ var mediaRange = token;
+ var semi = -1;
+ for (var i = 0; i < token.Length; i++)
+ {
+ if (token[i] == ';')
+ {
+ semi = i; break;
+ }
+ }
+ if (semi >= 0)
+ {
+ mediaRange = token[..semi].Trim();
+ var paramsPart = token[(semi + 1)..];
+ foreach (var pv in paramsPart.Split(';'))
+ {
+ var p = pv.Trim();
+ if (p.Length == 0) continue;
+ var eq = -1;
+ for (var j = 0; j < p.Length; j++)
+ {
+ if (p[j] == '=')
+ {
+ eq = j; break;
+ }
+ }
+ if (eq <= 0) continue;
+ var name = p[..eq].Trim();
+ var value = p[(eq + 1)..].Trim();
+ if (name.Equals("q", StringComparison.OrdinalIgnoreCase))
+ {
+ if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var qv))
+ {
+ q = qv;
+ }
+ }
+ }
+ }
+
+ if (q <= 0)
+ {
+ continue; // not acceptable for this range
+ }
+
+ if (mediaRange.Equals("*/*", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ // Find slash in mediaRange
+ var slash = -1;
+ for (var i = 0; i < mediaRange.Length; i++)
+ {
+ if (mediaRange[i] == '/')
+ {
+ slash = i; break;
+ }
+ }
+ if (slash > 0)
+ {
+ var t = mediaRange[..slash];
+ var s = mediaRange[(slash + 1)..];
+ if (s.Equals("*", StringComparison.Ordinal))
+ {
+ if (t.Equals(typePart, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+
+ if (mediaRange.Equals(mediaType, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Yllibed.HttpServer/IHttpServer.cs b/Yllibed.HttpServer/IHttpServer.cs
index 17322cf..875f434 100644
--- a/Yllibed.HttpServer/IHttpServer.cs
+++ b/Yllibed.HttpServer/IHttpServer.cs
@@ -1,6 +1,3 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
using Yllibed.HttpServer.Handlers;
namespace Yllibed.HttpServer;
diff --git a/Yllibed.HttpServer/IHttpServerRequest.cs b/Yllibed.HttpServer/IHttpServerRequest.cs
index a09874d..06e9cc5 100644
--- a/Yllibed.HttpServer/IHttpServerRequest.cs
+++ b/Yllibed.HttpServer/IHttpServerRequest.cs
@@ -1,9 +1,5 @@
-using System;
using System.Collections.Generic;
using System.Collections.Immutable;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
namespace Yllibed.HttpServer;
@@ -109,7 +105,7 @@ public interface IHttpServerRequest
/// See RFC 7231 section 5.3.2 for more details. https://tools.ietf.org/html/rfc7231#section-5.3.2
///
string? Accept { get; }
-
+
IReadOnlyDictionary>? Headers { get; }
///
@@ -139,4 +135,19 @@ void SetResponse(
uint resultCode = 200,
string resultText = "OK",
IReadOnlyDictionary>? headers = null);
+
+ ///
+ /// Set a streaming response. No Content-Length is sent; the server streams until the writer completes.
+ ///
+ /// Response content-type.
+ /// A callback that writes to the response TextWriter.
+ /// HTTP status code (default 200).
+ /// HTTP reason phrase (default OK).
+ /// Optional additional headers (Content-Type and Connection are controlled by the server).
+ void SetStreamingResponse(
+ string contentType,
+ Func writer,
+ uint resultCode = 200,
+ string resultText = "OK",
+ IReadOnlyDictionary>? headers = null);
}
diff --git a/Yllibed.HttpServer/Logging/DefaultLogger.cs b/Yllibed.HttpServer/Logging/DefaultLogger.cs
index ccbac20..69b8c85 100644
--- a/Yllibed.HttpServer/Logging/DefaultLogger.cs
+++ b/Yllibed.HttpServer/Logging/DefaultLogger.cs
@@ -1,6 +1,4 @@
-using Microsoft.Extensions.Logging;
-
-namespace Yllibed.Framework.Logging;
+namespace Yllibed.Framework.Logging;
public static class DefaultLogger
{
diff --git a/Yllibed.HttpServer/Logging/LogExtensions.cs b/Yllibed.HttpServer/Logging/LogExtensions.cs
index 40d804a..2fda9ae 100644
--- a/Yllibed.HttpServer/Logging/LogExtensions.cs
+++ b/Yllibed.HttpServer/Logging/LogExtensions.cs
@@ -1,6 +1,4 @@
-using Microsoft.Extensions.Logging;
-
-namespace Yllibed.Framework.Logging;
+namespace Yllibed.Framework.Logging;
public static class LogExtensions
{
diff --git a/Yllibed.HttpServer/README.md b/Yllibed.HttpServer/README.md
index 28997f0..c47959d 100644
--- a/Yllibed.HttpServer/README.md
+++ b/Yllibed.HttpServer/README.md
@@ -1,6 +1,16 @@
# Yllibed Http Server
-A versatile, lightweight HTTP server for .NET applications. Self-contained with no dependencies on ASP.NET or other frameworks.
+A versatile, lightweight HTTP server for .NET applications. Self-contained with no ASP.NET dependency. Ideal for tools, local services, test harnesses, IoT and desktop apps.
+
+## Features
+- Single-assembly, minimal footprint, no external web framework
+- Plug-in handler model: register one or many handlers; first to respond wins
+- IPv4 and IPv6 support; returns both URIs from Start()
+- Dynamic port assignment (Port = 0) to avoid conflicts (recommended)
+- Microsoft.Extensions.DependencyInjection integration and IOptions
+- Server-Sent Events (SSE) helper base class for real-time event streams
+- Static content responses and simple REST-style endpoints
+- Runs on .NET (see package TargetFrameworks)
## Quick Start
@@ -27,16 +37,16 @@ var (uri4, uri6) = server.Start();
## Configuration
-**Dynamic Port Assignment (Recommended):** Using port `0` automatically assigns an available port, preventing conflicts—perfect for testing, microservices, and team development.
+Dynamic port assignment (recommended): using port 0 automatically selects a free TCP port, preventing conflicts—perfect for tests, parallel runs and local tools.
```csharp
// ✅ Recommended approach
-var server = new Server(); // Dynamic port
+var server = new Server(); // Port 0 by default
var (uri4, uri6) = server.Start();
-var actualPort = new Uri(uri4).Port; // Get the assigned port
+var actualPort = new Uri(uri4).Port; // Discover the assigned port
```
-For advanced configuration, use `ServerOptions`:
+For advanced configuration, use ServerOptions:
```csharp
var serverOptions = new ServerOptions
@@ -69,9 +79,51 @@ services.Configure(opts => { opts.Port = 0; });
services.AddSingleton(); // Auto-selects IOptions<> constructor
```
+## Handlers and Routing
+- Handlers are small classes implementing IHttpHandler. You can register multiple handlers; they are queried in order and the first one to produce a response wins.
+- Use RelativePathHandler to compose simple routing trees under a base path.
+
+Example:
+```csharp
+var server = new Server();
+var api = new RelativePathHandler("/api");
+api.RegisterHandler(new StaticHandler("/ping", "text/plain", "pong"));
+server.RegisterHandler(api);
+server.Start();
+```
+
+## Server-Sent Events (SSE)
+Stream real-time events over HTTP using the SseHandler base class or StartSseSession extension.
+
+```csharp
+public sealed class MySseHandler : SseHandler
+{
+ protected override bool ShouldHandle(IHttpServerRequest req, string path)
+ => base.ShouldHandle(req, path) && path == "/sse";
+
+ protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
+ {
+ for (var i = 0; i < 5 && !ct.IsCancellationRequested; i++)
+ {
+ await sse.SendEventAsync($"tick {i}", eventName: "tick", id: i.ToString(), ct: ct);
+ await Task.Delay(1000, ct);
+ }
+ }
+}
+
+var root = new RelativePathHandler("/");
+root.RegisterHandler(new MySseHandler());
+server.RegisterHandler(root);
+```
+
+## Design goals
+- Keep things tiny and dependency-free
+- Prefer clarity over features; you own the control flow in your handlers
+- Make local and internal scenarios painless (dynamic ports, simple DI)
+
## Limitations
* HTTP/1.1 only (no HTTP/2+ or WebSockets)
* No HTTPS/TLS support
* Designed for small-scale applications
-For more examples and advanced usage, visit the [GitHub repository](https://github.com/carldebilly/Yllibed.HttpServer).
+For more examples and advanced usage, visit the GitHub repository: https://github.com/carldebilly/Yllibed.HttpServer
diff --git a/Yllibed.HttpServer/Server.HttpServerRequest.cs b/Yllibed.HttpServer/Server.HttpServerRequest.cs
index 3355bf8..d8c6d1d 100644
--- a/Yllibed.HttpServer/Server.HttpServerRequest.cs
+++ b/Yllibed.HttpServer/Server.HttpServerRequest.cs
@@ -1,18 +1,11 @@
-using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net.Sockets;
-using System.Text;
using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
using Yllibed.Framework.Logging;
-using Yllibed.HttpServer.Extensions;
-
-#pragma warning disable MA0040 // Don't force using ct (netstd2.0 limitations)
+using Yllibed.HttpServer.Sse;
namespace Yllibed.HttpServer;
@@ -64,7 +57,7 @@ private async Task ProcessConnection(CancellationToken ct)
this.Log().LogInformation("Response for url {Url}: {Code} {ResultText}", Url, _responseResultCode, _responseResultText);
- await ProcessResponse(ct, stream).ConfigureAwait(true);
+ await ProcessResponse(stream, ct).ConfigureAwait(true);
}
}
catch (Exception ex)
@@ -88,7 +81,7 @@ private async Task ProcessRequest(CancellationToken ct, Stream stream)
var encoding = GetRequestEncoding();
using var requestReader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, (int)BufferSize, leaveOpen: true);
- var requestLine = await requestReader.ReadLineAsync().ConfigureAwait(true);
+ var requestLine = await requestReader.ReadLineAsync(ct).ConfigureAwait(true);
var requestLineParts = requestLine?.Split(' ');
if (requestLineParts is not { Length: 3 })
{
@@ -104,7 +97,7 @@ private async Task ProcessRequest(CancellationToken ct, Stream stream)
var requestHeaders = new Dictionary>(StringComparer.OrdinalIgnoreCase);
- var headerLine = await requestReader.ReadLineAsync().ConfigureAwait(true);
+ var headerLine = await requestReader.ReadLineAsync(ct).ConfigureAwait(true);
while (headerLine is { Length: > 0 } && !ct.IsCancellationRequested)
{
var (header, value) = ParseHeader(headerLine);
@@ -121,7 +114,7 @@ private async Task ProcessRequest(CancellationToken ct, Stream stream)
}
}
- headerLine = await requestReader.ReadLineAsync().ConfigureAwait(true);
+ headerLine = await requestReader.ReadLineAsync(ct).ConfigureAwait(true);
}
Headers = requestHeaders;
@@ -159,23 +152,23 @@ private Encoding GetRequestEncoding()
return Utf8; // default value if an error or not specified
}
- private async Task ProcessResponse(CancellationToken ct, NetworkStream stream)
+ private async Task ProcessResponse(NetworkStream stream, CancellationToken ct)
{
using var responseWriter = new StreamWriter(stream, Utf8, (int)BufferSize, leaveOpen: true);
responseWriter.NewLine = "\r\n";
- await ProcessResponseHeader(responseWriter).ConfigureAwait(true);
+ await ProcessResponseHeader(responseWriter, ct).ConfigureAwait(true);
- await responseWriter.FlushAsync().ConfigureAwait(true);
+ await responseWriter.FlushAsync(ct).ConfigureAwait(true);
await ProcessResponsePayload(ct, responseWriter, stream).ConfigureAwait(true);
}
- private async Task ProcessResponseHeader(TextWriter responseWriter)
+ private async Task ProcessResponseHeader(TextWriter responseWriter, CancellationToken ct)
{
// Response Line
await responseWriter.WriteFormattedLineAsync($"HTTP/1.1 {_responseResultCode} {_responseResultText}").ConfigureAwait(true);
- await responseWriter.FlushAsync().ConfigureAwait(true);
+ await responseWriter.FlushAsync(ct).ConfigureAwait(true);
// Content-Type
await responseWriter.WriteFormattedLineAsync($"Content-Type: {_responseContentType}").ConfigureAwait(true);
@@ -208,17 +201,32 @@ private async Task ProcessResponseHeader(TextWriter responseWriter)
private async Task ProcessResponsePayload(CancellationToken ct, TextWriter responseWriter, Stream responseStream)
{
- if (_responseStreamFactory != null)
+ if (_responseStreamingWriter != null)
+ {
+ // Streaming mode: no Content-Length. End headers and stream body progressively.
+ // HTTP/1.1 message delimitation is connection-close (no chunked encoding here),
+ // which is valid per RFC 7230 §3.3.3 (obsoleted by RFC 9112 §6.1).
+ await responseWriter.WriteLineAsync().ConfigureAwait(false); // end of headers
+ await responseWriter.FlushAsync(ct).ConfigureAwait(false);
+
+ using (var bodyWriter = new StreamWriter(responseStream, Utf8, (int)BufferSize, leaveOpen: true))
+ {
+ bodyWriter.NewLine = "\n"; // SSE commonly uses LF
+ await _responseStreamingWriter(bodyWriter, ct).ConfigureAwait(false);
+ await bodyWriter.FlushAsync(ct).ConfigureAwait(false);
+ }
+ }
+ else if (_responseStreamFactory != null)
{
using (var streamToSend = await _responseStreamFactory(ct).ConfigureAwait(true))
{
// Content-Length header
await responseWriter.WriteFormattedLineAsync($"Content-Length: {streamToSend.Length}").ConfigureAwait(true);
await responseWriter.WriteLineAsync().ConfigureAwait(true);
-
+
// Ensure header is flushed before writing to inner stream directly
- await responseWriter.FlushAsync().ConfigureAwait(true);
-
+ await responseWriter.FlushAsync(ct).ConfigureAwait(true);
+
// Write the stream content to inner stream
await streamToSend.CopyToAsync(responseStream, 2048, ct).ConfigureAwait(false);
}
@@ -226,14 +234,14 @@ private async Task ProcessResponsePayload(CancellationToken ct, TextWriter respo
else
{
var bytes = Utf8.GetBytes(_responseContent ?? string.Empty);
-
+
// Content-Length header
await responseWriter.WriteFormattedLineAsync($"Content-Length: {bytes.Length}").ConfigureAwait(false);
await responseWriter.WriteLineAsync().ConfigureAwait(false);
-
+
// Ensure header is flushed before writing to inner stream directly
- await responseWriter.FlushAsync().ConfigureAwait(false);
-
+ await responseWriter.FlushAsync(ct).ConfigureAwait(false);
+
// Write the stream content to inner stream
await responseStream.WriteAsync(bytes, 0, bytes.Length, ct).ConfigureAwait(false);
}
@@ -377,10 +385,28 @@ public void SetResponse(
IsResponseSet = true;
}
+ public void SetStreamingResponse(
+ string contentType,
+ Func writer,
+ uint resultCode = 200,
+ string resultText = "OK",
+ IReadOnlyDictionary>? headers = null)
+ {
+ _responseContentType = contentType;
+ _responseResultCode = resultCode;
+ _responseResultText = resultText;
+ _responseStreamingWriter = writer;
+ _responseHeaders = headers;
+
+ IsResponseSet = true;
+ }
+
+
private string? _responseContentType;
private uint? _responseResultCode;
private string? _responseResultText;
private Func>? _responseStreamFactory;
+ private Func? _responseStreamingWriter;
private string? _responseContent;
private IReadOnlyDictionary>? _responseHeaders;
private Uri? _url;
diff --git a/Yllibed.HttpServer/Server.cs b/Yllibed.HttpServer/Server.cs
index 2c41459..4346392 100644
--- a/Yllibed.HttpServer/Server.cs
+++ b/Yllibed.HttpServer/Server.cs
@@ -1,13 +1,7 @@
-using System;
-using System.Collections.Immutable;
+using System.Collections.Immutable;
using System.Net;
using System.Net.Sockets;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
using Yllibed.Framework.Logging;
-using Yllibed.HttpServer.Extensions;
using Yllibed.HttpServer.Handlers;
#pragma warning disable MA0040 // Don't force using ct (netstd2.0 limitations)
diff --git a/Yllibed.HttpServer/Sse/HttpServerRequestSseExtensions.cs b/Yllibed.HttpServer/Sse/HttpServerRequestSseExtensions.cs
new file mode 100644
index 0000000..5674b8b
--- /dev/null
+++ b/Yllibed.HttpServer/Sse/HttpServerRequestSseExtensions.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Yllibed.HttpServer.Sse;
+
+public static class HttpServerRequestSseExtensions
+{
+ ///
+ /// Starts a Server-Sent Events (SSE) session and passes an to your handler lambda.
+ ///
+ ///
+ /// SSE framing per WHATWG HTML Living Standard.
+ /// HTTP body delimitation: this server uses close-delimited messages (Connection: close) which is valid in HTTP/1.1
+ /// as per RFC 7230 §3.3.3 (superseded by RFC 9112 §6.1). No chunked encoding is used.
+ ///
+ public static void StartSseSession(
+ this IHttpServerRequest request,
+ Func sessionHandler,
+ uint resultCode = 200,
+ string resultText = "OK",
+ IReadOnlyDictionary>? headers = null,
+ SseOptions? options = null)
+ {
+ if (request is null) throw new ArgumentNullException(nameof(request));
+ if (sessionHandler is null) throw new ArgumentNullException(nameof(sessionHandler));
+
+ var effectiveHeaders = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ if (headers != null)
+ {
+ foreach (var kvp in headers)
+ {
+ effectiveHeaders[kvp.Key] = kvp.Value;
+ }
+ }
+ if (!effectiveHeaders.ContainsKey("Cache-Control"))
+ {
+ effectiveHeaders["Cache-Control"] = new[] { "no-cache" };
+ }
+
+ // Always set text/event-stream for SSE
+ var contentType = "text/event-stream";
+ request.SetStreamingResponse(contentType, async (writer, ct) =>
+ {
+ // Per SSE best practices, use LF line endings; our response writer already writes CRLF for headers.
+ writer.NewLine = "\n";
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ var session = new SseSession(request, writer, linkedCts, autoFlush: options?.AutoFlush ?? true);
+ var heartbeatTask = Task.CompletedTask;
+ var hbInterval = options?.HeartbeatInterval ?? TimeSpan.Zero;
+ if (hbInterval > TimeSpan.Zero)
+ {
+ heartbeatTask = Task.Run(async () =>
+ {
+ try
+ {
+ while (!linkedCts.IsCancellationRequested)
+ {
+ await Task.Delay(hbInterval, linkedCts.Token).ConfigureAwait(false);
+ if (linkedCts.IsCancellationRequested) break;
+ await session.SendCommentAsync(options?.HeartbeatComment ?? "keepalive", linkedCts.Token).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // normal on shutdown
+ }
+ }, linkedCts.Token);
+ }
+
+ try
+ {
+ await sessionHandler(session, linkedCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // client or server requested cancellation – close stream gracefully
+ }
+ catch (IOException)
+ {
+ // network failure – close stream gracefully
+ }
+ catch (ObjectDisposedException)
+ {
+ // connection disposed – close stream gracefully
+ }
+ finally
+ {
+ // Stop heartbeat and wait for it to complete
+ linkedCts.Cancel();
+ try { await heartbeatTask.ConfigureAwait(false); } catch { /* ignore */ }
+ }
+ }, resultCode, resultText, effectiveHeaders);
+ }
+}
diff --git a/Yllibed.HttpServer/Sse/ISseSession.cs b/Yllibed.HttpServer/Sse/ISseSession.cs
new file mode 100644
index 0000000..f05e173
--- /dev/null
+++ b/Yllibed.HttpServer/Sse/ISseSession.cs
@@ -0,0 +1,41 @@
+namespace Yllibed.HttpServer.Sse;
+
+///
+/// Represents an active SSE session used by application handlers to emit events.
+///
+///
+/// Framing per WHATWG HTML Living Standard (Server-Sent Events):
+/// https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
+///
+public interface ISseSession
+{
+ /// Original HTTP request that initiated this SSE session.
+ IHttpServerRequest Request { get; }
+
+ /// Indicates whether the session is still considered connected (based on cancellation state).
+ bool IsConnected { get; }
+
+ ///
+ /// The SSE Last-Event-ID header value sent by the client for this session, if any.
+ ///
+ string? LastEventId { get; }
+
+ ///
+ /// Sends an event to the client.
+ ///
+ Task SendEventAsync(string data, string? eventName = null, string? id = null, CancellationToken ct = default);
+
+ ///
+ /// Sends a comment frame to the client.
+ ///
+ ///
+ /// Comments are not processed by the client, they are mostly used to keep the connection alive or to
+ /// help developers to debug their application.
+ ///
+ Task SendCommentAsync(string comment, CancellationToken ct = default);
+
+ ///
+ /// Flushes the output buffer to the client.
+ ///
+ Task FlushAsync(CancellationToken ct = default);
+}
diff --git a/Yllibed.HttpServer/Sse/SseHelper.cs b/Yllibed.HttpServer/Sse/SseHelper.cs
new file mode 100644
index 0000000..9467e98
--- /dev/null
+++ b/Yllibed.HttpServer/Sse/SseHelper.cs
@@ -0,0 +1,83 @@
+namespace Yllibed.HttpServer.Sse;
+
+#pragma warning disable MA0001 // Use an overload of 'Replace' that has a StringComparison parameter
+
+///
+/// Utilities to format and write Server-Sent Events (SSE) frames.
+/// See WHATWG HTML LS: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
+///
+internal static class SseHelper
+{
+ ///
+ /// Writes a single SSE event to the provided writer.
+ ///
+ /// The response writer (its NewLine should generally be "\n").
+ /// The event payload (can be multi-line; each line will be prefixed with "data:").
+ /// Optional event name ("event:").
+ /// Optional event id ("id:").
+ /// Cancellation token (checked before/after writes).
+ public static async Task WriteEvent(TextWriter writer, string data, string? eventName = null, string? id = null, CancellationToken ct = default)
+ {
+ if (writer is null) throw new ArgumentNullException(nameof(writer));
+
+ ct.ThrowIfCancellationRequested();
+
+ if (!string.IsNullOrEmpty(id))
+ {
+ await writer.WriteLineAsync($"id: {id}").ConfigureAwait(false);
+ }
+ if (!string.IsNullOrEmpty(eventName))
+ {
+ await writer.WriteLineAsync($"event: {eventName}").ConfigureAwait(false);
+ }
+
+ if (data is null)
+ {
+ data = string.Empty;
+ }
+
+ // Write each line prefixed with "data: "
+ var sb = new StringBuilder(data.Length);
+ for (var idx = 0; idx < data.Length; idx++)
+ {
+ var ch = data[idx];
+ if (ch == '\r')
+ {
+ if (idx + 1 < data.Length && data[idx + 1] == '\n')
+ {
+ continue; // skip the '\r' of CRLF
+ }
+
+ sb.Append('\n');
+ }
+ else
+ {
+ sb.Append(ch);
+ }
+ }
+
+ var normalized = sb.ToString();
+ var parts = normalized.Split('\n');
+ foreach (var line in parts)
+ {
+ await writer.WriteLineAsync($"data: {line}").ConfigureAwait(false);
+ }
+
+ // Terminate the event with a blank line
+ await writer.WriteLineAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Writes a comment heartbeat frame. Many proxies/timeouts are avoided by sending these periodically.
+ ///
+ /// The response writer.
+ /// Comment text after the colon (no semantics to client).
+ /// Cancellation token (checked before/after writes).
+ public static async Task WriteComment(TextWriter writer, string comment = "keepalive", CancellationToken ct = default)
+ {
+ if (writer is null) throw new ArgumentNullException(nameof(writer));
+ ct.ThrowIfCancellationRequested();
+ await writer.WriteLineAsync($": {comment}").ConfigureAwait(false);
+ await writer.WriteLineAsync().ConfigureAwait(false);
+ }
+}
diff --git a/Yllibed.HttpServer/Sse/SseOptions.cs b/Yllibed.HttpServer/Sse/SseOptions.cs
new file mode 100644
index 0000000..510f724
--- /dev/null
+++ b/Yllibed.HttpServer/Sse/SseOptions.cs
@@ -0,0 +1,32 @@
+namespace Yllibed.HttpServer.Sse;
+
+///
+/// Options for SSE sessions.
+///
+///
+/// - Heartbeats: While not mandated by spec, periodic comments help keep intermediaries from timing out idle connections.
+/// See WHATWG SSE processing model.
+///
+public sealed class SseOptions
+{
+ ///
+ /// The interval between heartbeat comments.
+ ///
+ ///
+ /// The default value is 45 seconds.
+ ///
+ public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(45);
+
+ ///
+ /// The comment sent in the heartbeat.
+ ///
+ public string HeartbeatComment { get; set; } = "keepalive";
+
+ ///
+ /// If true, the server will automatically flush the output stream after each message.
+ ///
+ ///
+ /// The default value is true.
+ ///
+ public bool AutoFlush { get; set; } = true;
+}
diff --git a/Yllibed.HttpServer/Sse/SseSession.cs b/Yllibed.HttpServer/Sse/SseSession.cs
new file mode 100644
index 0000000..2bfc839
--- /dev/null
+++ b/Yllibed.HttpServer/Sse/SseSession.cs
@@ -0,0 +1,121 @@
+using System.IO;
+
+namespace Yllibed.HttpServer.Sse;
+
+internal sealed class SseSession : ISseSession
+{
+ private readonly TextWriter _writer;
+ private readonly bool _autoFlush;
+ private readonly SemaphoreSlim _mutex = new SemaphoreSlim(1, 1);
+ private readonly CancellationTokenSource _sessionCts;
+ private readonly string? _lastEventId;
+ private CancellationToken SessionToken => _sessionCts.Token;
+ public IHttpServerRequest Request { get; }
+ public bool IsConnected => !_sessionCts.IsCancellationRequested;
+ public string? LastEventId => _lastEventId;
+
+ public SseSession(IHttpServerRequest request, TextWriter writer, CancellationTokenSource sessionCts, bool autoFlush)
+ {
+ Request = request ?? throw new ArgumentNullException(nameof(request));
+ _writer = writer ?? throw new ArgumentNullException(nameof(writer));
+ _sessionCts = sessionCts ?? throw new ArgumentNullException(nameof(sessionCts));
+ _autoFlush = autoFlush;
+
+ // Extract Last-Event-ID from request headers if present
+ try
+ {
+ var headers = Request.Headers;
+ if (headers != null && headers.TryGetValue("Last-Event-ID", out var values))
+ {
+ foreach (var v in values) { _lastEventId = v?.Trim(); break; }
+ }
+ }
+ catch { /* ignore header access issues */ }
+ }
+
+ public async Task SendEventAsync(string data, string? eventName = null, string? id = null, CancellationToken ct = default)
+ {
+ await _mutex.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ try
+ {
+ await SseHelper.WriteEvent(_writer, data, eventName, id, ct).ConfigureAwait(false);
+ if (_autoFlush)
+ {
+ await _writer.FlushAsync(ct).ConfigureAwait(false);
+ }
+ }
+ catch (IOException)
+ {
+ _sessionCts.Cancel();
+ throw new OperationCanceledException(ct.IsCancellationRequested ? ct : SessionToken);
+ }
+ catch (ObjectDisposedException)
+ {
+ _sessionCts.Cancel();
+ throw new OperationCanceledException(ct.IsCancellationRequested ? ct : SessionToken);
+ }
+ }
+ finally
+ {
+ _mutex.Release();
+ }
+ }
+
+ public async Task SendCommentAsync(string comment, CancellationToken ct = default)
+ {
+ await _mutex.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ try
+ {
+ await SseHelper.WriteComment(_writer, comment, ct).ConfigureAwait(false);
+ if (_autoFlush)
+ {
+ await _writer.FlushAsync(ct).ConfigureAwait(false);
+ }
+ }
+ catch (IOException)
+ {
+ _sessionCts.Cancel();
+ throw new OperationCanceledException(ct.IsCancellationRequested ? ct : SessionToken);
+ }
+ catch (ObjectDisposedException)
+ {
+ _sessionCts.Cancel();
+ throw new OperationCanceledException(ct.IsCancellationRequested ? ct : SessionToken);
+ }
+ }
+ finally
+ {
+ _mutex.Release();
+ }
+ }
+
+ public async Task FlushAsync(CancellationToken ct = default)
+ {
+ await _mutex.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ try
+ {
+ await _writer.FlushAsync(ct).ConfigureAwait(false);
+ }
+ catch (IOException)
+ {
+ _sessionCts.Cancel();
+ throw new OperationCanceledException(ct.IsCancellationRequested ? ct : SessionToken);
+ }
+ catch (ObjectDisposedException)
+ {
+ _sessionCts.Cancel();
+ throw new OperationCanceledException(ct.IsCancellationRequested ? ct : SessionToken);
+ }
+ }
+ finally
+ {
+ _mutex.Release();
+ }
+ }
+}
diff --git a/Yllibed.HttpServer/Yllibed.HttpServer.csproj b/Yllibed.HttpServer/Yllibed.HttpServer.csproj
index c07d317..dd47f20 100644
--- a/Yllibed.HttpServer/Yllibed.HttpServer.csproj
+++ b/Yllibed.HttpServer/Yllibed.HttpServer.csproj
@@ -1,23 +1,26 @@
+ Yllibed.HttpServer
+ Yllibed HttpServer
Yllibed HttpServer
- Yllibed Versatile Http Server
+ Yllibed Versatile Http Server. Lightweight, DI-friendly, with SSE support.
+ http server lightweight self-contained sse iot desktop tools diagnostics
true
README.md
- $(InternalsVisibleTo);Yllibed.HttpServer.Json
+ $(InternalsVisibleTo);Yllibed.HttpServer.Json;Yllibed.HttpServer.Tests
-
-
+
+
-
-
+
+
diff --git a/Yllibed.png b/Yllibed.png
new file mode 100644
index 0000000..660f53a
Binary files /dev/null and b/Yllibed.png differ
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..3c5cf51
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "9.0.0",
+ "rollForward": "latestMajor",
+ "allowPrerelease": false
+ }
+}