reckfellENGINE

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
C#
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
C#
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
C#
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, браузерных ограничений или экспорта на другие устройства.

Документация Reckfell EngineНаверх ↑
БЫСТРЫЙ ПЕРЕХОДНачало работыEntity и иерархияC# & .NET 10Сцены и префабы
Руководства и полный API Reckfell K
reckfellENGINE
RECKFELL ACCOUNT

Вход в аккаунт

Войдите в аккаунт Reckfell.

Один аккаунт для всей платформы Reckfell.