Skip to content

Commit 3307cff

Browse files
authored
Merge pull request #9 from carldebilly/dev/sse-support
Adding SSE support
2 parents b34c326 + dcace74 commit 3307cff

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1877
-260
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
dotnet-version: '9.0'
2929

3030
- name: Build
31-
run: dotnet build Yllibed.HttpServer.sln /p:Configuration=Release
31+
run: dotnet build Yllibed.HttpServer.slnx /p:Configuration=Release
3232

3333
- name: Collect NuGet packages
3434
shell: pwsh
@@ -46,8 +46,7 @@ jobs:
4646
if-no-files-found: error
4747

4848
- name: Test
49-
#run: dotnet test Yllibed.HttpServer.sln /p:Configuration=Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover --no-build
50-
run: dotnet test Yllibed.HttpServer.sln /p:Configuration=Release --no-build
49+
run: dotnet test Yllibed.HttpServer.slnx /p:Configuration=Release --no-build
5150

5251
publish:
5352
if: startsWith(github.ref, 'refs/heads/master')

Directory.Build.props

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,40 @@
1414
<RepositoryUrl>https://github.com/carldebilly/Yllibed.HttpServer</RepositoryUrl>
1515
<RepositoryType>git</RepositoryType>
1616
<PackageLicenseExpression>MIT</PackageLicenseExpression>
17+
<PublishRepositoryUrl>true</PublishRepositoryUrl>
18+
<PackageProjectUrl>https://github.com/carldebilly/Yllibed.HttpServer</PackageProjectUrl>
19+
<PackageIcon Condition="Exists('Yllibed.png')">Yllibed.png</PackageIcon>
20+
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
21+
<PackageTags>http server lightweight self-contained sse iot desktop tools diagnostics</PackageTags>
22+
<IncludeSymbols>true</IncludeSymbols>
23+
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
1724

1825
<!-- General build properties -->
1926
<Nullable>enable</Nullable>
2027
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
2128
<DebugType>portable</DebugType>
2229
<DebugSymbols>true</DebugSymbols>
2330
<LangVersion>12</LangVersion>
31+
<Deterministic>true</Deterministic>
32+
<ContinuousIntegrationBuild Condition="'$(CI)'!=''">true</ContinuousIntegrationBuild>
33+
34+
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
35+
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
2436
</PropertyGroup>
37+
38+
<ItemGroup>
39+
<None Include="Yllibed-small.png" Pack="true" PackagePath="/" Condition="Exists('Yllibed-small.png')" />
40+
</ItemGroup>
2541

2642
<ItemGroup>
27-
<PackageReference Include="Nerdbank.GitVersioning" Version="3.6.146" PrivateAssets="all" />
28-
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
43+
<PackageReference Include="Nerdbank.GitVersioning" PrivateAssets="all" />
44+
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers">
2945
<PrivateAssets>all</PrivateAssets>
3046
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
3147
</PackageReference>
32-
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>
33-
<PackageReference Include="DotNet.ReproducibleBuilds" Version="1.2.25" PrivateAssets="All" />
34-
<PackageReference Include="Meziantou.Analyzer" Version="2.0.180">
48+
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All"/>
49+
<PackageReference Include="DotNet.ReproducibleBuilds" PrivateAssets="All" />
50+
<PackageReference Include="Meziantou.Analyzer">
3551
<PrivateAssets>all</PrivateAssets>
3652
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
3753
</PackageReference>

Directory.Packages.props

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project>
2+
<ItemGroup>
3+
<PackageVersion Include="AwesomeAssertions" Version="9.1.0" />
4+
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
5+
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" />
6+
<PackageVersion Include="DotNet.ReproducibleBuilds" Version="1.2.25" />
7+
<PackageVersion Include="Meziantou.Analyzer" Version="2.0.215" />
8+
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
9+
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.8" />
10+
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
11+
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.7.115" />
12+
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
13+
<PackageVersion Include="PolySharp" Version="1.15.0" />
14+
<PackageVersion Include="System.Collections.Immutable" Version="9.0.8" />
15+
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.8" />
16+
<PackageVersion Include="System.ValueTuple" Version="4.6.1" />
17+
</ItemGroup>
18+
</Project>

README.md

Lines changed: 150 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
# Yllibed HttpServer
22

3-
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.
3+
![Yllibed logo](Yllibed-small.png)
44

5-
## Packages and NuGet Statistics
5+
A small, self-contained HTTP server for desktop, mobile, and embedded apps that need to expose a simple web endpoint.
66

7-
| Package | Downloads | Stable Version | Pre-release Version |
8-
|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
9-
| [**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) |
10-
| [**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) |
7+
- Lightweight, no ASP.NET dependency
8+
- Great for OAuth2 redirect URIs, diagnostics, and local tooling
9+
- IPv4/IPv6, HTTP/1.1, custom handlers, static files, and SSE
1110

12-
## Quick start-up
11+
---
12+
13+
## Packages and NuGet
14+
15+
| Package | Downloads | Stable | Pre-release |
16+
|---|---|---|---|
17+
| [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) |
18+
| [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) |
19+
20+
---
21+
22+
## Quick start
1323

1424
1. First install nuget package:
1525
```shell
@@ -49,36 +59,44 @@ This is a versatile http server designed to be used in mobile/UWP applications a
4959
```
5060

5161
## What it is
52-
* Simple web server which can be extended using custom code
53-
* No dependencies on ASP.NET or other frameworks, self-contained
62+
63+
- Simple web server that can be extended with custom code
64+
- No dependencies on ASP.NET or other frameworks; fully self-contained
65+
- Intended for small apps and utilities (e.g., OAuth2 redirect URL from an external browser)
5466

5567
## What it is not
56-
* This HTTP server is not designed for performance or high capacity
57-
* It's perfect for small applications, or small need, like to act as _return url_ for OAuth2 authentication using external browser.
68+
69+
- NOT designed for high performance or high concurrency
70+
- NOT appropriate for public-facing web services
71+
- NOT a full-featured web framework (no MVC, no Razor, no routing, etc.)
72+
- NOT a replacement for ASP.NET Core or Kestrel
5873

5974
## Features
60-
* Simple, lightweight, self-contained HTTP server
61-
* Supports IPv4 and IPv6
62-
* Supports HTTP 1.1 (limited: no keep-alive, no chunked encoding)
63-
* Supports GET, POST, PUT, DELETE, HEAD, OPTIONS, TRACE, PATCH - even custom methods
64-
* Supports static files
65-
* Supports custom headers
66-
* Supports custom status codes
67-
* Supports custom content types
68-
* Supports custom content encodings
69-
* Supports dependency injection and configuration via `IOptions<ServerOptions>`
70-
* Configurable bind addresses and hostnames for IPv4/IPv6
71-
* Supports dynamic port assignment
75+
76+
- Simple, lightweight, self-contained HTTP server
77+
- Supports IPv4 and IPv6
78+
- Supports HTTP 1.1 (limited: no keep-alive, no chunked encoding)
79+
- Allows any HTTP method (GET, POST, PUT, DELETE, HEAD, OPTIONS, TRACE, PATCH, custom). Handlers decide how to handle them.
80+
- Simple static responses via StaticHandler (no built-in file/directory serving)
81+
- Supports custom headers
82+
- Supports custom status codes
83+
- Supports custom content types
84+
- Arbitrary response headers (incl. Content-Encoding); no automatic compression/encoding
85+
- Supports dependency injection and configuration via `IOptions<ServerOptions>`
86+
- Configurable bind addresses and hostnames for IPv4/IPv6
87+
- Supports dynamic port assignment
7288

7389
## Common use cases
74-
* Return URL for OAuth2 authentication using external browser
75-
* Remote diagnostics/monitoring on your app
76-
* Building a headless Windows IoT app (for SSDP discovery or simply end-user configuration)
77-
* Any other use case where you need to expose a simple web server
90+
91+
- Return URL for OAuth2 authentication using external browser
92+
- Remote diagnostics/monitoring on your app
93+
- Building a headless Windows IoT app (for SSDP discovery or simply end-user configuration)
94+
- Any other use case where you need to expose a simple web server
7895

7996
## Limitations
80-
* There is no support for HTTP 2.0+ (yet) or WebSockets
81-
* There is no support for HTTPS (TLS)
97+
98+
- There is no support for HTTP/2+ (yet) or WebSockets
99+
- There is no support for HTTPS (TLS)
82100

83101
## Security and Intended Use (No TLS)
84102
This server uses plain HTTP with no transport encryption. It is primarily intended for:
@@ -260,3 +278,106 @@ var serverOptions = new ServerOptions
260278
Hostname6 = "::1" // IPv6 loopback
261279
};
262280
```
281+
282+
283+
## Server-Sent Events (SSE)
284+
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.
285+
286+
- Content-Type: text/event-stream
287+
- Cache-Control: no-cache is added by default
288+
- Connection: close is still set by the server; the connection remains open until your writer completes
289+
290+
Quick example (application code):
291+
292+
```csharp
293+
// Register a handler for /sse (very basic example)
294+
public sealed class SseDemoHandler : IHttpHandler
295+
{
296+
public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath)
297+
{
298+
if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)) return Task.CompletedTask;
299+
if (!string.Equals(relativePath, "/sse", StringComparison.Ordinal)) return Task.CompletedTask;
300+
301+
request.StartSseSession(RunSseSession,
302+
headers: new Dictionary<string, IReadOnlyCollection<string>>
303+
{
304+
["Access-Control-Allow-Origin"] = new[] { "*" } // if you need CORS
305+
},
306+
options: new SseOptions
307+
{
308+
HeartbeatInterval = TimeSpan.FromSeconds(30),
309+
HeartbeatComment = "keepalive",
310+
AutoFlush = true
311+
});
312+
return Task.CompletedTask;
313+
}
314+
315+
private async Task RunSseSession(ISseSession sse, CancellationToken ct)
316+
{
317+
// Optional: initial comment
318+
await sse.SendCommentAsync("start", ct);
319+
320+
var i = 0;
321+
while (!ct.IsCancellationRequested && i < 10)
322+
{
323+
// Write an event every second
324+
await sse.SendEventAsync($"{DateTimeOffset.UtcNow:O}", eventName: "tick", id: i.ToString(), ct: ct);
325+
await Task.Delay(TimeSpan.FromSeconds(1), ct);
326+
i++;
327+
}
328+
}
329+
}
330+
331+
// Usage during startup
332+
var server = new Server();
333+
var ssePath = new RelativePathHandler("/updates");
334+
ssePath.RegisterHandler(new SseDemoHandler());
335+
server.RegisterHandler(ssePath);
336+
var (uri4, _) = server.Start();
337+
Console.WriteLine($"SSE endpoint: {uri4}/updates/sse");
338+
```
339+
340+
SseHandler convenience base class:
341+
```csharp
342+
public sealed class MySseHandler : SseHandler
343+
{
344+
protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
345+
=> base.ShouldHandle(request, relativePath) && relativePath is "/sse";
346+
347+
protected override Task HandleSseSession(ISseSession sse, CancellationToken ct)
348+
=> RunSseSession(sse, ct); // Reuse the same private method as above
349+
}
350+
351+
// Registration
352+
var server = new Server();
353+
var ssePath = new RelativePathHandler("/updates");
354+
ssePath.RegisterHandler(new MySseHandler());
355+
server.RegisterHandler(ssePath);
356+
```
357+
358+
Client-side (browser):
359+
```html
360+
<script>
361+
const es = new EventSource('/updates/sse');
362+
es.addEventListener('tick', e => console.log('tick', e.data));
363+
es.onmessage = e => console.log('message', e.data);
364+
es.onerror = e => console.warn('SSE error', e);
365+
</script>
366+
```
367+
368+
Notes:
369+
- Heartbeats: send a comment frame (`: keepalive\n\n`) every 15–30s to prevent proxy timeouts.
370+
- Long-running streams: handle CancellationToken to stop cleanly when the client disconnects.
371+
- 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.
372+
- Public exposure: there is no TLS; prefer localhost or internal networks, or place behind a TLS-terminating reverse proxy.
373+
374+
375+
### SSE Spec and Interop Notes
376+
- 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).
377+
- 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.
378+
- Heartbeats: You can configure periodic comment frames via SseOptions.HeartBeatInterval; this keeps intermediaries from timing out idle connections.
379+
- 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.
380+
- 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.
381+
- Caching: Cache-Control: no-cache is added by default for SSE responses unless you override it via headers.
382+
- Retry: The SSE spec allows the server to send a retry: <milliseconds> 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.
383+
- 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.

Yllibed-small.png

5.65 KB
Loading

Yllibed.HttpServer.Json.Tests/FixtureBase.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
#nullable disable
2-
using System;
32
using System.Diagnostics;
4-
using System.Threading;
5-
using Microsoft.VisualStudio.TestTools.UnitTesting;
63

74
namespace Yllibed.HttpServer.Json.Tests;
85

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
global using AwesomeAssertions;
2+
global using Microsoft.VisualStudio.TestTools.UnitTesting;
3+
global using System;
4+
global using System.Net;
5+
global using System.Net.Http;
6+
global using System.Threading;
7+
global using Yllibed.HttpServer.Sse;

Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
using System.Collections.Generic;
22
using System.Linq;
3-
using System.Net;
4-
using System.Net.Http;
5-
using System.Threading;
63
using System.Threading.Tasks;
7-
using FluentAssertions;
8-
using Microsoft.VisualStudio.TestTools.UnitTesting;
94
using Newtonsoft.Json;
105
using Yllibed.HttpServer.Json;
116

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Text;
2+
using System.Threading.Tasks;
3+
using System.IO;
4+
using Yllibed.HttpServer.Handlers;
5+
using Yllibed.HttpServer.Tests;
6+
7+
namespace Yllibed.HttpServer.Json.Tests;
8+
9+
[TestClass]
10+
public sealed class SseJsonFixture : FixtureBase
11+
{
12+
private sealed class JsonSseHandler : SseHandler
13+
{
14+
protected override bool ShouldHandle(IHttpServerRequest request, string relativePath)
15+
=> base.ShouldHandle(request, relativePath) && relativePath is "/js";
16+
17+
protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath)
18+
=> new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero };
19+
20+
protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct)
21+
{
22+
var payload = new { A = 1, B = "x" };
23+
await sse.SendJsonEventAsync("obj", payload, id: "j1", ct: ct);
24+
}
25+
}
26+
27+
[TestMethod]
28+
public async Task Sse_SendJson_WritesCompactJsonInData()
29+
{
30+
using var sut = new Server();
31+
var route = new RelativePathHandler("sse-json");
32+
route.RegisterHandler(new JsonSseHandler());
33+
sut.RegisterHandler(route);
34+
35+
var (uri4, _) = sut.Start();
36+
var requestUri = new Uri(uri4, "sse-json/js");
37+
38+
await using var conn = await SseTestClient.ConnectAsync(requestUri, CT);
39+
conn.Response.StatusCode.Should().Be(HttpStatusCode.OK);
40+
conn.Response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
41+
42+
SseTestClient.ServerSentEvent? first = null;
43+
await foreach (var ev in conn.ReadEventsAsync(CT))
44+
{
45+
first = ev;
46+
break;
47+
}
48+
first.Should().NotBeNull();
49+
first!.Event.Should().Be("obj");
50+
first.Id.Should().Be("j1");
51+
first.Data.Should().Be("""{"A":1,"B":"x"}""");
52+
}
53+
}

0 commit comments

Comments
 (0)