SimpleHttpListener.Rx
7.4.0
See the version list below for details.
dotnet add package SimpleHttpListener.Rx --version 7.4.0
NuGet\Install-Package SimpleHttpListener.Rx -Version 7.4.0
<PackageReference Include="SimpleHttpListener.Rx" Version="7.4.0" />
<PackageVersion Include="SimpleHttpListener.Rx" Version="7.4.0" />
<PackageReference Include="SimpleHttpListener.Rx" />
paket add SimpleHttpListener.Rx --version 7.4.0
#r "nuget: SimpleHttpListener.Rx, 7.4.0"
#:package SimpleHttpListener.Rx@7.4.0
#addin nuget:?package=SimpleHttpListener.Rx&version=7.4.0
#tool nuget:?package=SimpleHttpListener.Rx&version=7.4.0
SimpleHttpListener.Rx
An Rx-based HTTP listener for TCP, UDP, and UDP multicast traffic.
Please star this project if you find it useful. Thank you.
Overview
SimpleHttpListener.Rx is a .NET library for HTTP message handling over application-controlled transports. It can listen for HTTP over TCP and UDP, including UDP multicast, which makes it useful for protocols such as UPnP/SSDP.
The library is built with Reactive Extensions, exposing incoming HTTP messages as an IObservable<HttpRequestResponse> for asynchronous processing.
Version highlights
| Version | Highlights |
|---|---|
| 7.4.0 | Listener options: opt-in raw wire capture of each UDP message's bytes as received, and opt-in RFC 9112 §6.3 framing for unframed response bodies. |
| 7.3.0 | Reliable stop and restart: stopping never surfaces as an error, and dispose-then-resubscribe never races the previous teardown. |
| 7.2.0 | LocalEndPoint on UDP messages reports the interface the datagram arrived on rather than the socket's bound address. |
| 7.1.0 | WebSocket accept support — no ASP.NET/Kestrel required. |
| 7.0.0 | Modernization release: .NET 10, HttpMachine.PCL 6.0.x span-based parsing, HTTP keep-alive with concurrent connection handling, and a cleaned-up public API. See Breaking changes in 7.0.0 if you are upgrading. |
Usage
Turn a TcpListener or UdpClient into an observable HTTP listener with the ToHttpListenerObservable extension method.
Namespaces
using SimpleHttpListener.Rx;
using SimpleHttpListener.Rx.Model;
TCP HTTP listener
Avoid an async subscriber such as .Subscribe(async x => ...); it can make Rx error handling and scheduling difficult. Instead, convert the asynchronous operation with Observable.FromAsync, then concatenate it as shown below.
var tcpListener = new TcpListener(IPAddress.Loopback, 8088);
var cts = new CancellationTokenSource();
var disposable = tcpListener
.ToHttpListenerObservable(cts.Token)
.Do(r => Console.WriteLine($"{r.Method} {r.Path} from {r.RemoteEndPoint}"))
// Send reply to the client
.Select(r => Observable.FromAsync(() => r.SendResponseAsync(new HttpResponse
{
Headers =
{
["Date"] = DateTime.UtcNow.ToString("r"),
["Content-Type"] = "text/html; charset=UTF-8"
},
Body = Encoding.UTF8.GetBytes($"<html><body><h1>Hello, World! {DateTime.Now}</h1></body></html>")
})))
.Concat()
.Subscribe(
_ => Console.WriteLine("Reply sent."),
ex => Console.WriteLine($"Exception: {ex}"),
() => Console.WriteLine("Completed."));
The listener starts when the observable is first subscribed and stops when the last subscription is disposed (or the token is cancelled). Re-subscribing restarts it.
Subscription lifecycle (7.3.0+)
Two guarantees make dispose/resubscribe cycles safe on every platform, for both the TCP and UDP listeners:
- Stopping is never an error. Cancelling the token, disposing the last subscription, or closing the listener/socket out from under the loop completes the stream — it never reaches
OnError. Cancelling a pending accept or receive raisesOperationCanceledExceptionon Windows but aSocketException("Operation canceled") orObjectDisposedExceptionon Linux and macOS; all of them are a stop. A genuine socket failure while the listener is meant to be running still errors as before. - Restart never races teardown. Teardown is synchronous — the listener is stopped by the time
Dispose()on the subscription returns — and a subscription that starts while an earlier loop is still unwinding cannot have its listener stopped underneath it. Disposing the last subscriber of a shared (RefCounted) stream and resubscribing in the same breath yields a working listener, with no delay needed in between.
Keep-alive and connection ownership
New in 7.0.0: connections are handled concurrently, and an HTTP/1.1 keep-alive connection emits one message per request — a slow or idle client no longer blocks other clients.
Ownership of the underlying connection is simple:
- While the listener is reading a connection, the listener owns it. It disposes the connection when the client closes it or a fatal parse/IO error occurs.
- When a message with
ShouldKeepAlive == falseis emitted, the listener stops reading and the consumer owns the connection.SendResponseAsyncin auto mode (closeConnection: null, the default) closes it after the response is written, so the simple flow above handles both cases correctly. - Disposing a connection yourself while the listener is reading it is treated as a normal close, not an error.
SendResponseAsync always emits a correct Content-Length (including 0 for empty bodies) unless you set Content-Length or Transfer-Encoding yourself, and adds the appropriate Connection: close / Connection: keep-alive header automatically.
UDP HTTP listener
The following UDP listener receives UPnP SSDP multicast traffic from the local network.
var udpClient = new UdpClient
{
ExclusiveAddressUse = false,
MulticastLoopback = true
};
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
udpClient.JoinMulticastGroup(IPAddress.Parse("239.255.255.250"));
udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, 1900));
var disposable = udpClient
.ToHttpListenerObservable(cts.Token, ErrorCorrection.HeaderCompletionError)
.Subscribe(r =>
{
Console.WriteLine($"{r.Method} from {r.RemoteEndPoint} on {r.LocalEndPoint}");
});
ErrorCorrection.HeaderCompletionError is optional. Some SSDP/UPnP devices send messages whose header section does not end with the required empty line (\r\n\r\n); with the correction enabled such messages still parse. Without it they are emitted with HasParsingErrors == true and IsEndOfMessage == false. UDP messages have Connection == null; replying is up to you (e.g. via UdpClient.SendAsync).
Local endpoint of a received datagram (7.2.0+)
LocalEndPoint reports the address the datagram was actually delivered to, not the socket's bound address, so it stays useful for the wildcard bind that multicast requires on macOS and Linux (0.0.0.0:1900 above). It is derived per datagram from the packet information the socket reports:
- Unicast — the packet's destination address is the receiving interface address, and is used directly.
- Multicast/broadcast — the packet's destination is the group (e.g.
239.255.255.250), so the address of the interface the datagram arrived on is resolved from its interface index instead. The interface lookup is cached and refreshed when the machine's addresses change. - If resolution fails (unknown interface index, or a NIC with no IPv4 address) the socket's bound endpoint is reported, as before. The message is always emitted; only the accuracy of
LocalEndPointdegrades.
The port is always the socket's bound port. IPv6 unicast is resolved the same way; IPv6 multicast still reports the bound endpoint.
This matters when you have to hand a peer an address to call you back on — for example a UPnP GENA CALLBACK URL. Before 7.2.0 that would be built from 0.0.0.0 on macOS and Linux, which strict devices reject.
Raw message capture (7.4.0+)
HttpRequestResponse gives you a parsed, normalised view — header names are upper-cased, repeated fields are comma-joined, field order is gone. For SSDP that loses the message itself: a datagram has no body, so the header block is the message. Enable capture to keep the bytes as they arrived:
var options = new HttpListenerOptions { CaptureRawMessage = true };
var disposable = udpClient
.ToHttpListenerObservable(options, cts.Token, ErrorCorrection.HeaderCompletionError)
.Subscribe(message =>
{
// Decode leniently: real devices emit bytes no encoding decodes faithfully.
var wire = Encoding.ASCII.GetString(message.RawMessage.Span);
Console.WriteLine(wire);
});
RawMessage is the message before parsing, normalisation or de-chunking, so original casing (Cache-Control:), field order, and repeated fields all survive — which is what makes a bug report against a parser reproducible. It is ReadOnlyMemory<byte> rather than a string on purpose: decoding is the consumer's call.
- Off by default, and free when off — no copy, no allocation on the receive path.
- Always a copy, never a slice of the pooled receive buffer, so it stays valid for as long as you hold the message.
- Populated for messages that failed to parse too (
HasParsingErrors == true) — usually when you want it most. Every datagram is emitted, parsable or not. - Observational only: enabling it changes no parsed value, no framing, and no emission.
- UDP only. A TCP message is framed out of a stream and may span reads, so attributing bytes to it would only be approximate;
RawMessagestays empty for TCP messages, and theHttpListenerOptionsoverload forTcpListenerexists only for future options. - Retention is yours. A chatty SSDP network emits a lot of datagrams; holding every message keeps every captured buffer alive. Long-running capture belongs in a file log, not in memory.
Listener options (7.4.0+)
Both ToHttpListenerObservable overloads accept an optional HttpListenerOptions. Every default matches the behaviour of the overloads without it, so options only ever add something you asked for:
| Option | Default | Effect |
|---|---|---|
CaptureRawMessage |
false |
Carry each message's bytes as received on RawMessage. UDP only. |
UnframedResponseMode |
CompleteAtHeaders |
How to frame a response with neither Content-Length nor Transfer-Encoding (see below). |
var options = new HttpListenerOptions
{
CaptureRawMessage = true,
UnframedResponseMode = UnframedResponseMode.CloseDelimited
};
var disposable = tcpListener.ToHttpListenerObservable(options, cts.Token).Subscribe(/* ... */);
Unframed response bodies
A response that carries neither Content-Length nor Transfer-Encoding has no self-evident end, and the two legal readings serve different protocols:
CompleteAtHeaders(default) — the message ends at the blank line. This is what SSDP/HTTPU needs, since those responses are bodyless, and it is what every version before 7.4.0 did. If such a response does carry a body, those bytes are read as the start of the next message.CloseDelimited— the body runs to the end of the input, per RFC 9112 §6.3: the framing an HTTP/1.0 style response that ends by closing the connection needs. Enable it only for a stream you know carries such responses, since every unframed response then waits for the input to end before completing.
Requests are unaffected either way: a request with no framing headers has no body (RFC 9112 §6), and completes immediately under both modes.
WebSockets (7.1.0+)
The listener accepts incoming WebSocket connections without ASP.NET/Kestrel. A WebSocket
handshake arrives as a normal HTTP request with IsUpgradeRequest == true; the listener stops
reading that connection and hands ownership to you. Call AcceptWebSocketAsync to complete the
RFC 6455 handshake and get a standard System.Net.WebSockets.WebSocket (all framing, ping/pong
and close handling comes from the .NET runtime):
var disposable = tcpListener
.ToHttpListenerObservable(cts.Token)
.Subscribe(request =>
{
if (request.IsUpgradeRequest)
{
_ = HandleWebSocketAsync(request);
}
else
{
_ = request.SendResponseAsync(new HttpResponse { Body = "Hello, World"u8.ToArray() });
}
});
static async Task HandleWebSocketAsync(HttpRequestResponse request)
{
try
{
using var webSocket = await request.AcceptWebSocketAsync();
// Standard WebSocket API from here: ReceiveAsync / SendAsync / CloseAsync…
}
finally
{
request.Connection?.Dispose(); // consumer owns the connection after the upgrade emission
}
}
Notes and limitations:
- After an upgrade request is emitted the listener no longer reads that connection — complete
the handshake or dispose
Connection, even if you reject the upgrade. ws://only: the listener runs on a rawTcpListenerwith no TLS, so browsers can only connect from non-HTTPS pages. This targets LAN/local/native-app scenarios.AcceptWebSocketAsyncvalidates the handshake (version 13, key present) and throwsInvalidOperationExceptionfor invalid requests; sending an error response is up to you.- Sub-protocols are pass-through (
subProtocolparameter), no negotiation logic. - For the client side of the story, see
WebsocketClientLite.PCL — the
samples/SimpleHttpListener.Rx.Sample.WebSocketClientproject connects it to the server sample's echo endpoint.
Parse errors
The listener observables never fail because of one bad client. Malformed input or a connection that closes mid-message produces an emission with HasParsingErrors == true, and the listener keeps serving other connections.
Breaking changes in 7.0.0
7.0.0 targets .NET 10 and ships one assembly: the separate ISimpleHttpListener.Rx interface assembly/package is gone, as are the IHttpCommon/IHttpHeaders/IParseControl/IHttpRequest/IHttpResponse/IHttpRequestReponse interfaces. Everything lives in the SimpleHttpListener.Rx and SimpleHttpListener.Rx.Model namespaces.
| v6 | v7 |
|---|---|
IHttpRequestReponse (misspelled) interface |
HttpRequestResponse class |
RequestType enum (TCP/UDP) |
HttpTransport enum (Tcp/Udp) |
MessageType (from HttpMachine) |
MessageType (own enum, Request/Response) |
ResponseReason |
ReasonPhrase |
LocalIpEndPoint / RemoteIpEndPoint |
LocalEndPoint / RemoteEndPoint |
MemoryStream Body |
ReadOnlyMemory<byte> Body |
TcpClient / ResponseStream properties |
IHttpConnection? Connection (Stream, endpoints, Dispose) |
IsEndOfRequest |
IsEndOfMessage |
IsUnableToParseHttp / IsRequestTimedOut |
HasParsingErrors |
UserContext, CancellationTokenSource |
removed |
Headers (uppercase keys, last duplicate wins) |
Headers (uppercase keys, case-insensitive lookup, duplicates comma-joined per RFC 9110 §5.2) |
new HttpSender().SendTcpResponseAsync(request, response) |
request.SendResponseAsync(response) extension |
HttpResponse.Body (MemoryStream) |
HttpResponse.Body (ReadOnlyMemory<byte>) |
uri.Host.GetIPv4Address() (sync) |
await uri.Host.GetIPv4AddressAsync() |
TcpClientEx / UriEx / HttpListenerEx classes |
TcpClientExtensions / UriExtensions / HttpListenerExtensions |
| One message per connection, sequential handling | Multiple messages per keep-alive connection, concurrent connections (emissions interleave) |
Behavior note: v6 closed every connection after one message. v7 honors keep-alive, so a consumer must respond per message; SendResponseAsync auto mode preserves v6-style close semantics whenever the client asked for Connection: close or HTTP/1.0.
HttpRequestResponse and HttpResponse are record types, so with expressions work for derived copies. Their equality is deliberately reference-based (a received message carries a live connection and a body buffer, so member-wise comparison would be misleading).
History
SimpleHttpListener.Rx is the successor to Simple HTTP Listener PCL. The legacy package remains available as SimpleHttpListener.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- HttpMachine.PCL (>= 6.1.0)
- System.Reactive (>= 7.0.0)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on SimpleHttpListener.Rx:
| Package | Downloads |
|---|---|
|
SSDP.UPnP.PCL
An Rx-based SSDP library for discovering and advertising UPnP Device Architecture 2.0 devices and services. |
|
|
UPnP.Rx
A modern, functional, Rx-based UPnP control point for .NET: discover devices, browse their services, call their actions — as observables and immutable records. Includes an IGD port-mapping client with auto-renewing leases. |
GitHub repositories (1)
Showing the top 1 popular GitHub repositories that depend on SimpleHttpListener.Rx:
| Repository | Stars |
|---|---|
|
uholeschak/ediabaslib
.NET BMW and VAG Ediabas interpreter library
|