WebSocket: сообщения, WSS, закрытие и переподключение
Полный локальный echo-клиент и сервер: UTF-8, двоичные сообщения, лимиты и жизненный цикл.
Запустить echo
Добавьте WsTextClient.cs, WsEchoServer.cs и WsEchoDemo.cs в C#-проект Reckfell 1.0.0. Создайте отдельный уровень с корнем Entity, прикрепите WsEchoDemo, соберите и запустите F6. В Output появятся отправка Error.Ok, WebSocket echo: Привет 🌍 и код нормального закрытия 1000.
Демонстрация создаёт настоящий TCP/WebSocket-сервер на loopback со свободным портом и отдельный клиент в одном процессе. Она не обращается к внешнему сервису. Для отдельного echo-сервера прикрепите WsEchoServer к Entity: экспортируемый Port по умолчанию 8767, привязка — 127.0.0.1.
WsEchoDemo.cs
using Reckfell;
public partial class WsEchoDemo : Entity
{
public override void _Ready()
{
var server = new WsEchoServer { Port = 0 }; AddChild(server);
if (server.StartError != Error.Ok) return;
var client = new WsTextClient(); AddChild(client);
client.Opened += () => ReckfellApi.Print($"WebSocket send: {client.SendText("Привет 🌍")}");
client.Received += message => { ReckfellApi.Print($"WebSocket echo: {message}"); client.Close(); };
client.Finished += (code, reason) => ReckfellApi.Print($"WebSocket close: {code}, {reason}");
Error start = client.Start($"ws://127.0.0.1:{server.LocalPort}/echo");
if (start != Error.Ok) ReckfellApi.Print($"WebSocket start: {start}");
}
}WsTextClient.cs
using System;
using System.Diagnostics;
using System.Text;
using Reckfell;
public partial class WsTextClient : Entity
{
public const int MaxMessageBytes = 1024;
public double TimeoutSeconds { get; set; } = 3;
public bool Busy => _socket is not null;
public bool IsOpen => _socket?.GetReadyState() == WebSocketPeer.State.Open && !_closing;
public string LastMessage { get; private set; } = "";
public int ReceivedCount { get; private set; }
public int CloseCode { get; private set; } = -1;
public string CloseReason { get; private set; } = "";
public TlsOptions? Trust { get; set; }
public event Action? Opened;
public event Action<string>? Received;
public event Action<int, string>? Finished;
private WebSocketPeer? _socket;
private readonly Stopwatch _wait = new();
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private bool _opened, _closing;
public Error Start(string url)
{
if (Busy) return Error.Busy;
if (!IsInsideTree()) return Error.Unconfigured;
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) ||
(uri.Scheme != "ws" && uri.Scheme != "wss") || uri.UserInfo.Length != 0 ||
!double.IsFinite(TimeoutSeconds) || TimeoutSeconds <= 0) return Error.InvalidParameter;
_socket = new WebSocketPeer { InboundBufferSize = 65536, OutboundBufferSize = 65536,
MaxQueuedPackets = 32, SupportedProtocols = ["reckfell.echo.v1"] };
_opened = _closing = false; ReceivedCount = 0; LastMessage = ""; CloseCode = -1; CloseReason = "";
Error error = _socket.ConnectToUrl(url, uri.Scheme == "wss" ? Trust ?? TlsOptions.Client() : null);
if (error != Error.Ok) { Release(); return error; }
_wait.Restart(); return Error.Ok;
}
public Error SendText(string message)
{
if (!IsOpen) return Error.Unconfigured;
try { if (StrictUtf8.GetByteCount(message) > MaxMessageBytes) return Error.InvalidData; }
catch (EncoderFallbackException) { return Error.InvalidData; }
return _socket!.SendText(message);
}
public void Close() => BeginClose(1000, "done");
private void BeginClose(int code, string reason)
{
if (_socket is null || _closing) return;
_closing = true; _socket.Close(code, reason); _wait.Restart();
}
public override void _Process(double delta)
{
if (_socket is null) return;
_socket.Poll();
var state = _socket.GetReadyState();
if (state == WebSocketPeer.State.Open && !_opened)
{ _opened = true; Opened?.Invoke(); if (_socket is null) return; }
if (state == WebSocketPeer.State.Open && !_closing)
for (int i = 0; i < 8 && !_closing && _socket.GetAvailablePacketCount() > 0; i++)
{
byte[] bytes = _socket.GetPacket();
if (_socket.GetPacketError() != Error.Ok) { BeginClose(1011, "read-error"); break; }
if (!_socket.WasStringPacket()) { BeginClose(1003, "text-required"); break; }
if (bytes.Length > MaxMessageBytes) { BeginClose(1009, "message-too-large"); break; }
try { LastMessage = StrictUtf8.GetString(bytes); }
catch (DecoderFallbackException) { BeginClose(1007, "invalid-utf8"); break; }
ReceivedCount++; Received?.Invoke(LastMessage); if (_socket is null) return;
}
if (_socket.GetReadyState() == WebSocketPeer.State.Closed)
{ Finish(_socket.GetCloseCode(), _socket.GetCloseReason()); return; }
if ((!_opened || _closing) && _wait.Elapsed.TotalSeconds >= TimeoutSeconds) Finish(-1, "timeout");
}
private void Finish(int code, string reason)
{ CloseCode = code; CloseReason = reason; Release(); Finished?.Invoke(code, reason); }
private void Release() { _socket?.Dispose(); _socket = null; _wait.Stop(); }
public override void _ExitTree() => Release();
}WsEchoServer.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Reckfell;
public partial class WsEchoServer : Entity
{
private sealed class Connection(StreamPeerTcp stream, WebSocketPeer socket)
{
public readonly StreamPeerTcp Stream = stream;
public readonly WebSocketPeer Socket = socket;
public readonly Stopwatch Wait = Stopwatch.StartNew();
public WebSocketPeer.State Previous = WebSocketPeer.State.Connecting;
}
[Export] public int Port { get; set; } = 8767;
public int LocalPort => _listener?.IsListening() == true ? _listener.GetLocalPort() : 0;
public int ClientCount => _connections.Count;
public int EchoedMessages { get; private set; }
public Error StartError { get; private set; } = Error.Unconfigured;
private TcpServer? _listener;
private readonly List<Connection> _connections = new();
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
public override void _Ready()
{
if (Port < 0 || Port > 65535) { StartError = Error.InvalidParameter; return; }
_listener = new TcpServer(); StartError = _listener.Listen((ushort)Port, "127.0.0.1");
if (StartError != Error.Ok) ReckfellApi.Print($"WebSocket listen: {StartError}");
}
public override void _Process(double delta)
{
if (_listener?.IsListening() != true) return;
for (int i = 0; i < 4 && _listener.IsConnectionAvailable(); i++)
{
StreamPeerTcp stream = _listener.TakeConnection();
if (_connections.Count >= 4) { stream.DisconnectFromHost(); stream.Dispose(); continue; }
var socket = new WebSocketPeer { InboundBufferSize = 65536, OutboundBufferSize = 65536,
MaxQueuedPackets = 32, SupportedProtocols = ["reckfell.echo.v1"] };
if (socket.AcceptStream(stream) != Error.Ok) { socket.Dispose(); stream.DisconnectFromHost(); stream.Dispose(); continue; }
_connections.Add(new Connection(stream, socket));
}
for (int i = _connections.Count - 1; i >= 0; i--)
{
Connection connection = _connections[i]; WebSocketPeer socket = connection.Socket; socket.Poll();
var state = socket.GetReadyState();
if (state != connection.Previous) connection.Wait.Restart();
connection.Previous = state;
if (state == WebSocketPeer.State.Closed || (state != WebSocketPeer.State.Open && connection.Wait.Elapsed.TotalSeconds > 3))
{ Remove(i); continue; }
if (state != WebSocketPeer.State.Open) continue;
for (int n = 0; n < 8 && socket.GetAvailablePacketCount() > 0; n++)
{
byte[] bytes = socket.GetPacket();
if (socket.GetPacketError() != Error.Ok) { socket.Close(1011, "read-error"); break; }
if (bytes.Length > WsTextClient.MaxMessageBytes) { socket.Close(1009, "message-too-large"); break; }
Error error;
if (socket.WasStringPacket())
{
try { error = socket.SendText(StrictUtf8.GetString(bytes)); }
catch (DecoderFallbackException) { socket.Close(1007, "invalid-utf8"); break; }
}
else error = socket.Send(bytes, WebSocketPeer.WriteMode.Binary);
if (error != Error.Ok) { socket.Close(1011, "send-error"); break; }
EchoedMessages++;
}
}
}
private void Remove(int index)
{
Connection connection = _connections[index]; connection.Socket.Dispose();
connection.Stream.DisconnectFromHost(); connection.Stream.Dispose(); _connections.RemoveAt(index);
}
public void Stop()
{ _listener?.Stop(); _listener?.Dispose(); _listener = null; for (int i = _connections.Count - 1; i >= 0; i--) Remove(i); }
public override void _ExitTree() => Stop();
}Соединение и сообщения
ConnectToUrl возвращает Error запуска. После Error.Ok вызывайте Poll регулярно и проверяйте GetReadyState; это ещё не успешный handshake. Состояния — Connecting, Open, Closing и Closed. В WsTextClient опрос выполняет _Process, событие Opened выдаётся после Open.
Сервер принимает TCP через TcpServer и передаёт поток в WebSocketPeer.AcceptStream. Клиент и сервер согласуют демонстрационный подпротокол reckfell.echo.v1. Для своего сервера согласуйте SupportedProtocols и формат сообщений; это имя не является общим игровым протоколом.
WsTextClient принимает только текст UTF-8. SendText отказывает до открытия, при закрытии, при некорректной строке и после превышения 1024 байт. Это байты, а не число видимых символов. Полученные пакеты читаются через GetPacket, затем проверяется GetPacketError и WasStringPacket. Двоичный пакет не превращается в текст автоматически.
Сам echo-сервер сохраняет тип: текст возвращает SendText, двоичные данные — Send с WriteMode.Binary. Двоичный roundtrip проверен отдельным клиентом WebSocketPeer. Для игрового JSON сначала разберите и проверьте схему; наличие WebSocket не проверяет значения полей.
Закрытие и повторный запуск
Close начинает нормальное закрытие, а не мгновенное уничтожение соединения. Продолжайте Poll до Closed или своего срока ожидания. Код 1000 означает нормальное закрытие; отсутствие такого кода после обрыва не следует показывать как успешное завершение.
В примере Finished выдаётся после Closed или тайм-аута. После него Start можно вызвать повторно. Конкурентный Start возвращает Error.Busy. При удалении владельца _ExitTree освобождает соединение без обещания Finished: обработчики удаляемого экрана не должны ждать этот сигнал для очистки UI.
Не переподключайтесь каждый кадр. Отдельно задайте ограниченные попытки и задержку. После нового соединения повторите нужную авторизацию/подписку; старые подписки и подтверждённое состояние не восстанавливаются одним вызовом ConnectToUrl.
Лимиты и WSS
Сервер держит не более четырёх соединений, принимает не более четырёх новых за кадр и обрабатывает до восьми сообщений на соединение за кадр. Буферы ограничены 65536 байт и 32 входящими пакетами; прикладное сообщение — 1024 байта. Слишком большое сообщение закрывается с 1009. Это локальный стенд, а не готовая система авторизации, ограничения частоты и защиты публичного сервиса.
TimeoutSeconds ограничивает ожидание открытия и закрытия. Stopwatch измеряет реальное время, но проверяется в _Process; пауза/ProcessMode владельца влияет на продолжение обработки.
Для WSS передайте wss://...; клиент использует проверяющий TlsOptions.Client. Для собственного CA можно загрузить TlsTrust из руководства HTTPS и передать результат в Trust до Start. Для TLS-сервера требуются сертификат и соответствующий закрытый ключ, а AcceptStream получает уже установленный TLS-поток. Обычный WsEchoServer выше обслуживает WS; смена букв URL на wss не включает TLS на сервере.
Проверены текст/двоичные байты, подпротокол, предел сообщения, нормальное закрытие, обрыв сервера, повторный запуск и срок ожидания. WSS проверен настоящим TLS-потоком с доверенным тестовым сертификатом и подходящим IP SAN. Это не проверка всех отказов WSS, прокси, NAT, браузерных ограничений или экспорта на другие устройства.