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.WebSocketpackage. Copy it into an application-owned project, pin resource limits, and maintain it against the currentIRpcChannelAPI before production use.
Overview
Section titled “Overview”DotBoxD is transport-agnostic. This guide shows how to create a WebSocket transport by implementing the core transport interfaces:
ITransport- Client-side transportIServerTransport- Server-side transportIRpcChannel- Bidirectional connection abstraction
1. Create the WebSocket Transport Project
Section titled “1. Create the WebSocket Transport Project”Create a new class library project:
dotnet new classlib -n DotBoxD.Transports.WebSocket -f netstandard2.1Add the required references to DotBoxD.Transports.WebSocket.csproj:
<ItemGroup> <ProjectReference Include="../DotBoxD.Services/DotBoxD.Services.csproj" /></ItemGroup>2. Implement WebSocketConnection
Section titled “2. Implement WebSocketConnection”The connection class wraps a WebSocket and implements IRpcChannel:
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(); }}3. Implement Client Transport
Section titled “3. Implement Client Transport”The client transport manages ClientWebSocket connections:
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(); } }}4. Implement Server Transport
Section titled “4. Implement Server Transport”The server transport uses HttpListener for WebSocket upgrades:
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(); }}5. Using WebSocket Transport
Section titled “5. Using WebSocket Transport”Server Setup
Section titled “Server Setup”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)Client Setup
Section titled “Client Setup”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);Secure WebSocket (WSS)
Section titled “Secure WebSocket (WSS)”For production, use secure WebSocket connections:
// Client with WSSvar transport = new WebSocketTransport("wss://myserver.com/rpc");
// Server requires HTTPS certificate configuration// Configure via netsh or use Kestrel for easier HTTPS setup6. Using with ASP.NET Core (Recommended for Production)
Section titled “6. Using with ASP.NET Core (Recommended for Production)”For production servers, use ASP.NET Core’s built-in WebSocket support:
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();7. Unity WebGL Integration
Section titled “7. Unity WebGL Integration”WebSocket transport is essential for Unity WebGL builds since raw TCP sockets are not available:
// Unity NetworkManager using WebSocketpublic 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(); }}8. Configuration Options
Section titled “8. Configuration Options”Client Options
Section titled “Client Options”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);});Server Options
Section titled “Server Options”// Using HttpListenervar transport = new WebSocketServerTransport(5050, "/rpc");
// Or with full prefix controlvar transport = new WebSocketServerTransport("http://192.168.1.100:5050/rpc/");Key Differences from TCP Transport
Section titled “Key Differences from TCP Transport”| Aspect | TCP | WebSocket |
|---|---|---|
| Browser support | No | Yes |
| Unity WebGL | No | Yes |
| Firewall friendly | Sometimes blocked | Usually allowed (HTTP ports) |
| Proxy support | Limited | Full (HTTP proxies) |
| SSL/TLS | Manual setup | Built-in (WSS) |
| Protocol overhead | Minimal | HTTP upgrade + framing |
Troubleshooting
Section titled “Troubleshooting”Connection Refused
Section titled “Connection Refused”- Verify the server URL includes the correct path (e.g.,
/rpc) - Check firewall rules for HTTP/HTTPS ports
- On Windows,
HttpListenermay require admin rights or URL reservations
HttpListener Access Denied (Windows)
Section titled “HttpListener Access Denied (Windows)”Run as administrator or reserve the URL:
netsh http add urlacl url=http://+:5050/rpc/ user=EveryoneWebSocket Handshake Failed
Section titled “WebSocket Handshake Failed”- 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
Next Steps
Section titled “Next Steps”- Quick Start Guide - Basic DotBoxD setup
- Unity Integration - Full Unity setup including WebGL
- API Reference - Detailed API documentation