namespace FinlyticAssets.Util;
using System;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public abstract class ManagedWebSocket : IDisposable
{
private ClientWebSocket? _webSocket;
private CancellationTokenSource? _cts;
private Task? _receiveTask;
private Task? _keepAliveTask;
///
/// Gets or sets the interval for keep-alive messages.
/// If set to null, the periodic timer is not started and no life messages are sent.
///
public TimeSpan? KeepAliveInterval { get; set; }
///
/// Gets a value indicating whether the WebSocket is currently open.
///
public bool IsConnected => _webSocket?.State == WebSocketState.Open;
///
/// Gets the current state of the WebSocket connection.
///
public WebSocketState State => _webSocket?.State ?? WebSocketState.None;
///
/// Connects to the specified WebSocket URI and starts the background tasks for receiving and keep-alive.
///
/// The URI of the WebSocket server to connect to.
/// Optional. Sets or overrides the keep-alive interval for this connection.
public async Task ConnectAsync(string uri, TimeSpan? keepAliveInterval = null)
{
if (IsConnected)
throw new InvalidOperationException("WebSocket is already connected.");
if (keepAliveInterval.HasValue)
{
KeepAliveInterval = keepAliveInterval;
}
_webSocket = new ClientWebSocket();
_cts = new CancellationTokenSource();
await _webSocket.ConnectAsync(new Uri(uri), _cts.Token);
_receiveTask = ReceiveLoopAsync(_cts.Token);
if (KeepAliveInterval.HasValue && KeepAliveInterval.Value > TimeSpan.Zero)
{
_keepAliveTask = KeepAliveLoopAsync(_cts.Token);
}
}
///
/// Closes the WebSocket connection gracefully and cancels all running background tasks.
///
public async Task DisconnectAsync()
{
if (_webSocket != null)
{
if (_cts != null)
{
await _cts.CancelAsync();
}
var tasksToWait = new List();
if (_receiveTask != null) tasksToWait.Add(_receiveTask);
if (_keepAliveTask != null) tasksToWait.Add(_keepAliveTask);
if (tasksToWait.Count > 0)
{
try
{
await Task.WhenAll(tasksToWait);
}
catch (OperationCanceledException) { /* Ignore */ }
catch { /* Ignore */ }
}
if (_webSocket.State == WebSocketState.Open)
{
try
{
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection closed by client", CancellationToken.None);
}
catch { /* Ignore exceptions during closure */ }
}
_webSocket.Dispose();
_webSocket = null;
_receiveTask = null;
_keepAliveTask = null;
}
}
///
/// Sends a raw string message over the WebSocket connection.
///
/// The text message to send.
public async Task SendAsync(string message)
{
if (!IsConnected || _webSocket == null || _cts == null)
throw new InvalidOperationException("WebSocket is not connected.");
var buffer = Encoding.UTF8.GetBytes(message);
await _webSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, true, _cts.Token);
}
///
/// Serializes the given generic object to JSON and sends it over the WebSocket connection.
///
/// The type of the object to send.
/// The object to serialize and send.
public Task SendAsync(T data)
{
var json = JsonSerializer.Serialize(data);
return SendAsync(json);
}
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
var buffer = new byte[8192];
try
{
while (_webSocket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await _webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
ms.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
if (result.MessageType == WebSocketMessageType.Close)
{
await DisconnectAsync();
break;
}
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(ms.ToArray());
if (!IsKeepAliveMessage(message))
{
OnMessageReceived(message);
}
}
}
}
catch (OperationCanceledException) { /* Expected behavior when cancellation is requested */ }
catch (Exception ex)
{
OnError(ex);
}
}
private async Task KeepAliveLoopAsync(CancellationToken cancellationToken)
{
if (!KeepAliveInterval.HasValue) return;
using var timer = new PeriodicTimer(KeepAliveInterval.Value);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
if (IsConnected)
{
await SendLifeMessageAsync();
}
}
}
catch (OperationCanceledException) { /* Expected behavior when cancellation is requested */ }
}
///
/// Triggered when a new text message is received that is not filtered as a keep-alive message.
///
/// The received message content.
protected abstract void OnMessageReceived(string message);
///
/// Triggered periodically based on the KeepAliveInterval to send a life message or ping to the server.
/// Override this method in the derived class if you want to send actual keep-alive payloads.
///
protected virtual Task SendLifeMessageAsync()
{
// Default implementation does nothing.
return Task.CompletedTask;
}
///
/// Determines whether the incoming message is a keep-alive response (e.g., a "pong").
/// If true is returned, the message is filtered out and OnMessageReceived is not triggered.
///
/// The incoming text message.
/// True if the message should be filtered; otherwise, false.
protected virtual bool IsKeepAliveMessage(string message)
{
// Default implementation does not filter any messages.
return false;
}
///
/// Optionally overridable method to handle exceptions that occur within the receive loop.
///
/// The exception that was caught.
protected virtual void OnError(Exception ex)
{
// Default implementation is empty. Can be overridden in the derived class.
}
///
/// Disposes the WebSocket connection and releases all associated resources.
///
public void Dispose()
{
DisconnectAsync().Wait();
_cts?.Dispose();
}
}