Initial commit

This commit is contained in:
rudder
2026-08-12 14:03:44 +03:00
commit 9a7b4a57dc
304 changed files with 82311 additions and 0 deletions
@@ -0,0 +1,22 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Unity
{
/// <summary>
/// Interface for WebSocket transport adapters used by the realtime transport bridge.
/// Provides connect, send, close, and main-thread event dispatch for WebSocket connections.
/// </summary>
public interface IWebSocketAdapter
{
event Action Closed;
event Action<ArraySegment<byte>> Received;
event Action<Exception> ReceivedError;
bool IsConnected { get; }
bool IsConnecting { get; }
Task ConnectAsync(Uri uri, int timeoutSeconds, CancellationToken cancellationToken = default);
Task CloseAsync();
Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a7216258d1bc14b1699fce5966a73073
@@ -0,0 +1,312 @@
#if UNITY_WEBGL && !UNITY_EDITOR
using System.Runtime.InteropServices;
#endif
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace RudderSdk.Unity
{
/// <summary>
/// WebGL WebSocket adapter using JavaScript interop via DllImport("__Internal__").
/// Uses SendMessage callbacks from JS to route WebSocket events to Unity's main thread.
/// Only functional in WebGL builds; throws PlatformNotSupportedException elsewhere.
/// </summary>
public sealed class JsWebSocketAdapter : IWebSocketAdapter, IDisposable
{
#if UNITY_WEBGL && !UNITY_EDITOR
[DllImport("__Internal__")]
private static extern void RudderWebSocketCreate(string gameObjectName, string url);
[DllImport("__Internal__")]
private static extern void RudderWebSocketSend(string gameObjectName, byte[] data, int length);
[DllImport("__Internal__")]
private static extern void RudderWebSocketClose(string gameObjectName);
#endif
private CancellationTokenSource _cts;
private readonly ConcurrentQueue<Action> _eventQueue = new();
private JsWebSocketBridge _bridge;
private TaskCompletionSource<bool> _connectionTcs;
private volatile bool _isConnected;
private volatile bool _isConnecting;
private bool _disposed;
/// <summary>
/// Fired when the WebSocket connection is closed.
/// </summary>
public event Action Closed;
/// <summary>
/// Fired when binary data is received from the server.
/// </summary>
public event Action<ArraySegment<byte>> Received;
/// <summary>
/// Fired when an error occurs on the WebSocket.
/// </summary>
public event Action<Exception> ReceivedError;
/// <summary>
/// True when the WebSocket connection is open.
/// </summary>
public bool IsConnected => _isConnected;
/// <summary>
/// True while the WebSocket connection is being established.
/// </summary>
public bool IsConnecting => _isConnecting;
public JsWebSocketAdapter()
{
var go = new GameObject("JsWebSocketBridge");
UnityEngine.Object.DontDestroyOnLoad(go);
_bridge = go.AddComponent<JsWebSocketBridge>();
_bridge.Init(this);
}
/// <summary>
/// Connects to the specified WebSocket URI via the JavaScript WebSocket API.
/// </summary>
/// <param name="uri">The WebSocket server URI (ws:// or wss://).</param>
/// <param name="timeoutSeconds">Connection timeout in seconds.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <exception cref="ArgumentNullException">Thrown when uri is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when timeoutSeconds is not positive.</exception>
/// <exception cref="TimeoutException">Thrown when the connection attempt times out.</exception>
/// <exception cref="PlatformNotSupportedException">Thrown when not running in WebGL.</exception>
public 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.");
#if UNITY_WEBGL && !UNITY_EDITOR
_isConnecting = true;
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_connectionTcs = new TaskCompletionSource<bool>();
// Register cancellation (including timeout)
_cts.Token.Register(() =>
{
if (_connectionTcs?.TrySetException(
new TimeoutException($"WebSocket connection timed out after {timeoutSeconds} seconds.")) == true)
{
_isConnecting = false;
}
});
_cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
RudderWebSocketCreate(_bridge.gameObject.name, uri.ToString());
return _connectionTcs.Task;
#else
// Not available outside WebGL builds; editor test uses NativeWebSocketAdapter.
return Task.FromException(new PlatformNotSupportedException(
"JsWebSocketAdapter is only supported on WebGL. Use NativeWebSocketAdapter for other platforms."));
#endif
}
/// <summary>
/// Gracefully closes the WebSocket connection via the JavaScript WebSocket API.
/// </summary>
public Task CloseAsync()
{
#if UNITY_WEBGL && !UNITY_EDITOR
RudderWebSocketClose(_bridge.gameObject.name);
#endif
_cts?.Cancel();
_isConnected = false;
_isConnecting = false;
EnqueueEvent(() => Closed?.Invoke());
return Task.CompletedTask;
}
/// <summary>
/// Sends binary data over the WebSocket via the JavaScript WebSocket API.
/// </summary>
public Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default)
{
if (!_isConnected) return Task.CompletedTask;
#if UNITY_WEBGL && !UNITY_EDITOR
byte[] arr;
int offset;
int count;
if (data.Offset == 0 && data.Count == data.Array.Length)
{
arr = data.Array;
offset = 0;
count = data.Count;
}
else
{
// Copy to a contiguous buffer since DllImport needs a pinned array from offset 0
arr = new byte[data.Count];
System.Buffer.BlockCopy(data.Array, data.Offset, arr, 0, data.Count);
offset = 0;
count = data.Count;
}
RudderWebSocketSend(_bridge.gameObject.name, arr, count);
#endif
return Task.CompletedTask;
}
/// <summary>
/// Disposes the adapter, cleaning up the bridge GameObject.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
#if UNITY_WEBGL && !UNITY_EDITOR
RudderWebSocketClose(_bridge.gameObject.name);
#endif
_cts?.Cancel();
_cts?.Dispose();
if (_bridge != null && _bridge.gameObject != null)
{
UnityEngine.Object.Destroy(_bridge.gameObject);
}
}
/// <summary>
/// Called by JsWebSocketBridge when the JS WebSocket onopen fires.
/// </summary>
internal void HandleOpen()
{
_isConnected = true;
_isConnecting = false;
_connectionTcs?.TrySetResult(true);
}
/// <summary>
/// Called by JsWebSocketBridge when the JS WebSocket onclose fires.
/// </summary>
internal void HandleClose()
{
_isConnected = false;
_isConnecting = false;
_connectionTcs?.TrySetCanceled();
Closed?.Invoke();
}
/// Called by JsWebSocketBridge when the JS WebSocket onerror fires.
/// </summary>
internal void HandleError(string errorMessage)
{
_isConnecting = false;
_connectionTcs?.TrySetException(new Exception(errorMessage));
ReceivedError?.Invoke(new Exception(errorMessage));
}
/// Called by JsWebSocketBridge when binary message data is received.
/// </summary>
internal void HandleMessage(ArraySegment<byte> data)
{
Received?.Invoke(data);
}
internal void HandleMessageError(Exception ex)
{
ReceivedError?.Invoke(ex);
}
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);
}
}
}
}
/// <summary>
/// MonoBehaviour that receives SendMessage callbacks from the JavaScript WebSocket bridge
/// and dispatches events on the Unity main thread.
/// </summary>
internal class JsWebSocketBridge : MonoBehaviour
{
private JsWebSocketAdapter _adapter;
public void Init(JsWebSocketAdapter adapter)
{
_adapter = adapter;
}
/// <summary>
/// Called from JavaScript via SendMessage when the WebSocket connection opens.
/// </summary>
public void OnOpen(string _)
{
_adapter?.EnqueueEvent(() => _adapter?.HandleOpen());
}
/// <summary>
/// Called from JavaScript via SendMessage when binary data is received.
/// Data is base64-encoded by the JS bridge and decoded here.
/// </summary>
public void OnMessage(string base64Data)
{
if (string.IsNullOrEmpty(base64Data) || _adapter == null) return;
try
{
var data = Convert.FromBase64String(base64Data);
var segment = new ArraySegment<byte>(data);
_adapter.EnqueueEvent(() => _adapter.HandleMessage(segment));
}
catch (FormatException ex)
{
Debug.LogError($"JsWebSocketBridge: Failed to decode base64 message: {ex.Message}");
_adapter.EnqueueEvent(() => _adapter.HandleMessageError(ex));
}
}
/// <summary>
/// Called from JavaScript via SendMessage when the WebSocket connection closes.
/// </summary>
public void OnClose(string _)
{
_adapter?.EnqueueEvent(() => _adapter?.HandleClose());
}
/// <summary>
/// Called from JavaScript via SendMessage when a WebSocket error occurs.
/// </summary>
public void OnError(string error)
{
_adapter?.EnqueueEvent(() => _adapter?.HandleError(error ?? "Unknown WebSocket error"));
}
private void Update()
{
_adapter?.ProcessEvents();
}
private void OnDestroy()
{
_adapter?.Dispose();
_adapter = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e53d8ae2e7487470280f0da178c56cf8
@@ -0,0 +1,233 @@
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
{
/// <summary>
/// Native WebSocket adapter using System.Net.WebSockets.ClientWebSocket.
/// Dispatches received events to the Unity main thread via a MonoBehaviour Update loop.
/// </summary>
public sealed class NativeWebSocketAdapter : IWebSocketAdapter, IDisposable
{
private ClientWebSocket _ws;
private CancellationTokenSource _cts;
private readonly WebSocketDispatcher _dispatcher;
private readonly ConcurrentQueue<Action> _eventQueue = new();
private bool _disposed;
/// <summary>
/// Fired when the WebSocket connection is closed.
/// </summary>
public event Action Closed;
/// <summary>
/// Fired when binary data is received from the server.
/// </summary>
public event Action<ArraySegment<byte>> Received;
/// <summary>
/// Fired when an error occurs on the receive loop.
/// </summary>
public event Action<Exception> ReceivedError;
/// <summary>
/// True when the WebSocket is in the Open state.
/// </summary>
public bool IsConnected => _ws?.State == WebSocketState.Open;
/// <summary>
/// True when the WebSocket is in the Connecting state.
/// </summary>
public bool IsConnecting => _ws?.State == WebSocketState.Connecting;
public NativeWebSocketAdapter()
{
var go = new GameObject("WebSocketDispatcher");
UnityEngine.Object.DontDestroyOnLoad(go);
_dispatcher = go.AddComponent<WebSocketDispatcher>();
_dispatcher.Init(this);
}
/// <summary>
/// Connects to the specified WebSocket URI.
/// </summary>
/// <param name="uri">The WebSocket server URI (ws:// or wss://).</param>
/// <param name="timeoutSeconds">Connection timeout in seconds.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <exception cref="ArgumentNullException">Thrown when uri is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when timeoutSeconds is not positive.</exception>
/// <exception cref="TimeoutException">Thrown when the connection attempt times out.</exception>
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.");
}
}
/// <summary>
/// Gracefully closes the WebSocket connection.
/// </summary>
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());
}
/// <summary>
/// Sends binary data over the WebSocket. WebSocket transport is always reliable,
/// so no reliability parameter is needed.
/// </summary>
public async Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default)
{
if (_ws?.State != WebSocketState.Open)
return;
await _ws.SendAsync(data, WebSocketMessageType.Binary, true, cancellationToken);
}
/// <summary>
/// Disposes the adapter, cancelling any in-flight operations and cleaning up the dispatcher GameObject.
/// </summary>
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<byte>(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<byte>(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);
}
}
}
}
/// <summary>
/// MonoBehaviour used to dispatch WebSocket events on the Unity main thread via Update.
/// Created and managed by NativeWebSocketAdapter.
/// </summary>
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 508609c897805437dab5527c21c9f914