Full port design for WebSocket connections from Go NATS server, including HTTP upgrade handshake, custom frame parser, compression, origin checking, and cookie-based auth.
11 KiB
WebSocket Support Design
Overview
Port WebSocket connection support from the Go NATS server (golang/nats-server/server/websocket.go, ~1,550 lines) to the .NET solution. Full feature parity: client/leaf/MQTT paths, HTTP upgrade handshake, custom frame parser with masking, permessage-deflate compression, browser compatibility, origin checking, cookie-based auth, and close frame handling.
Approach
Raw socket with manual HTTP upgrade and custom frame parser — no ASP.NET Core WebSocket middleware, no System.Net.WebSockets. Direct port of Go's frame-level implementation for full control over masking negotiation, compression, fragmentation, and browser quirks.
Self-contained WebSocket module under src/NATS.Server/WebSocket/ with a WsConnection Stream wrapper that integrates transparently with the existing NatsClient read/write loops.
Go Reference
server/websocket.go— Main implementation (1,550 lines)server/websocket_test.go— Tests (4,982 lines)server/opts.golines 518-610 —WebsocketOptsstructserver/client.go— Integration points (c.wsfield,wsRead,wsCollapsePtoNB)
File Structure
src/NATS.Server/WebSocket/
WsConstants.cs — Opcodes, frame limits, close codes, compression magic bytes
WsReadInfo.cs — Per-connection frame reader state machine
WsFrameWriter.cs — Frame construction, masking, compression, fragmentation
WsUpgrade.cs — HTTP upgrade handshake validation and 101 response
WsConnection.cs — Stream wrapper bridging WS frames <-> NatsClient read/write
WsOriginChecker.cs — Same-origin and allowed-origins validation
WsCompression.cs — permessage-deflate via DeflateStream
tests/NATS.Server.Tests/WebSocket/
WsUpgradeTests.cs
WsFrameTests.cs
WsCompressionTests.cs
WsOriginCheckerTests.cs
WsIntegrationTests.cs
Modified existing files:
NatsOptions.cs— AddWebSocketOptionsclassNatsServer.cs— Second accept loop for WebSocket portNatsClient.cs—IsWebSocketflag,WsInfometadata property
Constants (WsConstants.cs)
Direct port of Go constants:
| Constant | Value | Purpose |
|---|---|---|
WsTextMessage |
1 | Text frame opcode |
WsBinaryMessage |
2 | Binary frame opcode |
WsCloseMessage |
8 | Close frame opcode |
WsPingMessage |
9 | Ping frame opcode |
WsPongMessage |
10 | Pong frame opcode |
WsFinalBit |
0x80 | FIN bit in byte 0 |
WsRsv1Bit |
0x40 | RSV1 (compression) in byte 0 |
WsMaskBit |
0x80 | Mask bit in byte 1 |
WsMaxFrameHeaderSize |
14 | Max frame header bytes |
WsMaxControlPayloadSize |
125 | Max control frame payload |
WsFrameSizeForBrowsers |
4096 | Browser fragmentation limit |
WsCompressThreshold |
64 | Min payload size to compress |
| Close codes | 1000-1015 | RFC 6455 Section 11.7 |
| Paths | /, /leafnode, /mqtt |
Client type routing |
HTTP Upgrade Handshake (WsUpgrade.cs)
Input
Raw Stream (TCP or TLS) after socket accept.
Validation (RFC 6455 Section 4.2.1)
- Parse HTTP request line — must be
GET <path> HTTP/1.1 - Parse headers into dictionary
- Host header required
Upgradeheader must contain"websocket"(case-insensitive)Connectionheader must contain"Upgrade"(case-insensitive)Sec-WebSocket-Versionmust be"13"Sec-WebSocket-Keymust be present- Path routing:
/-> Client,/leafnode-> Leaf,/mqtt-> Mqtt - Origin checking via
WsOriginCheckerif configured - Compression: parse
Sec-WebSocket-Extensionsforpermessage-deflate - No-masking: check
Nats-No-Maskingheader (for leaf nodes) - Browser detection:
User-Agentcontains"Mozilla/", Safari fornocompfrag - Cookie extraction: map configured cookie names to values
- X-Forwarded-For: extract client IP
Response
HTTP/1.1 101 Switching Protocols\r\n
Upgrade: websocket\r\n
Connection: Upgrade\r\n
Sec-WebSocket-Accept: <base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))>\r\n
[Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n]
[Nats-No-Masking: true\r\n]
[Custom headers\r\n]
\r\n
Result Type
public readonly record struct WsUpgradeResult(
bool Success,
bool Compress,
bool Browser,
bool NoCompFrag,
bool MaskRead,
bool MaskWrite,
string? CookieJwt,
string? CookieUsername,
string? CookiePassword,
string? CookieToken,
string? ClientIp,
WsClientKind Kind);
public enum WsClientKind { Client, Leaf, Mqtt }
Error Handling
Return standard HTTP error responses (400, 403, etc.) with body text for invalid requests.
Frame Reading (WsReadInfo.cs)
State Machine
public struct WsReadInfo
{
public int Remaining; // Bytes left in current frame payload
public bool FrameStart; // Reading new frame header
public bool FirstFrame; // First frame of fragmented message
public bool FrameCompressed; // Message is compressed (RSV1)
public bool ExpectMask; // Client frames should be masked
public byte MaskKeyPos; // Position in 4-byte mask key
public byte[] MaskKey; // 4-byte XOR mask
public List<byte[]>? CompressedBuffers;
public int CompressedOffset;
}
Flow
- Parse frame header: FIN, RSV1, opcode, mask, length, mask key
- Handle control frames in-band (PING -> PONG, CLOSE -> close response)
- Unmask payload bytes (XOR with cycling 4-byte key, optimized for 8-byte chunks)
- If compressed: collect payloads across frames, decompress on final frame
- Return unframed NATS protocol bytes
Decompression
- Append magic trailer
[0x00, 0x00, 0xff, 0xff]before decompressing - Use
DeflateStreamfor decompression - Validate decompressed size against
MaxPayload
Frame Writing (WsFrameWriter.cs)
Per-Connection State
public sealed class WsFrameWriter
{
private readonly bool _compress;
private readonly bool _maskWrite;
private readonly bool _browser;
private readonly bool _noCompFrag;
private DeflateStream? _compressor;
}
Flow
- If compression enabled and payload > 64 bytes: compress via
DeflateStream - If browser client and payload > 4096 bytes: fragment into chunks
- Build frame headers: FIN | RSV1 | opcode | MASK | length | mask key
- If masking: generate random 4-byte key, XOR payload
- Control frame helpers:
EnqueuePing,EnqueuePong,EnqueueClose
Close Frame
- 2-byte status code (big-endian) + optional UTF-8 reason
- Reason truncated to 125 bytes with
"..."suffix - Status code mapping from
ClientClosedReason:- ClientClosed -> 1000 (Normal)
- Auth failure -> 1008 (Policy Violation)
- Parse error -> 1002 (Protocol Error)
- Payload too big -> 1009 (Message Too Big)
- Other -> 1001 (Going Away) or 1011 (Server Error)
WsConnection Stream Wrapper (WsConnection.cs)
Extends Stream to transparently wrap WebSocket framing around raw I/O:
ReadAsync: CallsWsReadto decode frames, buffers decoded payloads, returns NATS bytes to callerWriteAsync: Wraps payload in WS frames viaWsFrameWriter, writes to inner streamEnqueueControlMessage: Called by read loop for PING responses and close frames
NatsClient's FillPipeAsync and RunWriteLoopAsync work unchanged because WsConnection is a Stream.
NatsServer Integration
Second Accept Loop
StartAsync:
if WebSocket.Port > 0:
create _wsListener socket
bind to WebSocket.Host:WebSocket.Port
start RunWebSocketAcceptLoopAsync
WebSocket Accept Flow
Accept socket
-> TLS negotiation (reuse TlsConnectionWrapper)
-> WsUpgrade.TryUpgradeAsync (HTTP upgrade)
-> Create WsConnection wrapping stream
-> Create NatsClient with WsConnection as stream
-> Set IsWebSocket = true, attach WsUpgradeResult
-> RunClientAsync (same as TCP from here)
NatsClient Changes
public bool IsWebSocket { get; set; }flagpublic WsUpgradeResult? WsInfo { get; set; }metadata- No changes to read/write loops —
WsConnectionhandles framing transparently
Shutdown
ShutdownAsyncalso closes_wsListener- WebSocket clients receive close frames before TCP disconnect
LameDuckShutdownAsyncincludes WS clients in stagger-close
Configuration (WebSocketOptions)
public sealed class WebSocketOptions
{
public string Host { get; set; } = "0.0.0.0";
public int Port { get; set; } // 0 = disabled
public string? Advertise { get; set; }
public string? NoAuthUser { get; set; }
public string? JwtCookie { get; set; }
public string? UsernameCookie { get; set; }
public string? PasswordCookie { get; set; }
public string? TokenCookie { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? Token { get; set; }
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);
public bool NoTls { get; set; }
public string? TlsCert { get; set; }
public string? TlsKey { get; set; }
public bool SameOrigin { get; set; }
public List<string>? AllowedOrigins { get; set; }
public bool Compression { get; set; }
public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan? PingInterval { get; set; }
public Dictionary<string, string>? Headers { get; set; }
}
Validation Rules
- Port 0 = disabled, skip remaining validation
- TLS required unless
NoTls = true - AllowedOrigins must be valid URLs
- Custom headers must not use reserved names
- NoAuthUser must match existing user if specified
Origin Checking (WsOriginChecker.cs)
- No Origin header -> accept (per RFC for non-browser clients)
- If
SameOrigin: compare origin host:port with request Host header - If
AllowedOrigins: check host against list, match scheme and port
Parsed from config URLs, stored as Dictionary<string, (string scheme, int port)>.
Testing
Unit Tests
WsUpgradeTests.cs
- Valid upgrade -> 101
- Missing/invalid headers -> error codes
- Origin checking (same-origin, allowed, blocked)
- Compression negotiation
- No-masking header
- Browser/Safari detection
- Cookie extraction
- Path routing
- Handshake timeout
- Custom/reserved headers
WsFrameTests.cs
- Read uncompressed frames (various sizes)
- Length encoding (7-bit, 16-bit, 64-bit)
- Masking/unmasking round-trip
- Control frames (PING, PONG, CLOSE)
- Close frame status code and reason
- Invalid frames (missing FIN on control, oversized control)
- Fragmented messages
- Compressed frame round-trip
- Browser fragmentation at 4096
- Safari no-compressed-fragmentation
WsCompressionTests.cs
- Compress/decompress round-trip
- Below threshold not compressed
- Large payload compression
- MaxPayload limit on decompression
WsOriginCheckerTests.cs
- No origin -> accepted
- Same-origin match/mismatch
- Allowed origins match/mismatch
- Scheme and port matching
Integration Tests
WsIntegrationTests.cs
- Connect via raw WebSocket, CONNECT/INFO exchange
- PUB/SUB over WebSocket
- Multiple WS clients
- Mixed TCP + WS clients interoperating
- WS with compression
- Graceful close (close frame exchange)
- Server shutdown sends close frames
Post-Implementation
- Update
differences.mdto mark WebSocket support as implemented - Update ports file to include WebSocket port