using System; using System.Collections.Concurrent; using System.IO; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using UnityEngine; namespace RudderSdk.Unity { /// /// Native WebSocket adapter using System.Net.WebSockets.ClientWebSocket. /// Dispatches received events to the Unity main thread via a MonoBehaviour Update loop. /// public sealed class NativeWebSocketAdapter : IWebSocketAdapter, IDisposable { private ClientWebSocket _ws; private CancellationTokenSource _cts; private readonly WebSocketDispatcher _dispatcher; private readonly ConcurrentQueue _eventQueue = new(); private bool _disposed; /// /// Fired when the WebSocket connection is closed. /// public event Action Closed; /// /// Fired when binary data is received from the server. /// public event Action> Received; /// /// Fired when an error occurs on the receive loop. /// public event Action ReceivedError; /// /// True when the WebSocket is in the Open state. /// public bool IsConnected => _ws?.State == WebSocketState.Open; /// /// True when the WebSocket is in the Connecting state. /// public bool IsConnecting => _ws?.State == WebSocketState.Connecting; public NativeWebSocketAdapter() { var go = new GameObject("WebSocketDispatcher"); UnityEngine.Object.DontDestroyOnLoad(go); _dispatcher = go.AddComponent(); _dispatcher.Init(this); } /// /// Connects to the specified WebSocket URI. /// /// The WebSocket server URI (ws:// or wss://). /// Connection timeout in seconds. /// Optional cancellation token. /// Thrown when uri is null. /// Thrown when timeoutSeconds is not positive. /// Thrown when the connection attempt times out. public async Task ConnectAsync(Uri uri, int timeoutSeconds, CancellationToken cancellationToken = default) { if (uri == null) throw new ArgumentNullException(nameof(uri)); if (timeoutSeconds <= 0) throw new ArgumentOutOfRangeException(nameof(timeoutSeconds), "Timeout must be greater than zero."); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _ws = new ClientWebSocket(); _ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, timeoutCts.Token); try { await _ws.ConnectAsync(uri, linked.Token); _ = ReceiveLoop(); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { throw new TimeoutException($"WebSocket connection timed out after {timeoutSeconds} seconds."); } } /// /// Gracefully closes the WebSocket connection. /// public async Task CloseAsync() { if (_ws?.State == WebSocketState.Open) { try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closing", CancellationToken.None); } catch { // Best-effort close } } _cts?.Cancel(); EnqueueEvent(() => Closed?.Invoke()); } /// /// Sends binary data over the WebSocket. WebSocket transport is always reliable, /// so no reliability parameter is needed. /// public async Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default) { if (_ws?.State != WebSocketState.Open) return; await _ws.SendAsync(data, WebSocketMessageType.Binary, true, cancellationToken); } /// /// Disposes the adapter, cancelling any in-flight operations and cleaning up the dispatcher GameObject. /// public void Dispose() { if (_disposed) return; _disposed = true; _cts?.Cancel(); _cts?.Dispose(); _ws?.Dispose(); if (_dispatcher != null && _dispatcher.gameObject != null) { UnityEngine.Object.Destroy(_dispatcher.gameObject); } } private async Task ReceiveLoop() { var buffer = new byte[65536]; try { while (!_cts.IsCancellationRequested && _ws.State == WebSocketState.Open) { using (var ms = new MemoryStream()) { WebSocketReceiveResult result; do { result = await _ws.ReceiveAsync(new ArraySegment(buffer), _cts.Token); ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); if (result.MessageType == WebSocketMessageType.Close) { try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); } catch { // Best-effort close } EnqueueEvent(() => Closed?.Invoke()); return; } var data = ms.ToArray(); EnqueueEvent(() => Received?.Invoke(new ArraySegment(data))); } } } catch (OperationCanceledException) { // Normal cancellation, no action needed } catch (Exception ex) when (!_disposed) { EnqueueEvent(() => ReceivedError?.Invoke(ex)); EnqueueEvent(() => Closed?.Invoke()); } } internal void EnqueueEvent(Action action) { _eventQueue.Enqueue(action); } internal void ProcessEvents() { while (_eventQueue.TryDequeue(out var action)) { try { action?.Invoke(); } catch (Exception ex) { Debug.LogException(ex); } } } } /// /// MonoBehaviour used to dispatch WebSocket events on the Unity main thread via Update. /// Created and managed by NativeWebSocketAdapter. /// internal class WebSocketDispatcher : MonoBehaviour { private NativeWebSocketAdapter _adapter; public void Init(NativeWebSocketAdapter adapter) { _adapter = adapter; } private void Update() { _adapter?.ProcessEvents(); } private void OnDestroy() { _adapter?.Dispose(); _adapter = null; } } }