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