From a555b0ff191d08b6b29e3e2d965ae88369a36811 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 4 Sep 2025 22:05:00 -0400 Subject: [PATCH 1/2] Added SSE (Server-Sent Events) support --- README.md | 103 ++++++++ Yllibed.HttpServer.Json.Tests/FixtureBase.cs | 3 - Yllibed.HttpServer.Json.Tests/GlobalUsings.cs | 7 + .../JsonHandlerBaseFixture.cs | 5 - .../SseJsonFixture.cs | 53 ++++ .../Yllibed.HttpServer.Json.Tests.csproj | 5 +- Yllibed.HttpServer.Json/SseJsonExtensions.cs | 24 ++ .../AcceptHeaderHelperFixture.cs | 74 ++++++ Yllibed.HttpServer.Tests/DiFixture.cs | 26 +- Yllibed.HttpServer.Tests/FixtureBase.cs | 3 - Yllibed.HttpServer.Tests/GlobalUsings.cs | 4 + Yllibed.HttpServer.Tests/HttpServerFixture.cs | 12 +- .../ServerOptionsFixture.cs | 11 +- Yllibed.HttpServer.Tests/SseFixture.cs | 242 ++++++++++++++++++ .../SseNegotiationFixture.cs | 109 ++++++++ Yllibed.HttpServer.Tests/SseTestClient.cs | 144 +++++++++++ .../StreamingLifecycleFixture.cs | 133 ++++++++++ .../Yllibed.HttpServer.Tests.csproj | 1 + Yllibed.HttpServer/Extensions/Disposable.cs | 2 - .../HttpServerRequestAcceptExtensions.cs | 14 + .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Extensions/TextWriterExtensions.cs | 23 +- Yllibed.HttpServer/GlobalUsings.cs | 7 + Yllibed.HttpServer/Handlers/IHttpHandler.cs | 6 +- .../Handlers/RelativePathHandler.cs | 10 +- Yllibed.HttpServer/Handlers/SseHandler.cs | 52 ++++ Yllibed.HttpServer/Handlers/StaticHandler.cs | 8 +- .../Helpers/AcceptHeaderHelper.cs | 132 ++++++++++ Yllibed.HttpServer/IHttpServer.cs | 3 - Yllibed.HttpServer/IHttpServerRequest.cs | 21 +- Yllibed.HttpServer/Logging/DefaultLogger.cs | 4 +- Yllibed.HttpServer/Logging/LogExtensions.cs | 4 +- .../Server.HttpServerRequest.cs | 76 ++++-- Yllibed.HttpServer/Server.cs | 8 +- .../Sse/HttpServerRequestSseExtensions.cs | 97 +++++++ Yllibed.HttpServer/Sse/ISseSession.cs | 41 +++ Yllibed.HttpServer/Sse/SseHelper.cs | 83 ++++++ Yllibed.HttpServer/Sse/SseOptions.cs | 32 +++ Yllibed.HttpServer/Sse/SseSession.cs | 121 +++++++++ Yllibed.HttpServer/Yllibed.HttpServer.csproj | 2 +- 40 files changed, 1583 insertions(+), 123 deletions(-) create mode 100644 Yllibed.HttpServer.Json.Tests/GlobalUsings.cs create mode 100644 Yllibed.HttpServer.Json.Tests/SseJsonFixture.cs create mode 100644 Yllibed.HttpServer.Json/SseJsonExtensions.cs create mode 100644 Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs create mode 100644 Yllibed.HttpServer.Tests/GlobalUsings.cs create mode 100644 Yllibed.HttpServer.Tests/SseFixture.cs create mode 100644 Yllibed.HttpServer.Tests/SseNegotiationFixture.cs create mode 100644 Yllibed.HttpServer.Tests/SseTestClient.cs create mode 100644 Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs create mode 100644 Yllibed.HttpServer/Extensions/HttpServerRequestAcceptExtensions.cs create mode 100644 Yllibed.HttpServer/GlobalUsings.cs create mode 100644 Yllibed.HttpServer/Handlers/SseHandler.cs create mode 100644 Yllibed.HttpServer/Helpers/AcceptHeaderHelper.cs create mode 100644 Yllibed.HttpServer/Sse/HttpServerRequestSseExtensions.cs create mode 100644 Yllibed.HttpServer/Sse/ISseSession.cs create mode 100644 Yllibed.HttpServer/Sse/SseHelper.cs create mode 100644 Yllibed.HttpServer/Sse/SseOptions.cs create mode 100644 Yllibed.HttpServer/Sse/SseSession.cs diff --git a/README.md b/README.md index bf876cc..8a7e405 100644 --- a/README.md +++ b/README.md @@ -260,3 +260,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("/"); +ssePath.RegisterHandler(new SseDemoHandler()); +server.RegisterHandler(ssePath); +var (uri4, _) = server.Start(); +Console.WriteLine($"SSE endpoint: {uri4}/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 ShouldHandle in your handler. +- 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.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..a944687 --- /dev/null +++ b/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Net; +global using System.Net.Http; +global using System.Threading; +global using FluentAssertions; +global using Microsoft.VisualStudio.TestTools.UnitTesting; +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..6e57dee 100644 --- a/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj +++ b/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj @@ -5,6 +5,7 @@ false False false + enable @@ -29,5 +30,7 @@ + + - \ No newline at end of file + 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.Tests/AcceptHeaderHelperFixture.cs b/Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs new file mode 100644 index 0000000..7eb9142 --- /dev/null +++ b/Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Yllibed.HttpServer; +using Yllibed.HttpServer.Extensions; + +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(); + } + + [DataTestMethod] + // 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..ddc1f1c 100644 --- a/Yllibed.HttpServer.Tests/DiFixture.cs +++ b/Yllibed.HttpServer.Tests/DiFixture.cs @@ -1,8 +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 +17,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 +33,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 +47,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 +63,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 +78,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 +91,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 +110,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 +118,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..8c13986 --- /dev/null +++ b/Yllibed.HttpServer.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Net; +global using FluentAssertions; +global using Microsoft.VisualStudio.TestTools.UnitTesting; +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..7cf49ec --- /dev/null +++ b/Yllibed.HttpServer.Tests/SseNegotiationFixture.cs @@ -0,0 +1,109 @@ +using System.Net.Http; +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..5702ccb --- /dev/null +++ b/Yllibed.HttpServer.Tests/SseTestClient.cs @@ -0,0 +1,144 @@ +#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 cancellationToken = default) + { + _stream ??= await _response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await foreach (var ev in ReadFromStreamAsync(_stream, cancellationToken)) + { + 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); + } + + public static async IAsyncEnumerable ReadFromStreamAsync(Stream stream, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = 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 && !cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken).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..76b7b92 --- /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, 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, 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..5016f24 100644 --- a/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj +++ b/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj @@ -5,6 +5,7 @@ false False false + enable 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..61717fe --- /dev/null +++ b/Yllibed.HttpServer/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.IO; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; +global using Microsoft.Extensions.Logging; +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..26f6990 --- /dev/null +++ b/Yllibed.HttpServer/Handlers/SseHandler.cs @@ -0,0 +1,52 @@ +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); + + /// + /// 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 (!request.ValidateAccept("text/event-stream")) + { + 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/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..15f1610 100644 --- a/Yllibed.HttpServer/Yllibed.HttpServer.csproj +++ b/Yllibed.HttpServer/Yllibed.HttpServer.csproj @@ -6,7 +6,7 @@ true README.md - $(InternalsVisibleTo);Yllibed.HttpServer.Json + $(InternalsVisibleTo);Yllibed.HttpServer.Json;Yllibed.HttpServer.Tests From dcace74c67fd721a3c19b1d6391661cb807ba471 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 6 Sep 2025 00:27:28 -0400 Subject: [PATCH 2/2] Update dependencies and improve documentation. --- .github/workflows/build.yml | 5 +- Directory.Build.props | 26 ++++- Directory.Packages.props | 18 ++++ README.md | 84 +++++++++------- Yllibed-small.png | Bin 0 -> 5783 bytes Yllibed.HttpServer.Json.Tests/GlobalUsings.cs | 4 +- .../Yllibed.HttpServer.Json.Tests.csproj | 24 ++--- Yllibed.HttpServer.Json/README.md | 93 +++++++++++++++++- .../Yllibed.HttpServer.Json.csproj | 11 ++- .../AcceptHeaderHelperFixture.cs | 7 +- Yllibed.HttpServer.Tests/DiFixture.cs | 1 - Yllibed.HttpServer.Tests/GlobalUsings.cs | 6 +- .../SseNegotiationFixture.cs | 1 - Yllibed.HttpServer.Tests/SseTestClient.cs | 15 +-- .../StreamingLifecycleFixture.cs | 10 +- .../Yllibed.HttpServer.Tests.csproj | 26 ++--- Yllibed.HttpServer.sln | 49 --------- Yllibed.HttpServer.slnx | 15 +++ Yllibed.HttpServer/GlobalUsings.cs | 2 +- Yllibed.HttpServer/Handlers/SseHandler.cs | 8 +- Yllibed.HttpServer/README.md | 64 ++++++++++-- Yllibed.HttpServer/Yllibed.HttpServer.csproj | 13 ++- Yllibed.png | Bin 0 -> 128213 bytes global.json | 7 ++ 24 files changed, 323 insertions(+), 166 deletions(-) create mode 100644 Directory.Packages.props create mode 100644 Yllibed-small.png delete mode 100644 Yllibed.HttpServer.sln create mode 100644 Yllibed.HttpServer.slnx create mode 100644 Yllibed.png create mode 100644 global.json 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 8a7e405..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. +![Yllibed logo](Yllibed-small.png) -## 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/) | ![Downloads](https://img.shields.io/nuget/dt/Yllibed.HttpServer?label=Downloads) | ![Stable](https://img.shields.io/nuget/v/Yllibed.HttpServer?label=Stable&labelColor=blue) | ![Pre-release](https://img.shields.io/nuget/vpre/Yllibed.HttpServer?label=Pre-release&labelColor=yellow) | -| [**HttpServer.Json**](https://www.nuget.org/packages/Yllibed.HttpServer.Json/) | ![Downloads](https://img.shields.io/nuget/dt/Yllibed.HttpServer.Json?label=Downloads) | ![Stable](https://img.shields.io/nuget/v/Yllibed.HttpServer.Json?label=Stable&labelColor=blue) | ![Pre-release](https://img.shields.io/nuget/vpre/Yllibed.HttpServer.Json?label=Pre-release&labelColor=yellow) | +- 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/) | ![Downloads](https://img.shields.io/nuget/dt/Yllibed.HttpServer?label=downloads) | ![Stable](https://img.shields.io/nuget/v/Yllibed.HttpServer?label=stable) | ![Pre-release](https://img.shields.io/nuget/vpre/Yllibed.HttpServer?label=pre-release) | +| [Yllibed.HttpServer.Json](https://www.nuget.org/packages/Yllibed.HttpServer.Json/) | ![Downloads](https://img.shields.io/nuget/dt/Yllibed.HttpServer.Json?label=downloads) | ![Stable](https://img.shields.io/nuget/v/Yllibed.HttpServer.Json?label=stable) | ![Pre-release](https://img.shields.io/nuget/vpre/Yllibed.HttpServer.Json?label=pre-release) | + +--- + +## 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: @@ -312,11 +330,11 @@ public sealed class SseDemoHandler : IHttpHandler // Usage during startup var server = new Server(); -var ssePath = new RelativePathHandler("/"); +var ssePath = new RelativePathHandler("/updates"); ssePath.RegisterHandler(new SseDemoHandler()); server.RegisterHandler(ssePath); var (uri4, _) = server.Start(); -Console.WriteLine($"SSE endpoint: {uri4}/sse"); +Console.WriteLine($"SSE endpoint: {uri4}/updates/sse"); ``` SseHandler convenience base class: @@ -348,14 +366,14 @@ Client-side (browser): ``` Notes: -- Heartbeats: send a comment frame (": keepalive\n\n") every 15–30s to prevent proxy timeouts. +- 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 ShouldHandle in your handler. +- 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. diff --git a/Yllibed-small.png b/Yllibed-small.png new file mode 100644 index 0000000000000000000000000000000000000000..50af5fd6fa1d725ae863c02d4f26f9fef8dd364e GIT binary patch literal 5783 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 literal 0 HcmV?d00001 diff --git a/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs b/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs index a944687..14fc03a 100644 --- a/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs +++ b/Yllibed.HttpServer.Json.Tests/GlobalUsings.cs @@ -1,7 +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 FluentAssertions; -global using Microsoft.VisualStudio.TestTools.UnitTesting; global using Yllibed.HttpServer.Sse; diff --git a/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj b/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj index 6e57dee..0d45916 100644 --- a/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj +++ b/Yllibed.HttpServer.Json.Tests/Yllibed.HttpServer.Json.Tests.csproj @@ -1,30 +1,24 @@ - + - net9.0 + net9.0 true - false - False - false enable - - - - - - - - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + 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/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 index 7eb9142..1caca54 100644 --- a/Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs +++ b/Yllibed.HttpServer.Tests/AcceptHeaderHelperFixture.cs @@ -1,8 +1,3 @@ -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Yllibed.HttpServer; -using Yllibed.HttpServer.Extensions; - namespace Yllibed.HttpServer.Tests; [TestClass] @@ -32,7 +27,7 @@ private sealed class FakeRequest : IHttpServerRequest public void SetStreamingResponse(string contentType, Func writer, uint resultCode = 200, string resultText = "OK", IReadOnlyDictionary>? headers = null) => throw new NotSupportedException(); } - [DataTestMethod] + [TestMethod] // No Accept header means no constraint [DataRow(null, "text/html", true)] [DataRow("", "text/html", true)] diff --git a/Yllibed.HttpServer.Tests/DiFixture.cs b/Yllibed.HttpServer.Tests/DiFixture.cs index ddc1f1c..11d5271 100644 --- a/Yllibed.HttpServer.Tests/DiFixture.cs +++ b/Yllibed.HttpServer.Tests/DiFixture.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Yllibed.HttpServer.Extensions; namespace Yllibed.HttpServer.Tests; diff --git a/Yllibed.HttpServer.Tests/GlobalUsings.cs b/Yllibed.HttpServer.Tests/GlobalUsings.cs index 8c13986..ea4e991 100644 --- a/Yllibed.HttpServer.Tests/GlobalUsings.cs +++ b/Yllibed.HttpServer.Tests/GlobalUsings.cs @@ -1,4 +1,6 @@ -global using System.Net; -global using FluentAssertions; +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/SseNegotiationFixture.cs b/Yllibed.HttpServer.Tests/SseNegotiationFixture.cs index 7cf49ec..27e2e6e 100644 --- a/Yllibed.HttpServer.Tests/SseNegotiationFixture.cs +++ b/Yllibed.HttpServer.Tests/SseNegotiationFixture.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using Yllibed.HttpServer.Sse; namespace Yllibed.HttpServer.Tests; diff --git a/Yllibed.HttpServer.Tests/SseTestClient.cs b/Yllibed.HttpServer.Tests/SseTestClient.cs index 5702ccb..d07cade 100644 --- a/Yllibed.HttpServer.Tests/SseTestClient.cs +++ b/Yllibed.HttpServer.Tests/SseTestClient.cs @@ -23,10 +23,10 @@ public SseConnection(HttpClient client, HttpResponseMessage response) public HttpResponseMessage Response => _response; - public async IAsyncEnumerable ReadEventsAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable ReadEventsAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) { - _stream ??= await _response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await foreach (var ev in ReadFromStreamAsync(_stream, cancellationToken)) + _stream ??= await _response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + await foreach (var ev in ReadFromStreamAsync(_stream, ct)) { yield return ev; } @@ -71,8 +71,9 @@ public static async Task ConnectAsync(Uri uri, CancellationToken return new SseConnection(client, resp); } - public static async IAsyncEnumerable ReadFromStreamAsync(Stream stream, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + 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); @@ -80,9 +81,9 @@ public static async IAsyncEnumerable ReadFromStreamAsync(Stream string? id = null; var dataBuilder = new StringBuilder(); - while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) + while (!reader.EndOfStream && !ct.IsCancellationRequested) { - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) ?? string.Empty; + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false) ?? string.Empty; if (line.Length == 0) { diff --git a/Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs b/Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs index 76b7b92..3c8bb2c 100644 --- a/Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs +++ b/Yllibed.HttpServer.Tests/StreamingLifecycleFixture.cs @@ -8,7 +8,7 @@ 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)) + || !string.Equals(relativePath, "/finite", StringComparison.Ordinal)) { return Task.CompletedTask; } @@ -42,7 +42,7 @@ public async Task Streaming_StreamEnds_WhenWriterCompletes() resp.StatusCode.Should().Be(HttpStatusCode.OK); await using var stream = await resp.Content.ReadAsStreamAsync(CT); - using var reader = new StreamReader(stream, leaveOpen: false); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); var lines = new List(); while (true) { @@ -64,7 +64,7 @@ private sealed class InfiniteStreamingHandler : IHttpHandler public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) { if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase) - || !string.Equals(relativePath, "/infinite", StringComparison.Ordinal)) + || !string.Equals(relativePath, "/infinite", StringComparison.Ordinal)) { return Task.CompletedTask; } @@ -121,13 +121,13 @@ public async Task Streaming_HandlerStops_OnClientDisconnect() { resp.StatusCode.Should().Be(HttpStatusCode.OK); await using var stream = await resp.Content.ReadAsStreamAsync(CT); - using var reader = new StreamReader(stream, leaveOpen: false); + 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(); + (await tcs.Task).Should().BeTrue(); } } diff --git a/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj b/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj index 5016f24..e18dcf8 100644 --- a/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj +++ b/Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj @@ -1,10 +1,6 @@ - + - net9.0 - true - false - False - false + net9.0 enable @@ -13,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/GlobalUsings.cs b/Yllibed.HttpServer/GlobalUsings.cs index 61717fe..5d894fe 100644 --- a/Yllibed.HttpServer/GlobalUsings.cs +++ b/Yllibed.HttpServer/GlobalUsings.cs @@ -1,7 +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 Microsoft.Extensions.Logging; global using Yllibed.HttpServer.Extensions; diff --git a/Yllibed.HttpServer/Handlers/SseHandler.cs b/Yllibed.HttpServer/Handlers/SseHandler.cs index 26f6990..433a704 100644 --- a/Yllibed.HttpServer/Handlers/SseHandler.cs +++ b/Yllibed.HttpServer/Handlers/SseHandler.cs @@ -17,6 +17,12 @@ public abstract class SseHandler : IHttpHandler 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). /// @@ -39,7 +45,7 @@ public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, stri return Task.CompletedTask; } - if (!request.ValidateAccept("text/event-stream")) + if (!ValidateHeaders(request)) { request.SetResponse("text/plain", "Not Acceptable", 406, "Not Acceptable"); return Task.CompletedTask; 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/Yllibed.HttpServer.csproj b/Yllibed.HttpServer/Yllibed.HttpServer.csproj index 15f1610..dd47f20 100644 --- a/Yllibed.HttpServer/Yllibed.HttpServer.csproj +++ b/Yllibed.HttpServer/Yllibed.HttpServer.csproj @@ -1,7 +1,10 @@  + 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 @@ -10,14 +13,14 @@ - - + + - - + + diff --git a/Yllibed.png b/Yllibed.png new file mode 100644 index 0000000000000000000000000000000000000000..660f53aacaaa3057fa200f676aaf9f2d9f39e550 GIT binary patch literal 128213 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 z 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

UQ#;Jtq;rw;EEFYSLUnR$= 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�D9JA6dd5FR{8>5`S!-gy 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 literal 0 HcmV?d00001 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 + } +}