WebSocket
A two-way, message-oriented connection that stays open - with the parts a managed ClientWebSocket hides: binary frames, close codes, the negotiated sub-protocol, and the one back-pressure signal a browser socket offers.
@inject Bit.Butil.WebSocket webSocketMDN reference
/ws/echo on the demo server. It echoes text back with an
echo: prefix, echoes binary frames back with every byte incremented (so a real
round trip is distinguishable from keeping your own array), offers the
butil-echo sub-protocol, and understands two commands - close to make
the server close with an application code, and burst to flood the connection.
Returns true when the runtime exposes WebSocket. Supported by every current engine. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.
@inject Bit.Butil.WebSocket webSocket
var supported = await webSocket.IsSupported();A page served over HTTPS may only open wss:// - a ws:// URL is blocked as mixed content before the connection is attempted, which is why Open returns null rather than reporting an error. Sending before the connection is established fails rather than queueing, so SendText returns false until onOpen has run.
IAsyncDisposable
Bit.Butil.WebSocket webSocket
{
private WebSocketHandle? _socket;
private readonly List<string> _messages = [];
private async Task Open()
{
_socket = await webSocket.Open(
"wss://example.com/ws/echo",
onMessage: m => InvokeAsync(() =>
{
// m.IsBinary says which payload carries this frame
_messages.Insert(0, m.IsBinary ? $"{m.Data!.Length} bytes" : m.Text!);
StateHasChanged();
}),
protocols: ["butil-echo"],
onOpen: (protocol, extensions) => InvokeAsync(StateHasChanged),
onClose: c => InvokeAsync(StateHasChanged),
onError: () => InvokeAsync(StateHasChanged));
}
// False until onOpen has run: sending before the connection is established fails rather than
// queueing.
private async Task Send() => await _socket!.SendText("hello");
public async ValueTask DisposeAsync()
{
if (_socket is not null) await _socket.DisposeAsync();
}
}// A browser will not open a socket against an endpoint that does not answer the upgrade, so this is
// the other half of the sample rather than an aside.
app.UseWebSockets();
app.MapGet("/ws/echo", async (HttpContext context, CancellationToken cancellationToken) =>
{
if (context.WebSockets.IsWebSocketRequest is false)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
// Accept the client's protocol only when it actually offered it; accepting one that was not
// offered fails the handshake in the browser rather than degrading to none.
var requested = context.WebSockets.WebSocketRequestedProtocols;
var protocol = requested.Contains("butil-echo") ? "butil-echo" : null;
using var socket = protocol is null
? await context.WebSockets.AcceptWebSocketAsync()
: await context.WebSockets.AcceptWebSocketAsync(protocol);
var buffer = new byte[8 * 1024];
while (socket.State == System.Net.WebSockets.WebSocketState.Open)
{
var result = await socket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) break;
await socket.SendAsync(buffer.AsMemory(0, result.Count),
result.MessageType, result.EndOfMessage, cancellationToken);
}
});The socket is opened in arraybuffer mode, so an incoming binary frame arrives as bytes rather than a Blob that would need an extra asynchronous read per frame. The echo endpoint increments every byte it sends back, so a round trip is visible in the values rather than only in the length.
{
private WebSocketHandle? _socket; // from webSocket.Open
// The socket is opened in arraybuffer mode, so a binary frame arrives as bytes rather than as a
// Blob that would need an extra asynchronous read per frame.
private async Task Send() => await _socket!.SendBytes([1, 2, 3, 4]);
// ... and arrives in the onMessage callback as:
// m.IsBinary == true, m.Data == [2, 3, 4, 5]
}Sub-protocols are offered most-preferred first and the server picks one; offering a protocol the server does not know fails the connection outright rather than falling back to none. Extensions are what the server agreed to - permessage-deflate, usually - and are not something a page chooses.
{
private WebSocketHandle? _socket; // from webSocket.Open
private async Task Identify()
{
var protocol = await _socket!.GetProtocol(); // "butil-echo"
var extensions = await _socket.GetExtensions(); // e.g. "permessage-deflate"
var url = await _socket.GetUrl();
var state = await _socket.GetState(); // Connecting / Open / Closing / Closed
}
}Sending never blocks and never fails for being too fast, so a producer that outruns the connection just grows this number until the tab runs out of memory. It is the only back-pressure signal a browser socket has. Press Burst to make the server flood the connection, then read it while the frames are still arriving.
{
private WebSocketHandle? _socket; // from webSocket.Open
private async Task Pressure()
{
await _socket!.SendText("burst");
// The only back-pressure signal a browser socket has: sending never blocks and never fails
// for being too fast, so a producer that outruns the connection just grows this number.
var queued = await _socket.GetBufferedAmount();
if (queued > 1_000_000) { /* stop producing until it drains */ }
}
}Only 1000 and 3000-4999 may be sent from script; anything else is refused and the socket closes without a code instead. Code 1006 with WasClean false is the browser's stand-in for 'the connection dropped' - no close frame ever arrived, so there is nothing more specific to report. Nothing here reconnects: unlike EventSource, a WebSocket that drops stays dropped until you open another.
{
private WebSocketHandle? _socket; // from webSocket.Open
// Only 1000 and 3000-4999 may be sent from script; anything else is refused and the socket
// closes without a code instead.
private async Task CloseFromHere() => await _socket!.Close(4000, "done for now");
// ... or let the server close: this demo endpoint answers "close" with code 4001.
private async Task AskServerToClose() => await _socket!.SendText("close");
// Either way the same callback runs - the one given to Open:
// onClose: c => { /* c.Code, c.Reason, c.WasClean */ }
}WebSocket
service closes any leaked socket when its scope is torn down.
API reference
ValueTask<bool> IsSupported()ValueTask<WebSocketHandle?> Open(string url, Action<WebSocketMessage> onMessage, string[]? protocols = null, Action<string, string>? onOpen = null, Action<WebSocketClose>? onClose = null, Action? onError = null)ValueTask DisposeAsync()ValueTask<bool> SendText(string text)ValueTask<bool> SendBytes(byte[] data)ValueTask<WebSocketState> GetState()ValueTask<long> GetBufferedAmount()ValueTask<string> GetProtocol()ValueTask<string> GetExtensions()ValueTask<string> GetUrl()ValueTask Close(int? code = null, string? reason = null)ValueTask DisposeAsync()record (bool IsBinary, string? Text, byte[]? Data)record (int Code, string Reason, bool WasClean)