Skip to content

DotBoxD WebSocket Transport Setup Guide

This guide covers implementing and using WebSocket transport with DotBoxD. WebSocket transport is ideal for browser clients, Unity WebGL builds, and scenarios requiring HTTP-based connectivity.

This is an illustrative custom-transport walkthrough, not a shipped or CI-compiled DotBoxD.Transports.WebSocket package. Copy it into an application-owned project, pin resource limits, and maintain it against the current IRpcChannel API before production use.

DotBoxD is transport-agnostic. This guide shows how to create a WebSocket transport by implementing the core transport interfaces:

  • ITransport - Client-side transport
  • IServerTransport - Server-side transport
  • IRpcChannel - Bidirectional connection abstraction

Create a new class library project:

Terminal window
dotnet new classlib -n DotBoxD.Transports.WebSocket -f netstandard2.1

Add the required references to DotBoxD.Transports.WebSocket.csproj:

<ItemGroup>
<ProjectReference Include="../DotBoxD.Services/DotBoxD.Services.csproj" />
</ItemGroup>

The connection class wraps a WebSocket and implements IRpcChannel:

WebSocketConnection.cs
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using DotBoxD.Services.Transport;
using DotBoxD.Services.Buffers;
namespace DotBoxD.Transports.WebSocket;
/// <summary>
/// WebSocket-based connection implementation.
/// </summary>
public sealed class WebSocketConnection : IRpcChannel
{
private readonly System.Net.WebSockets.WebSocket _socket;
private readonly string _remoteEndpoint;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private bool _disposed;
public WebSocketConnection(System.Net.WebSockets.WebSocket socket, string remoteEndpoint)
{
_socket = socket ?? throw new ArgumentNullException(nameof(socket));
_remoteEndpoint = remoteEndpoint ?? "unknown";
}
public bool IsConnected => _socket.State == WebSocketState.Open && !_disposed;
public string RemoteEndpoint => _remoteEndpoint;
public async Task SendAsync(ReadOnlyMemory<byte> data, CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(WebSocketConnection));
}
await _sendLock.WaitAsync(ct);
try
{
await _socket.SendAsync(
data,
WebSocketMessageType.Binary,
endOfMessage: true,
ct);
}
finally
{
_sendLock.Release();
}
}
public async Task<Payload> ReceiveAsync(CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(WebSocketConnection));
}
// Read the complete WebSocket message
const int maxMessageBytes = 16 * 1024 * 1024;
var buffer = ArrayPool<byte>.Shared.Rent(16 * 1024); // 16KB initial buffer
var totalReceived = 0;
try
{
WebSocketReceiveResult result;
do
{
if (totalReceived >= maxMessageBytes)
{
throw new InvalidOperationException("Message exceeds the 16 MiB limit");
}
// Expand buffer if needed
if (totalReceived >= buffer.Length - 4096)
{
var newBuffer = ArrayPool<byte>.Shared.Rent(
Math.Min(buffer.Length * 2, maxMessageBytes));
Buffer.BlockCopy(buffer, 0, newBuffer, 0, totalReceived);
ArrayPool<byte>.Shared.Return(buffer);
buffer = newBuffer;
}
result = await _socket.ReceiveAsync(
buffer.AsMemory(totalReceived, maxMessageBytes - totalReceived),
ct);
if (result.MessageType == WebSocketMessageType.Close)
{
return Payload.Empty;
}
totalReceived += result.Count;
}
while (!result.EndOfMessage);
if (totalReceived == 0)
{
return Payload.Empty;
}
// Validate message length
if (totalReceived < 4)
{
throw new InvalidOperationException("Message too short");
}
var messageLength = BinaryPrimitives.ReadInt32LittleEndian(buffer.AsSpan(0, 4));
if (messageLength != totalReceived || messageLength > maxMessageBytes)
{
throw new InvalidOperationException($"Invalid message length: {messageLength}");
}
var message = Payload.Rent(totalReceived);
buffer.AsSpan(0, totalReceived).CopyTo(message.Memory.Span);
return message;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
try
{
if (_socket.State == WebSocketState.Open)
{
await _socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Connection closed",
CancellationToken.None);
}
}
catch
{
// Ignore errors during cleanup
}
_socket.Dispose();
_sendLock.Dispose();
}
}

The client transport manages ClientWebSocket connections:

WebSocketTransport.cs
using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using DotBoxD.Services.Transport;
namespace DotBoxD.Transports.WebSocket;
/// <summary>
/// WebSocket client transport implementation.
/// </summary>
public sealed class WebSocketTransport : ITransport
{
private readonly Uri _uri;
private readonly Action<ClientWebSocketOptions>? _configureOptions;
private ClientWebSocket? _socket;
private WebSocketConnection? _connection;
private bool _disposed;
/// <summary>
/// Creates a new WebSocket transport.
/// </summary>
/// <param name="uri">WebSocket server URI (e.g., ws://localhost:5050/rpc)</param>
/// <param name="configureOptions">Optional callback to configure WebSocket options</param>
public WebSocketTransport(string uri, Action<ClientWebSocketOptions>? configureOptions = null)
: this(new Uri(uri), configureOptions)
{
}
/// <summary>
/// Creates a new WebSocket transport.
/// </summary>
/// <param name="uri">WebSocket server URI</param>
/// <param name="configureOptions">Optional callback to configure WebSocket options</param>
public WebSocketTransport(Uri uri, Action<ClientWebSocketOptions>? configureOptions = null)
{
_uri = uri ?? throw new ArgumentNullException(nameof(uri));
_configureOptions = configureOptions;
}
public IRpcChannel? Connection => _connection;
public bool IsConnected => _connection?.IsConnected ?? false;
public async Task ConnectAsync(CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(WebSocketTransport));
}
if (_connection != null)
{
throw new InvalidOperationException("Already connected.");
}
_socket = new ClientWebSocket();
_configureOptions?.Invoke(_socket.Options);
await _socket.ConnectAsync(_uri, ct);
_connection = new WebSocketConnection(_socket, _uri.Host);
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection != null)
{
await _connection.DisposeAsync();
}
}
}

The server transport uses HttpListener for WebSocket upgrades:

WebSocketServerTransport.cs
using System;
using System.Net;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using DotBoxD.Services.Transport;
namespace DotBoxD.Transports.WebSocket;
/// <summary>
/// WebSocket server transport implementation using HttpListener.
/// </summary>
public sealed class WebSocketServerTransport : IServerTransport
{
private readonly HttpListener _listener;
private readonly string _path;
private readonly Channel<IRpcChannel> _connectionChannel;
private CancellationTokenSource? _cts;
private Task? _acceptTask;
private bool _disposed;
/// <summary>
/// Creates a new WebSocket server transport.
/// </summary>
/// <param name="port">Port to listen on</param>
/// <param name="path">WebSocket path (e.g., "/rpc")</param>
public WebSocketServerTransport(int port, string path = "/rpc")
: this($"http://+:{port}{path}/")
{
_path = path;
}
/// <summary>
/// Creates a new WebSocket server transport.
/// </summary>
/// <param name="prefix">HTTP listener prefix (e.g., "http://localhost:5050/rpc/")</param>
public WebSocketServerTransport(string prefix)
{
_listener = new HttpListener();
_listener.Prefixes.Add(prefix);
_path = new Uri(prefix.Replace("+", "localhost").Replace("*", "localhost")).AbsolutePath;
_connectionChannel = Channel.CreateUnbounded<IRpcChannel>();
}
public async Task StartAsync(CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(WebSocketServerTransport));
}
_listener.Start();
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_acceptTask = AcceptConnectionsAsync(_cts.Token);
await Task.CompletedTask;
}
private async Task AcceptConnectionsAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync();
if (!context.Request.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
context.Response.Close();
continue;
}
_ = HandleWebSocketAsync(context, ct);
}
catch (HttpListenerException) when (ct.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
}
}
private async Task HandleWebSocketAsync(HttpListenerContext context, CancellationToken ct)
{
try
{
var wsContext = await context.AcceptWebSocketAsync(null);
var remoteEndpoint = context.Request.RemoteEndPoint?.ToString() ?? "unknown";
var connection = new WebSocketConnection(wsContext.WebSocket, remoteEndpoint);
await _connectionChannel.Writer.WriteAsync(connection, ct);
}
catch
{
context.Response.StatusCode = 500;
context.Response.Close();
}
}
public async Task<IRpcChannel> AcceptAsync(CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(WebSocketServerTransport));
}
return await _connectionChannel.Reader.ReadAsync(ct);
}
public async Task StopAsync(CancellationToken ct = default)
{
_cts?.Cancel();
_listener.Stop();
_connectionChannel.Writer.Complete();
if (_acceptTask != null)
{
try
{
await _acceptTask;
}
catch (OperationCanceledException)
{
// Expected
}
}
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await StopAsync();
_listener.Close();
_cts?.Dispose();
}
}
using DotBoxD.Services;
using DotBoxD.Services.Generated;
using DotBoxD.Codecs.MessagePack;
using DotBoxD.Transports.WebSocket;
// A host turns every accepted WebSocket connection into a full peer.
await using var host = RpcHost
.Listen(new WebSocketServerTransport(5050, "/rpc"), new MessagePackRpcSerializer())
.ForEachPeer(peer => peer.ProvideMyService(new MyService()));
await host.StartAsync();
Console.WriteLine("WebSocket server running on ws://localhost:5050/rpc");
// Optional lifecycle hooks:
// host.PeerConnected += (_, args) => { /* args.Peer is the new RpcPeer */ };
// host.PeerDisconnected += (_, args) => { /* peer closed */ };
// host.AcceptError += (_, args) => { /* transport-level accept failure */ };
// Shutdown: await host.StopAsync(); (DisposeAsync also stops the host)
using DotBoxD.Services;
using DotBoxD.Services.Generated;
using DotBoxD.Codecs.MessagePack;
using DotBoxD.Transports.WebSocket;
var transport = new WebSocketTransport("ws://localhost:5050/rpc");
await transport.ConnectAsync();
// RejectInboundCalls signals a get-only intent: this peer calls out but refuses callbacks.
await using var peer = RpcPeer
.Over(transport.Connection!, new MessagePackRpcSerializer(),
new RpcPeerOptions { RejectInboundCalls = true })
.Start();
var service = peer.GetMyService();
var response = await service.GreetAsync(new GreetingRequest { Name = "World" });
Console.WriteLine(response.Message);

For production, use secure WebSocket connections:

// Client with WSS
var transport = new WebSocketTransport("wss://myserver.com/rpc");
// Server requires HTTPS certificate configuration
// Configure via netsh or use Kestrel for easier HTTPS setup
Section titled “6. Using with ASP.NET Core (Recommended for Production)”

For production servers, use ASP.NET Core’s built-in WebSocket support:

Program.cs
using DotBoxD.Services;
using DotBoxD.Services.Generated;
using DotBoxD.Codecs.MessagePack;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseWebSockets();
// We accept WebSocket upgrades ourselves, then wrap each connection in an RpcPeer.
var serializer = new MessagePackRpcSerializer();
var gameService = new GameService();
app.Map("/rpc", async context =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
var ws = await context.WebSockets.AcceptWebSocketAsync();
// WebSocketConnection implements IRpcChannel, so it plugs
// straight into RpcPeer.Over - no builder or manual dispatch loop required.
await using var connection = new WebSocketConnection(
ws, context.Connection.RemoteIpAddress?.ToString() ?? "unknown");
await using var peer = RpcPeer
.Over(connection, serializer)
.ProvideGameService(gameService)
.Start();
// Keep the request alive while the peer pumps the connection. The Disconnected
// event fires when the read loop ends after a remote close or read error.
var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
peer.Disconnected += (_, _) => closed.TrySetResult();
await closed.Task.WaitAsync(context.RequestAborted);
});
app.Run();

WebSocket transport is essential for Unity WebGL builds since raw TCP sockets are not available:

// Unity NetworkManager using WebSocket
public class NetworkManager : MonoBehaviour
{
[SerializeField] private string serverUrl = "wss://game.example.com/rpc";
private WebSocketTransport _transport;
private RpcPeer _peer;
private IGameService _gameService;
public async Task ConnectAsync()
{
_transport = new WebSocketTransport(serverUrl);
// GeneratedResolver is application code produced for this DTO set.
var serializer = MessagePackRpcSerializer.CreateWithResolver(GeneratedResolver.Instance);
await _transport.ConnectAsync();
_peer = RpcPeer
.Over(_transport.Connection!, serializer,
new RpcPeerOptions
{
RequestTimeout = TimeSpan.FromSeconds(30),
RejectInboundCalls = true, // get-only client
})
.Start();
_gameService = _peer.GetGameService();
}
public async Task DisconnectAsync()
{
if (_peer != null) await _peer.DisposeAsync();
if (_transport != null) await _transport.DisposeAsync();
}
}

Configure WebSocket options during connection:

var transport = new WebSocketTransport("ws://localhost:5050/rpc", options =>
{
// Set custom headers
options.SetRequestHeader("Authorization", "Bearer token");
// Configure keep-alive
options.KeepAliveInterval = TimeSpan.FromSeconds(30);
// Set buffer sizes
options.SetBuffer(8192, 8192);
});
// Using HttpListener
var transport = new WebSocketServerTransport(5050, "/rpc");
// Or with full prefix control
var transport = new WebSocketServerTransport("http://192.168.1.100:5050/rpc/");
AspectTCPWebSocket
Browser supportNoYes
Unity WebGLNoYes
Firewall friendlySometimes blockedUsually allowed (HTTP ports)
Proxy supportLimitedFull (HTTP proxies)
SSL/TLSManual setupBuilt-in (WSS)
Protocol overheadMinimalHTTP upgrade + framing
  • Verify the server URL includes the correct path (e.g., /rpc)
  • Check firewall rules for HTTP/HTTPS ports
  • On Windows, HttpListener may require admin rights or URL reservations

Run as administrator or reserve the URL:

Terminal window
netsh http add urlacl url=http://+:5050/rpc/ user=Everyone
  • Ensure the server is configured for WebSocket connections
  • Check that the path matches between client and server
  • Verify no proxy is interfering with WebSocket upgrades