This project originated from using Ninja WebSocket for local communication scenarios rather than server deployments. For server-side websocket communication, I recommend using ASP.NET Core WebSockets or SignalR instead.
The typical use case is a small Windows service that local user interfaces or lightweight tools connect to.
While working with Ninja WebSocket, I found a bug, fixed it, and took the opportunity to update and refactor the project. The goal was to simplify its usage, add better logging capabilities, and improve expandability and performance.
A modern, flexible, and extensible WebSocket server library for .NET, built on top of System.Net.WebSockets and designed for high-performance, feature-rich test or local applications.
Shinobi.WebSockets lets you build WebSocket servers with a fluent builder API, optional compression, CORS, SSL/TLS, authentication, and custom event hooks.
Origin: This library is a complete refactor and extension of Ninja.WebSockets by David Haig, updated with modern APIs (
ValueTask,CancellationToken,Shared Buffers), improved extensibility, and new features.
- Fully asynchronous WebSocket server implementation
- Fluent
WebSocketServerBuilderconfiguration API - Per-message Deflate compression (RFC 7692)
- SSL/TLS support (including ASP.NET Core dev certs)
- CORS support
- Authentication hooks
- HTTP keep-alive support with timeout and connection limits
- Connection, message, and error event interceptors
- Built-in logging integration with
Microsoft.Extensions.Logging CancellationTokenandValueTasksupport
using Microsoft.Extensions.Logging;
using Shinobi.WebSockets;
using Shinobi.WebSockets.Builders;
using Shinobi.WebSockets.Extensions;
var loggerFactory = LoggerFactory.Create(builder => builder
.SetMinimumLevel(LogLevel.Information)
.AddConsole());
var server = WebSocketServerBuilder.Create()
.UsePort(8080)
.UseDevCertificate()
.UseLogging(loggerFactory)
.UsePerMessageDeflate(x =>
{
x.ServerContextTakeover = ContextTakeoverMode.ForceDisabled;
x.ClientContextTakeover = ContextTakeoverMode.ForceDisabled;
})
.OnConnect(async (webSocket, next, token) =>
{
var logger = loggerFactory.CreateLogger("Server");
logger.LogInformation("Client connected: {Id}", webSocket.Context.Guid);
await webSocket.SendTextAsync("Welcome to Shinobi.WebSockets!", token);
await next(webSocket, token);
})
.OnTextMessage((webSocket, message, token) =>
{
return webSocket.SendTextAsync($"ECHO: {message}", token);
})
.Build();
await server.StartAsync();
Console.WriteLine("Server running on wss://localhost:8080");
Console.ReadLine();
await server.StopAsync();Install the package via NuGet Package Manager:
dotnet add package Shinobi.WebSocketsOr via the Package Manager Console in Visual Studio:
Install-Package Shinobi.WebSocketsYou can also add it directly to your project file:
<PackageReference Include="Shinobi.WebSockets" Version="1.0.0" />Note: Replace "1.0.0" with the latest version number available on NuGet.
Shinobi.WebSockets supports two authentication approaches to work with both C# and JavaScript clients:
C# clients can use standard HTTP Authorization headers:
var client = WebSocketClientBuilder.Create()
.AddHeader("Authorization", "Bearer your-token-here")
.Build();JavaScript clients cannot set custom headers, so use the Authorization:[Token] subprotocol pattern:
// JavaScript WebSocket with auth subprotocol
const ws = new WebSocket('wss://localhost:8080', ['Authorization:your-jwt-token-here']);Configure the server to validate tokens from both headers and subprotocols:
var server = WebSocketServerBuilder.Create()
.OnHandshake(async (context, next, cancellationToken) =>
{
var isAuthenticated = false;
string? selectedProtocol = null;
// Check Authorization header (C# clients)
var authHeader = context.HttpRequest?.GetHeaderValue("Authorization");
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
{
var token = authHeader.Substring("Bearer ".Length);
if (await ValidateTokenAsync(token))
{
isAuthenticated = true;
}
}
// Check Authorization:[Token] subprotocol (JS clients)
if (!isAuthenticated)
{
foreach (var protocol in context.WebSocketRequestedProtocols)
{
if (protocol.StartsWith("Authorization:"))
{
var token = protocol.Substring("Authorization:".Length);
if (await ValidateTokenAsync(token))
{
selectedProtocol = protocol;
isAuthenticated = true;
break;
}
}
}
}
if (!isAuthenticated)
{
return HttpResponse.Create(401)
.AddHeader("Connection", "close")
.WithBody("Authentication required");
}
var response = await next(context, cancellationToken);
// Echo back the selected Authorization subprotocol
if (!string.IsNullOrEmpty(selectedProtocol))
{
response = response.AddHeader("Sec-WebSocket-Protocol", selectedProtocol);
}
return response;
})
.Build();
// Example token validation
private static async Task<bool> ValidateTokenAsync(string token)
{
// Validate JWT, check database, verify claims, etc.
// For demo: accept hardcoded token or JWT format
if (token == "demo-token-12345") return true;
// Example JWT validation
var parts = token.Split('.');
if (parts.Length == 3)
{
// Could decode and validate JWT claims, expiration, signature
return true; // Simplified for demo
}
return false;
}Shinobi.WebSockets supports HTTP keep-alive connections for better performance when clients make multiple HTTP requests before upgrading to WebSocket. You can configure timeout and connection limits to prevent resource exhaustion:
var server = WebSocketServerBuilder.Create()
.UseKeepAliveTimeout(TimeSpan.FromSeconds(5)) // Timeout for detecting idle connections
.UseMaxKeepAliveConnections(100) // Limit to 100 concurrent keep-alive connections
.OnHandshake(async (context, next, cancellationToken) =>
{
// Your handshake logic
return await next(context, cancellationToken);
})
.Build();- Timeout Protection: Idle keep-alive connections are detected and closed using first-byte timeout (doesn't affect active data transfer)
- Connection Limits: When the limit is reached, the oldest idle connection is evicted (LRU)
- WebSocket Transition: Once a connection upgrades to WebSocket, it's no longer counted as keep-alive
- Resource Management: Proper cleanup ensures connection counters remain accurate
KeepAliveTimeout(default: 5 seconds) - Timeout for detecting idle connections (applies only to first byte, not full request)MaxKeepAliveConnections(default: 1000) - Maximum concurrent keep-alive connections- Set
KeepAliveTimeouttoTimeSpan.Zeroto disable timeout - Set
MaxKeepAliveConnectionsto0for unlimited connections
Note: WebSocketClientFactory has been removed. All connection logic is now integrated directly into WebSocketClient/WebSocketClientBuilder.
using var client = WebSocketClientBuilder.Create()
.OnTextMessage((ws, message, ct) =>
{
Console.WriteLine($"Received: {message}");
return default(ValueTask);
})
.Build();
// Start connection
await client.StartAsync(new Uri("ws://localhost:8080"), cancellationToken);
// Send messages using the new API
await client.SendTextAsync("Hello WebSocket!", cancellationToken);
await client.SendBinaryAsync(new byte[] { 1, 2, 3, 4, 5 }, cancellationToken);
// Check connection state
Console.WriteLine($"Connection state: {client.ConnectionState}");
// Stop connection
await client.StopAsync();using var client = WebSocketClientBuilder.Create()
.UseReliableConnection() // Enables auto-reconnect with sensible defaults
.OnTextMessage((message, ct) => // Simplified handler
{
Console.WriteLine($"Received: {message}");
return default(ValueTask);
})
.Build();
await client.StartAsync(uri);
// Client will automatically reconnect if connection is lostusing var client = WebSocketClientBuilder.Create()
.UseAutoReconnect(options =>
{
options.InitialDelay = TimeSpan.FromSeconds(2);
options.MaxDelay = TimeSpan.FromMinutes(1);
options.BackoffMultiplier = 2.0;
options.Jitter = 0.1; // Add 10% random jitter
})
.Build();var fallbackUrls = new[]
{
new Uri("ws://backup1.example.com/websocket"),
new Uri("ws://backup2.example.com/websocket"),
new Uri("ws://backup3.example.com/websocket")
};
using var client = WebSocketClientBuilder.Create()
.EnableAutoReconnect()
.UseFallbackUrls(fallbackUrls)
.OnReconnecting(async (currentUri, attemptNumber, ct) =>
{
Console.WriteLine($"Reconnecting to {currentUri} (attempt {attemptNumber})");
// You can modify the URI here for custom logic
return currentUri;
})
.Build();using var client = WebSocketClientBuilder.Create()
.UseReliableConnection()
.Build();
client.ConnectionStateChanged += (client, e) =>
{
Console.WriteLine($"Connection state changed: {e.PreviousState} -> {e.NewState}");
if (e.Exception != null)
{
Console.WriteLine($"Error: {e.Exception.Message}");
}
};
client.Reconnecting += (client, e) =>
{
Console.WriteLine($"Reconnecting to {e.CurrentUri} in {e.Delay} (attempt {e.AttemptNumber})");
};
// Comprehensive logging is automatically included when you configure a logger
using var loggerFactory = LoggerFactory.Create(builder => builder
.SetMinimumLevel(LogLevel.Information) // Set to Debug for more detailed logs
.AddConsole());
using var client = WebSocketClientBuilder.Create()
.UseLogging(loggerFactory) // This enables all reconnect logging
.UseReliableConnection()
.Build();using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
using var client = WebSocketClientBuilder.Create()
.UseKeepAlive(TimeSpan.FromSeconds(30))
.UseNoDelay(true)
.UsePerMessageDeflate()
.AddHeader("Authorization", "Bearer your-token")
.UseLogging(loggerFactory)
.UseExponentialBackoff(
initialDelay: TimeSpan.FromSeconds(1),
maxDelay: TimeSpan.FromSeconds(30),
multiplier: 2.0,
jitter: 0.1)
.OnConnect(async (ws, next, ct) =>
{
Console.WriteLine("Connected!");
await next(ws, ct);
})
.OnClose(async (ws, next, ct) =>
{
Console.WriteLine("Connection closed");
await next(ws, ct);
})
.OnError(async (ws, ex, next, ct) =>
{
Console.WriteLine($"Error: {ex.Message}");
await next(ws, ex, ct);
})
.OnTextMessage((ws, message, ct) =>
{
Console.WriteLine($"Text: {message}");
return default(ValueTask);
})
.OnBinaryMessage((ws, data, ct) =>
{
Console.WriteLine($"Binary: {data.Length} bytes");
return default(ValueTask);
})
.Build();The ConnectionState property provides real-time connection status:
Disconnected- Not connectedConnecting- Establishing connectionConnected- Successfully connectedReconnecting- Attempting to reconnectDisconnecting- Shutting down connectionFailed- Connection failed (after max attempts if configured)
When you configure logging with UseLogging(), the WebSocketClient automatically logs comprehensive information about reconnection attempts:
- Connection closed: "WebSocket connection closed, checking reconnect options"
- Auto-reconnect disabled: "Auto-reconnect is disabled, staying disconnected"
- Starting reconnect: "Starting auto-reconnect sequence (attempt N)"
- Reconnect attempt: "Reconnecting to ws://example.com in 2000ms (attempt 3)"
- Successful reconnect: "Successfully reconnected to ws://example.com after 3 attempts"
- Reconnection cancelled: "WebSocket reconnection cancelled"
- Max attempts exceeded: "Maximum reconnect attempts (5) exceeded"
- Connection errors: "Connection error (attempt 2)" with full exception details
- State changes: "WebSocket connection state changed from Connected to Reconnecting"
- OnReconnecting handler: "Calling OnReconnecting handler for attempt 2"
- URI changes: "OnReconnecting handler changed URI from ws://primary.com to ws://backup.com"
info: WebSocket connection closed, checking reconnect options
info: Starting auto-reconnect sequence (attempt 1)
info: Reconnecting to ws://localhost:8080 in 1000ms (attempt 1)
dbug: WebSocket connection state changed from Reconnecting to Connecting
info: Successfully reconnected to ws://localhost:8080 after 1 attempts
var factory = new WebSocketClientFactory();
var webSocket = await factory.ConnectAsync(uri, options);
await webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, ct);
webSocket.Dispose();using var client = WebSocketClientBuilder.Create().Build();
await client.StartAsync(uri);
await client.SendTextAsync(message, ct);
await client.StopAsync();
// Disposal is handled by using statementvar factory = new WebSocketClientFactory();
var webSocket = await factory.ConnectAsync(uri, options);
var buffer = Encoding.UTF8.GetBytes("Hello");
await webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, ct);
webSocket.Dispose();var client = WebSocketClientBuilder.Create().Build();
await client.StartAsync(uri);
await client.SendTextAsync("Hello", ct);
// client.ConnectionState property available
// Auto-reconnect support built-in
await client.StopAsync(); // or dispose clientA full server example is included in the Shinobi.WebSockets.DemoServer project:
- Echoes text messages
- Supports time and help commands
- Logs connections and disconnections
- Supports SSL/TLS with dev certificate
- Demonstrates per-message deflate compression
Run it with:
dotnet run --project Shinobi.WebSockets.DemoServerA comprehensive console-based client demo is included in the Shinobi.WebSockets.DemoClient project that showcases all WebSocket client features:
- Interactive command-line interface
- Connection management (connect/disconnect)
- Text and binary message sending
- Built-in stress testing capabilities
- Auto-reconnect demonstration
- Real-time connection state monitoring
- Connection statistics and timing
Run it with:
dotnet run --project Shinobi.WebSockets.DemoClientOnce running, the demo client supports these interactive commands:
connect [url] - Connect to WebSocket server (default: wss://localhost:8080)
disconnect - Disconnect from server
send <message> - Send a text message
binary <message> - Send a binary message
ping - Send ping command
time - Send time command
serverhelp - Send help command to server
stress [count] - Run stress test (default: 1000 messages)
stopstress - Stop running stress test
reconnect - Enable auto-reconnect features
stats - Show connection statistics
status - Show connection status
clear - Clear the console
help/? - Show command help
quit/exit - Exit the application
-
Start the server in one terminal:
dotnet run --project Shinobi.WebSockets.DemoServer
-
Start the client in another terminal:
dotnet run --project Shinobi.WebSockets.DemoClient
-
In the client, type
connectto connect to the demo server, then try various commands like:send Hello World!timestress 100reconnect
This project is licensed under the MIT License – see the LICENCE.md file for details.
- Original work: Ninja.WebSockets by David Haig
- Refactor, new features, and maintenance: Hempe