Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand Down
26 changes: 21 additions & 5 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,40 @@
<RepositoryUrl>https://github.com/carldebilly/Yllibed.HttpServer</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageProjectUrl>https://github.com/carldebilly/Yllibed.HttpServer</PackageProjectUrl>
<PackageIcon Condition="Exists('Yllibed.png')">Yllibed.png</PackageIcon>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageTags>http server lightweight self-contained sse iot desktop tools diagnostics</PackageTags>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>

<!-- General build properties -->
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<LangVersion>12</LangVersion>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)'!=''">true</ContinuousIntegrationBuild>

<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>

<ItemGroup>
<None Include="Yllibed-small.png" Pack="true" PackagePath="/" Condition="Exists('Yllibed-small.png')" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Version="3.6.146" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
<PackageReference Include="Nerdbank.GitVersioning" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>
<PackageReference Include="DotNet.ReproducibleBuilds" Version="1.2.25" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.180">
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All"/>
<PackageReference Include="DotNet.ReproducibleBuilds" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
18 changes: 18 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project>
<ItemGroup>
<PackageVersion Include="AwesomeAssertions" Version="9.1.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" />
<PackageVersion Include="DotNet.ReproducibleBuilds" Version="1.2.25" />
<PackageVersion Include="Meziantou.Analyzer" Version="2.0.215" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.8" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.7.115" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="PolySharp" Version="1.15.0" />
<PackageVersion Include="System.Collections.Immutable" Version="9.0.8" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.8" />
<PackageVersion Include="System.ValueTuple" Version="4.6.1" />
</ItemGroup>
</Project>
179 changes: 150 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ServerOptions>`
* 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<ServerOptions>`
- 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:
Expand Down Expand Up @@ -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<string, IReadOnlyCollection<string>>
{
["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
<script>
const es = new EventSource('/updates/sse');
es.addEventListener('tick', e => console.log('tick', e.data));
es.onmessage = e => console.log('message', e.data);
es.onerror = e => console.warn('SSE error', e);
</script>
```

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: <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.
- 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.
Binary file added Yllibed-small.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 0 additions & 3 deletions Yllibed.HttpServer.Json.Tests/FixtureBase.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
#nullable disable
using System;
using System.Diagnostics;
using System.Threading;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Yllibed.HttpServer.Json.Tests;

Expand Down
7 changes: 7 additions & 0 deletions Yllibed.HttpServer.Json.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 0 additions & 5 deletions Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
53 changes: 53 additions & 0 deletions Yllibed.HttpServer.Json.Tests/SseJsonFixture.cs
Original file line number Diff line number Diff line change
@@ -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"}""");
}
}
Loading
Loading