Editor/SandboxRelaySocket.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;

namespace TeamCreate;

/// <summary>
/// <see cref="IRelaySocket"/> over the engine's own <see cref="WebSocket"/>. An editor script cannot use the
/// framework's <c>ClientWebSocket</c> directly for anything the engine polices, and this class is the reason
/// the relay is an option at all: the engine documents WebSockets as its supported way to reach an external
/// service, and its allow-list (DNS names only, no private ranges, no IP addresses) applies to every connect.
/// </summary>
public sealed class SandboxRelaySocket : IRelaySocket
{
    // The engine's default receive buffer is 64 KiB; a relay message is at most 16 KiB plus a five-byte
    // header, so the default has generous headroom and a hostile relay cannot make a bigger allocation.
    private readonly WebSocket socket = new WebSocket();

    private int closedRaised;

    public bool IsConnected => socket.IsConnected;

    public event Action<string> TextReceived;

    public event Action<byte[]> BinaryReceived;

    public event Action<int, string> Closed;

    public SandboxRelaySocket()
    {
        socket.OnMessageReceived += message => TextReceived?.Invoke(message);
        // The span is only valid for the duration of the callback, so it is copied before it is queued.
        socket.OnDataReceived += data => BinaryReceived?.Invoke(data.ToArray());
        socket.OnDisconnected += (status, reason) =>
        {
            if (Interlocked.Exchange(ref closedRaised, 1) == 0)
            {
                Closed?.Invoke(status, reason);
            }
        };
    }

    public Task ConnectAsync(string url, CancellationToken cancellationToken)
    {
        // Validated again here so a bad address fails with the library's own sentence rather than the
        // engine's generic "Access to '...' is not allowed."
        if (!RelayProtocol.TryValidateUrl(url, out _, out string error))
        {
            throw new InvalidOperationException(error);
        }
        return socket.Connect(url, cancellationToken);
    }

    public Task SendTextAsync(string text, CancellationToken cancellationToken)
    {
        return socket.Send(text).AsTask();
    }

    public Task SendBinaryAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
    {
        return socket.Send(data.ToArray()).AsTask();
    }

    public void Dispose()
    {
        socket.Dispose();
    }
}