namespace FinlyticCore.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(); } }