using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.WebSocket;
///
/// WebSocket frame construction, masking, and control message creation.
/// Ported from golang/nats-server/server/websocket.go lines 543-726.
///
public static class WsFrameWriter
{
///
/// Creates a complete frame header for a single-frame message (first=true, final=true).
/// Returns (header bytes, mask key or null).
///
public static (byte[] header, byte[]? key) CreateFrameHeader(
bool useMasking, bool compressed, int opcode, int payloadLength)
{
var fh = new byte[WsConstants.MaxFrameHeaderSize];
var (n, key) = FillFrameHeader(fh, useMasking,
first: true, final: true, compressed: compressed, opcode: opcode, payloadLength: payloadLength);
return (fh[..n], key);
}
///
/// Fills a pre-allocated frame header buffer.
/// Returns (bytes written, mask key or null).
///
public static (int written, byte[]? key) FillFrameHeader(
Span fh, bool useMasking, bool first, bool final, bool compressed, int opcode, int payloadLength)
{
byte b0 = first ? (byte)opcode : (byte)0;
if (final) b0 |= WsConstants.FinalBit;
if (compressed) b0 |= WsConstants.Rsv1Bit;
byte b1 = 0;
if (useMasking) b1 |= WsConstants.MaskBit;
int n;
switch (payloadLength)
{
case <= 125:
n = 2;
fh[0] = b0;
fh[1] = (byte)(b1 | (byte)payloadLength);
break;
case < 65536:
n = 4;
fh[0] = b0;
fh[1] = (byte)(b1 | 126);
BinaryPrimitives.WriteUInt16BigEndian(fh[2..], (ushort)payloadLength);
break;
default:
n = 10;
fh[0] = b0;
fh[1] = (byte)(b1 | 127);
BinaryPrimitives.WriteUInt64BigEndian(fh[2..], (ulong)payloadLength);
break;
}
byte[]? key = null;
if (useMasking)
{
key = new byte[4];
RandomNumberGenerator.Fill(key);
key.CopyTo(fh[n..]);
n += 4;
}
return (n, key);
}
///
/// XOR masks a buffer with a 4-byte key. Applies in-place.
///
public static void MaskBuf(ReadOnlySpan key, Span buf)
{
for (int i = 0; i < buf.Length; i++)
buf[i] ^= key[i & 3];
}
///
/// XOR masks multiple contiguous buffers as if they were one.
///
public static void MaskBufs(ReadOnlySpan key, List bufs)
{
int pos = 0;
foreach (var buf in bufs)
{
for (int j = 0; j < buf.Length; j++)
{
buf[j] ^= key[pos & 3];
pos++;
}
}
}
///
/// Creates a close message payload: 2-byte status code + optional UTF-8 body.
/// Body truncated to fit MaxControlPayloadSize with "..." suffix.
///
public static byte[] CreateCloseMessage(int status, string body)
{
var bodyBytes = Encoding.UTF8.GetBytes(body);
int maxBody = WsConstants.MaxControlPayloadSize - WsConstants.CloseStatusSize;
if (bodyBytes.Length > maxBody)
{
var suffix = "..."u8;
int truncLen = maxBody - suffix.Length;
// Find a valid UTF-8 boundary by walking back from truncation point
while (truncLen > 0 && (bodyBytes[truncLen] & 0xC0) == 0x80)
truncLen--;
var buf = new byte[WsConstants.CloseStatusSize + truncLen + suffix.Length];
BinaryPrimitives.WriteUInt16BigEndian(buf, (ushort)status);
bodyBytes.AsSpan(0, truncLen).CopyTo(buf.AsSpan(WsConstants.CloseStatusSize));
suffix.CopyTo(buf.AsSpan(WsConstants.CloseStatusSize + truncLen));
return buf;
}
var result = new byte[WsConstants.CloseStatusSize + bodyBytes.Length];
BinaryPrimitives.WriteUInt16BigEndian(result, (ushort)status);
bodyBytes.CopyTo(result.AsSpan(WsConstants.CloseStatusSize));
return result;
}
///
/// Builds a complete control frame (header + payload, optional masking).
///
public static byte[] BuildControlFrame(int opcode, ReadOnlySpan payload, bool useMasking)
{
int headerSize = 2 + (useMasking ? 4 : 0);
var frame = new byte[headerSize + payload.Length];
var span = frame.AsSpan();
var (n, key) = FillFrameHeader(span, useMasking,
first: true, final: true, compressed: false, opcode: opcode, payloadLength: payload.Length);
if (payload.Length > 0)
{
payload.CopyTo(span[n..]);
if (useMasking && key != null)
MaskBuf(key, span[n..]);
}
return frame;
}
///
/// Maps a ClientClosedReason to a WebSocket close status code.
/// Matches Go wsEnqueueCloseMessage in websocket.go lines 668-694.
///
public static int MapCloseStatus(ClientClosedReason reason) => reason switch
{
ClientClosedReason.ClientClosed => WsConstants.CloseStatusNormalClosure,
ClientClosedReason.AuthenticationTimeout or
ClientClosedReason.AuthenticationViolation or
ClientClosedReason.SlowConsumerPendingBytes or
ClientClosedReason.SlowConsumerWriteDeadline or
ClientClosedReason.MaxSubscriptionsExceeded or
ClientClosedReason.AuthenticationExpired => WsConstants.CloseStatusPolicyViolation,
ClientClosedReason.TlsHandshakeError => WsConstants.CloseStatusTlsHandshake,
ClientClosedReason.ParseError or
ClientClosedReason.ProtocolViolation => WsConstants.CloseStatusProtocolError,
ClientClosedReason.MaxPayloadExceeded => WsConstants.CloseStatusMessageTooBig,
ClientClosedReason.WriteError or
ClientClosedReason.ReadError or
ClientClosedReason.StaleConnection or
ClientClosedReason.ServerShutdown => WsConstants.CloseStatusGoingAway,
_ => WsConstants.CloseStatusInternalSrvError,
};
}