LHZ.WebSocket
1.3.0
dotnet add package LHZ.WebSocket --version 1.3.0
NuGet\Install-Package LHZ.WebSocket -Version 1.3.0
<PackageReference Include="LHZ.WebSocket" Version="1.3.0" />
<PackageVersion Include="LHZ.WebSocket" Version="1.3.0" />
<PackageReference Include="LHZ.WebSocket" />
paket add LHZ.WebSocket --version 1.3.0
#r "nuget: LHZ.WebSocket, 1.3.0"
#:package LHZ.WebSocket@1.3.0
#addin nuget:?package=LHZ.WebSocket&version=1.3.0
#tool nuget:?package=LHZ.WebSocket&version=1.3.0
LHZ.WebSocket
A lightweight, zero-dependency WebSocket library for .NET, implementing RFC 6455 from the ground up using raw TcpListener / TcpClient. Supports both server-side and client-side WebSocket connections.
Features
- Zero dependencies — pure .NET, no third-party libraries
- RFC 6455 compliant — FIN, RSV1–3, all opcodes, masking, extended payload lengths
- Server & Client — create WebSocket servers or connect as a client via
CreateWebSocketClient - Fragmented messages — automatic reassembly of continuation frames (client → server)
- Streaming send — split large payloads across multiple frames via
CreateDataFrame(Stream) - Bounded channel — producer-consumer pattern for outgoing frames; no unbounded queues
- Event-driven —
OnMessageReceived,OnBytesReceived,OnCloseReceived,OnClientClose,OnPingReceived,OnPongReceived - Multi-targeting —
net5.0,net6.0,net8.0,net9.0,net10.0with nullable reference types enabled - Handshake timeout — configurable timeout to reject slow HTTP upgrade requests. It also covers the time your
OnUpgradeRequesthandler takes, so keep that handler quick. Handshake lines are capped at 8 KB and 100 headers (answered with431) - xUnit tested — comprehensive unit tests for frame parsing, close messages, HTTP headers, server, and client
Quick Start
1. Add LHZ.WebSocket Package
Package Manager
Install-Package LHZ.WebSocket -version 1.3.0
.NET CLI
dotnet add package LHZ.WebSocket --Version 1.3.0
Package Reference
<PackageReference Include="LHZ.WebSocket" Version="1.3.0" />
Upgrading from 1.1.x? Some misspelled public names were corrected, so you will get compile errors until you rename the call sites:
Old New ClientStatus.OpendClientStatus.OpenedOnCloseRecivedOnCloseReceivedOnPingRecivedOnPingReceivedOnPongRecivedOnPongReceivedDataFrame.DataDataFrameFlagDataFrame.DataFrameFlagTwo parameter names were corrected as well, which only matters if you pass them by name:
CreateWebSocketClient'stimeOUtis nowtimeOut, andCreateDataFrame'sdataDataFrameLengthis nowdataFrameLength.Also note that a Pong is now sent automatically on every incoming Ping, so a handler that replied by hand can be removed.
2. Create and start a server
using LHZ.WebSocket;
using LHZ.WebSocket.Core;
using LHZ.WebSocket.Http;
using LHZ.WebSocket.Interfaces;
// Listen on port 5000, all interfaces
var server = new WebSocketServer(5000);
server.OnUpgradeRequest += (HttpContext context) =>
{
// Optional: inspect headers, add custom response headers, or deny the upgrade
// context.Response?.Headers.Add("X-Custom", "value");
var client = context.HttpUpgrade(); // completes the 101 handshake
client.OnMessageReceived += (IWebSocketClient sender, string message) =>
{
Console.WriteLine($"Received: {message}");
sender.SendMessage($"Echo: {message}");
};
client.OnBytesReceived += (IWebSocketClient sender, byte[] data) =>
{
Console.WriteLine($"Received {data.Length} bytes");
};
client.OnCloseReceived += (IWebSocketClient sender, CloseMessage msg) =>
{
Console.WriteLine($"Client closed: {msg.CloseCode} — {msg.Message}");
sender.Close();
};
};
server.Start();
Console.WriteLine($"Server started, {server.ClientNums} clients connected");
Console.ReadLine();
server.Stop();
3. Connect as a client
using LHZ.WebSocket;
using LHZ.WebSocket.Core;
using LHZ.WebSocket.Interfaces;
// Connect to a WebSocket server
var client = WebSocketClient.CreateWebSocketClient("ws://localhost:5000/");
client.OnMessageReceived += (IWebSocketClient sender, string message) =>
{
Console.WriteLine($"Received: {message}");
};
client.OnCloseReceived += (IWebSocketClient sender, CloseMessage message) =>
{
Console.WriteLine($"Connection closed: {message.CloseCode}");
sender.Close();
};
client.Open();
client.SendMessage("Hello World!");
4. Bind to a specific IP
var server = new WebSocketServer(IPAddress.Loopback, 5000);
5. Run the tests
cd src
dotnet test
API Reference
WebSocketServer
| Member | Description |
|---|---|
WebSocketServer(int port) |
Bind to all interfaces on the given port |
WebSocketServer(IPAddress ip, int port) |
Bind to a specific IP and port |
WebSocketServer(IPAddress ip, int port, int timeOut) |
Bind to a specific IP and port with a handshake timeout (seconds) |
Start() |
Begin accepting connections |
Stop() |
Disconnect all clients and stop listening |
ClientNums |
Current number of connected clients |
WebSocketClients |
Snapshot of connected clients (IEnumerable<IWebSocketClient>) |
Status |
Current ServerStatus (Ready / Start / Closing / Closed) |
OnUpgradeRequest |
Fired when an HTTP upgrade is received; call HttpUpgrade() to accept |
OnClientConnected |
Fired after the WebSocket handshake completes (Action<IWebSocketClient>) |
OnError |
Fired when the accept loop or a handshake fails (Action<Exception>); falls back to the console when unsubscribed |
IWebSocketClient (interface)
| Member | Description |
|---|---|
ID |
Unique Guid for this connection |
Status |
Current ClientStatus (Connection / Opened / Close) |
MaxMessageSize |
Largest reassembled incoming message in bytes (default 4 MiB; 0 disables the limit). A peer exceeding it is closed with 1009 |
SendMessage(string) |
Send a UTF-8 text frame. Blocks while the outgoing queue is full |
SendMessageAsync(string, CancellationToken) |
Send a UTF-8 text frame without blocking |
SendByte(byte[]) |
Send a binary frame. Blocks while the outgoing queue is full |
SendByteAsync(byte[], CancellationToken) |
Send a binary frame without blocking |
Ping(byte[]) / PingAsync(byte[], CancellationToken) |
Send a Ping frame |
Pong(byte[]) / PongAsync(byte[], CancellationToken) |
Send a Pong frame. A Pong is sent automatically on every incoming Ping, so this is only needed for unsolicited Pongs |
Open() |
Start the background send/receive loops |
Close() / Close(CloseCode, string?) |
Send a Close frame (1000, or the given code and reason of at most 123 UTF-8 bytes), give the outgoing queue up to 5 seconds to flush, then dispose the TCP connection. Sending afterwards throws ChannelClosedException |
Dispose() |
Alias for Close() (implements IDisposable) |
OnMessageReceived |
EventHandler<IWebSocketClient, string> — complete text message |
OnBytesReceived |
EventHandler<IWebSocketClient, byte[]> — complete binary message |
OnCloseReceived |
EventHandler<IWebSocketClient, CloseMessage> — close frame received |
OnPingReceived |
EventHandler<IWebSocketClient, byte[]> — Ping frame received |
OnPongReceived |
EventHandler<IWebSocketClient, byte[]> — Pong frame received |
OnClientClose |
Action<IWebSocketClient> — connection closed (local or remote) |
OnError |
EventHandler<IWebSocketClient, Exception> — a background task failed, or one of your event handlers threw (the connection stays open in that case); falls back to the console when unsubscribed. A peer that simply disconnects is not an error |
WebSocketClient
| Member | Description |
|---|---|
CreateWebSocketClient(string url, HttpHeaders? headers) |
Static — creates a client connection to a WebSocket server. ws:// only; wss:// throws NotSupportedException |
HttpContext |
The underlying HTTP context for this connection |
Status |
Current ClientStatus |
MaxMessageSize |
Largest reassembled incoming message in bytes (default 4 MiB) |
SendMessage(string) / SendMessageAsync(string) |
Send a UTF-8 text frame |
SendByte(byte[]) / SendByteAsync(byte[]) |
Send a binary frame |
Ping(byte[]) / PingAsync(byte[]) |
Send a Ping frame |
Pong(byte[]) / PongAsync(byte[]) |
Send a Pong frame |
Open() |
Start background send/receive loops |
Close() / Close(CloseCode, string?) |
Send a Close frame (1000, or the given code and reason of at most 123 UTF-8 bytes), give the outgoing queue up to 5 seconds to flush, then dispose the TCP connection. Sending afterwards throws ChannelClosedException |
Dispose() |
Alias for Close() |
IHttpContext (interface)
| Member | Description |
|---|---|
Request |
Parsed HTTP request (method, URL, headers) |
Response |
HTTP response object |
Stream |
The underlying network stream |
Status |
Current HttpContextStatus |
HttpUpgrade(int capacity = 1024) |
Completes the WebSocket handshake and returns the WebSocketClient |
HttpContextBase (abstract class)
Base class implementing IHttpContext. Provides HTTP request parsing, timeout handling, and the HttpUpgrade() handshake logic. The concrete HttpContext class adds TcpClient support.
HttpContext (sealed, extends HttpContextBase)
| Member | Description |
|---|---|
Request |
Parsed HTTP request (method, URL, headers) |
Response |
HTTP response object (nullable; populated for server-side upgrades) |
TcpClient |
The underlying TCP connection |
Stream |
The underlying network stream |
Status |
Current HttpContextStatus (NotInitialized / Initialized / Upgraded / TimedOut / Rejected) |
HttpUpgrade(int capacity = 1024) |
Computes Sec-WebSocket-Accept, writes 101 Switching Protocols, returns the WebSocketClient |
WebSocketClient |
The upgraded client (populated after HttpUpgrade()) |
Dispose() |
Disposes the TCP client if no upgrade was performed |
HttpRequest
| Member | Description |
|---|---|
Method |
HTTP method (e.g. GET) |
Url |
Request path (e.g. /chat) |
HttpVersion |
HTTP version string (e.g. HTTP/1.1) |
Headers |
Parsed request headers (System.Net.Http.Headers.HttpHeaders, case-insensitive keys) |
WriteToStream(Stream) |
Writes the request line and headers to a stream (used for client-side handshake) |
HttpResponse
| Member | Description |
|---|---|
StatusCode |
HTTP status code (e.g. HttpStatusCode.SwitchingProtocols) |
HttpVersion |
HTTP version string (e.g. HTTP/1.1) |
Headers |
Response headers (System.Net.Http.Headers.HttpHeaders) |
DataFrame
| Member | Description |
|---|---|
CreateDataFrame(OpCode, bool FIN, byte[]? key, byte[] data) |
Build a single frame from a byte array |
CreateDataFrame(OpCode, byte[]? key, Stream data, int maxLen) |
Split a stream into multiple frames |
FIN / RSV1 / RSV2 / RSV3 |
Frame control flags |
Opcode |
OpCode enum value |
Masked |
Whether the payload is XOR-masked |
MaskingKey |
4-byte masking key (null if unmasked) |
DataFrameHeader |
Serialized frame header (2–14 bytes) |
Data |
Payload as ArraySegment<byte> |
CloseMessage
| Member | Description |
|---|---|
CloseCode |
RFC 6455 status code (e.g. Normal = 1000) |
Message |
Optional human-readable reason |
Delegates
EventHandler<TSender, TEventArgs> — Custom delegate defined in LHZ.WebSocket.Delegates
public delegate void EventHandler<in TSender, TEventArgs>(TSender sender, TEventArgs e);
Enums
OpCode — Continuation (0x0), Text (0x1), Binary (0x2), Close (0x8), Ping (0x9), Pong (0xA)
CloseCode — All RFC 6455 codes: Normal (1000), GoingAway (1001), ProtocolError (1002), …, TlsHandshake (1015)
ClientStatus — Connection, Opened, Close
ServerStatus — Ready, Start, Closing, Closed
HttpContextStatus — NotInitialized (0), Initialized (1), Upgraded (2), TimedOut (-2), Rejected (-1)
Architecture
sequenceDiagram
participant Peer
participant TcpListener
participant WebSocketServer
participant HttpContext
participant WebSocketClient
Peer->>TcpListener: TCP connect
TcpListener->>WebSocketServer: AcceptTcpClientAsync()
WebSocketServer->>HttpContext: Parse HTTP upgrade request
WebSocketServer->>+Peer: OnUpgradeRequest (user code calls HttpUpgrade())
HttpContext->>Peer: HTTP 101 + Sec-WebSocket-Accept
HttpContext->>WebSocketClient: new WebSocketClient(httpContext)
WebSocketClient->>WebSocketClient: Open() → StartReceiver() + StartSender()
Peer->>WebSocketClient: Data frames
WebSocketClient->>Peer: OnMessageReceived / OnBytesReceived / OnPingReceived / OnPongReceived
WebSocketClient-->>Peer: SendMessage() / SendByte() / Ping() / Pong()
Internally, each WebSocketClient runs two background tasks:
- Receiver — reads frames via
DataFrameReader, reassembles fragmented messages, dispatches by opcode - Sender — dequeues frames from a bounded
Channel<DataFrame>, writes header + payload to the network stream
Project Structure
LHZ.WebSocket/
├── src/
│ ├── LHZ.WebSocket/ # Core library
│ │ ├── WebSocketServer.cs # TCP listener & client lifecycle
│ │ ├── WebSocketClient.cs # Per-connection send/receive (server & client)
│ │ ├── Core/
│ │ │ ├── CloseMessage.cs # Close frame payload
│ │ │ ├── DataFrame.cs # Frame builder / parser (RFC 6455 §5.2)
│ │ │ └── DataFrameReader.cs # Frame reader from Stream
│ │ ├── Delegates/
│ │ │ └── EventHandler.cs # Custom event delegate with `in` modifier
│ │ ├── Enums/
│ │ │ ├── ClientStatus.cs # Client lifecycle states
│ │ │ ├── CloseCode.cs # RFC 6455 close status codes
│ │ │ ├── HttpContextStatus.cs # HTTP context lifecycle states
│ │ │ ├── OpCode.cs # Frame opcodes
│ │ │ └── ServerStatus.cs # Server lifecycle states
│ │ ├── Http/
│ │ │ ├── HttpContext.cs # HTTP upgrade handshake (server & client)
│ │ │ ├── HttpContextBase.cs # Abstract base with handshake & timeout logic
│ │ │ ├── HttpHeaders.cs # Internal header collection
│ │ │ ├── HttpRequest.cs # HTTP request-line & header parser/writer
│ │ │ └── HttpResponse.cs # HTTP response builder & parser
│ │ └── Interfaces/
│ │ ├── IHttpContext.cs # HTTP context interface
│ │ └── IWebSocketClient.cs # WebSocket client interface
│ ├── LHZ.WebSocket.Test/ # xUnit test project
│ │ ├── WebSocketServerTests.cs
│ │ ├── WebSocketClientTests.cs
│ │ └── Core/ / Http/
│ └── LHZ.WebSocket.slnx # Solution file
├── LICENSE
├── README.md
└── README.zh-CN.md
Requirements
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 is compatible. net5.0-windows was computed. net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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
- No dependencies.
-
net5.0
- No dependencies.
-
net6.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on LHZ.WebSocket:
| Package | Downloads |
|---|---|
|
LHZ.WebSocket.AspNetCore
A lightweight ASP.NET Core middleware wrapper for the LHZ.WebSocket library, enabling WebSocket upgrade handling and client tracking in ASP.NET Core applications. |
GitHub repositories
This package is not used by any popular GitHub repositories.
1.3.0:
- BREAKING: IWebSocketClient gained Close(CloseCode, string?), so any third-party implementation of the interface must be updated
- BREAKING: DataFrame is now backed by a pooled buffer and implements IDisposable. DataFrame.Data and DataFrameHeader return spans, and DataFrameHeaderFull was removed. Callers using Data.Count/Data.Array/Data.Offset must switch to Data.Length and span indexing, and must dispose frames they create
- Change: Close() now sends a Close frame (1000) and gives the outgoing queue up to 5 s to flush before tearing down. New overload Close(CloseCode, string?), the reason is limited to 123 UTF-8 bytes
- Change: an exception thrown by a user event handler is reported through OnError and no longer closes the connection, and each subscriber is invoked separately
- Change: a peer that drops the TCP connection closes the client quietly instead of raising OnError or writing to the console
- Perf: frame buffers are pooled and text is encoded straight into the pooled frame buffer. On a loopback echo benchmark against 1.2.0, single-message round trip latency is about 33 percent lower with about 45 percent fewer allocations, and pipelined small messages are 42-65 percent faster with 28-57 percent fewer allocations
- Fix: sending after the connection is closed throws ChannelClosedException instead of queueing silently until the bounded channel fills and the synchronous Send blocks forever
- Fix: Send() no longer throws InvalidOperationException when the outgoing channel is full
- Fix: DataFrame.CreateDataFrame(string) produced a non-final (FIN=0) frame
- Fix: CreateDataFrame(Stream) used the wrong header length for a short final fragment and dropped the terminating FIN frame when the stream ended exactly on a fragment boundary
- Fix: WebSocketServer.Stop no longer overwrites the state of a server that was restarted in the meantime
- Add: the declared payload length is validated from the frame header alone, so non-minimal 16/64-bit length encodings, control frames over 125 bytes and data frames over MaxMessageSize (1009) are refused before any buffer is allocated
- Add: HTTP handshake lines are capped at 8 KB and headers at 100, the server answers 431
1.2.0:
- BREAKING: long-standing misspellings in the public API were corrected. Rename call sites as follows: ClientStatus.Opend -> ClientStatus.Opened, OnCloseRecived -> OnCloseReceived, OnPingRecived -> OnPingReceived, OnPongRecived -> OnPongReceived, DataFrame.DataDataFrameFlag -> DataFrame.DataFrameFlag. The old names are gone rather than deprecated, so this is a compile error you fix once, not a silent behaviour change
- BREAKING (named arguments only): two parameters were renamed - CreateWebSocketClient's timeOUt -> timeOut, and CreateDataFrame's dataDataFrameLength -> dataFrameLength. Positional callers are unaffected
- BREAKING: IWebSocketClient gained members (the async send methods, MaxMessageSize and OnError), so any third-party implementation of the interface must be updated
- Fix: frames larger than 65535 bytes wrote an uninitialized 64-bit length field, because the send path rents an uncleared buffer from ArrayPool. The peer read a bogus length and the connection desynchronized
- Fix: ApplyMask masked too few bytes for payload segments with a non-zero offset
- Fix: the sender could return the same pooled buffer twice, corrupting ArrayPool for the whole process
- Fix: an all-zero masking key was treated as "not masked", dropping the MASK bit
- Fix: OnClientConnected was declared and documented but never raised
- Fix: Close() was check-then-act, so concurrent callers could double-dispose the socket and raise OnClientClose more than once
- Fix: close codes 3000-4999 are accepted per RFC 6455 §7.4 instead of being treated as errors; reserved codes (1004, 1005, 1006, 1015) are now rejected and never echoed back
- Fix: the handshake timeout could dispose the stream while the 101 response was being written, and its timer outlived every connection
- Fix: the server could stay stuck in the Closing state, making Start() refuse to run again
- Fix: header serialization no longer comma-joins multiple values and no longer throws on a header with no values
- Fix: HTTP response status codes are parsed numerically, so unusual codes no longer throw
- Fix: CreateWebSocketClient no longer mutates the caller's header collection, disposes the socket when the handshake fails, and rejects wss:// explicitly instead of connecting in plaintext
- Fix: the client handshake key is drawn from a cryptographic RNG instead of Guid.NewGuid
- Add: protocol validation the implementation previously lacked - reserved bits must be zero, incoming frames must match the peer's masking role, control frames are capped at 125 bytes, text payloads must be valid UTF-8 (1007)
- Add: MaxMessageSize (default 4 MiB) caps reassembled messages, closing the unbounded-memory path a peer could open by never setting FIN
- Add: automatic Pong reply to Ping, as required by RFC 6455 §5.5.2
- Add: SendMessageAsync, SendByteAsync, PingAsync and PongAsync, so sending no longer has to block when the outgoing queue is full
- Add: OnError on both client and server, and WebSocketProtocolException, replacing Console.WriteLine as the only way to observe failures
- Add: WebSocketServer.Status
- Change: a protocol violation now sends a Close frame stating the reason before the connection is failed
1.1.1:
- Fix: handshake handling is now concurrent, a slow client no longer blocks the accept loop
- Fix: client-role connections now mask every outgoing frame (RFC 6455 §5.1), with a cryptographic masking key
- Fix: the client collection is now per server instance instead of static
- Fix: OnClientClose event subscription no longer leaks
- Fix: masking no longer mutates the caller's buffer
- Fix: client handshake can parse restricted response headers (e.g. Connection)
- Add: server-side handshake validation (Sec-WebSocket-Key / Sec-WebSocket-Version 13) with HTTP 400 rejection
- Add: automatic Close frame reply on receiving a Close frame
- Add: fragmentation validation (reject orphan Continuation frames, control frames are never merged into messages)
- Add: 10 interop tests (client-server round trip, masking, concurrent handshakes, handshake validation, close reply, fragmentation)