.NET-клиент
DarkWS.Client — асинхронный клиент для .NET 8, 9 и 10. У него нет зависимостей от
пакетов во время выполнения и не требуется ASP.NET, поэтому он работает в консольных приложениях, сервисах,
десктопных приложениях и интеграционных тестах.
dotnet add package DarkWS.Client
using DarkWS.Client;
await using var client = new DarkWsClient(new Uri("wss://example.com/ws"));
var result = await client.RequestAsync<SumResult>("math:sum", new { left = 10, right = 20 });
Console.WriteLine(result.Value); // 30
public sealed record SumResult(int Value);
Первый запрос открывает соединение. Вызовите ConnectAsync(ct) для ранней
проверки готовности или если приложение только слушает рассылки.
Для полной настройки передайте DarkWsClientOptions:
await using var client = new DarkWsClient(new DarkWsClientOptions {
Endpoint = new Uri("wss://example.com/ws"),
RequestTimeout = TimeSpan.FromSeconds(30),
AuthenticationTokenProvider = ct => tokenStore.GetAccessTokenAsync(ct),
});
Запросы
| Вызов | Отправляет | Возвращает |
|---|---|---|
RequestAsync<T>(action, ct) | Без data | Десериализованное data |
RequestAsync<T>(action, payload, ct) | payload как data | Десериализованное data |
RequestAsync(action, ct) | Без data | Завершается по подтверждению |
RequestAsync(action, payload, ct) | payload как data | Завершается по подтверждению |
- Действия без результата всё равно ждут подтверждения и выбрасывают исключение при ошибках сервера.
(object?)nullотправляет явный JSON null; если payload опущен,dataтоже опускается.- Типизированные запросы требуют поле
dataв ответе. Сервер DarkWS всегда пишет его для результатов с данными; JSON null превращается вdefault. - Nullable-аннотации во время выполнения не проверяются; JSON null обрабатывается по правилам System.Text.Json.
- Для динамических результатов используйте
JsonElement. Возвращённые элементы остаются валидными после освобождения буферов приёма.
Ошибка сервера выбрасывает DarkWsResponseException:
try {
await client.RequestAsync("message:delete", new { id }, ct);
} catch (DarkWsResponseException error) when (error.Code == "message:forbidden") {
ShowForbidden();
}
Исключение содержит Code, Action, RequestId и необязательное ErrorData (JsonElement?).
Отмена запроса или таймаут не откатывают работу на сервере. Если запись уже началась, отмена со стороны вызывающего кода не отменяет общий сокет.
Уведомления
using var created = client.On<MessageCreated>("message:created",
message => Console.WriteLine(message.Text));
using var saved = client.OnAsync<MessageCreated>("message:created",
async (message, ct) => await SaveMessageAsync(message, ct));
using var ping = client.On("system:tick", () => Console.WriteLine("tick"));
- Чтобы удалить подписку, освободите её (Dispose). Подписки сохраняются при переподключении.
- Callback-и выполняются по одному, вне потока чтения сокета, и могут ожидать запросы к тому же клиенту.
- Callback, уже выбранный для вызова, может выполниться и после отписки.
- Исключения передаются через
Errorи не останавливают доставку остальным. - Типизированные подписки требуют поле
data; сервер всегда пишет его для рассылок с данными.
Callback-и не захватывают UI-контекст. Передавайте в него сами:
var ui = SynchronizationContext.Current
?? throw new InvalidOperationException("Call from the UI thread.");
using var subscription = client.On<MessageCreated>("message:created",
message => ui.Post(_ => UpdateView(message), null));
Медленные callback-и заполняют очередь уведомлений (NotificationQueueCapacity, 256).
Переполнение останавливает соединение, а не отбрасывает уведомления молча. При
разрыве соединения уведомления в очереди отбрасываются, а токен выполняющегося callback-а
отменяется. Рассылки, пришедшие во время разрыва, не воспроизводятся повторно; перезагрузите состояние
после переподключения.
Аутентификация
Вручную
await client.ConnectAsync(ct);
await client.AuthenticateAsync(accessToken, ct);
await client.RequestAsync("message:send", new { text = "Hello" }, ct);
await client.LogoutAsync(ct);
AuthenticateAsyncотправляетauth:<token>и ждётauth:success. Отказ выбрасываетDarkWsResponseExceptionс кодомauth:failed, действиемauthи пустым id запроса.LogoutAsyncотправляетlogoutи ждётlogout:success.- Команды выполняются по одной. Таймаут или отмена во время выполнения команды отбрасывает сокет, чтобы запоздавший ответ не завершил следующую команду. Команда, отменённая ещё в очереди, не была отправлена, и сокет продолжает использоваться.
- Ручная аутентификация не сохраняет токен для переподключения.
Автоматически на каждом сокете
await using var client = new DarkWsClient(new DarkWsClientOptions {
Endpoint = new Uri("wss://example.com/ws"),
AuthenticationTokenProvider = ct => tokenStore.GetAccessTokenAsync(ct),
});
Провайдер возвращает ValueTask<string?> и вызывается для каждого нового сокета. Запросы
ждут успешной аутентификации. Когда провайдер настроен, отсутствие токена,
исключение в провайдере или auth:failed — постоянный отказ готовности: состояние
становится Disconnected с указанием причины, и автоматические повторы прекращаются. Устраните причину и
вызовите ConnectAsync.
Некорректное JSON-сообщение, бинарный кадр или превышение размера во время ожидания
провайдера токена сразу завершают готовность с DarkWsProtocolException.
Провайдер получает отмену, код закрытия протокола сохраняется, автоматическое
переподключение прекращается; обычные транспортные сбои допускают повтор.
Начало LogoutAsync отключает провайдер, даже если подтверждение потеряно.
Logout, отклонённый из-за MaxPendingRequests, не начинался и оставляет провайдер включённым.
Только AuthenticateAsync, вызванный после этого logout, может снова включить
провайдер при успехе; более ранние вызовы, включая команды в очереди, не отменяют logout.
Учётные данные на уровне HTTP
ConfigureWebSocketOptionsAsync выполняется перед каждым upgrade и может задать заголовки,
cookie, сертификаты или прокси:
ConfigureWebSocketOptionsAsync = async (socket, ct) => {
var token = await tokenStore.GetAccessTokenAsync(ct);
socket.SetRequestHeader("Authorization", $"Bearer {token}");
},
Используйте его, когда HTTP endpoint требует аутентификации до открытия сокета. HTTP-идентичность и сессия DarkWS независимы: при выходе очищайте заголовки и cookie, которыми владеет приложение, чтобы переподключение не восстановило старую идентичность.
Провайдер и callback-и конфигурации должны учитывать отмену и не должны вызывать клиент, соединение которого они готовят.
Жизненный цикл
State принимает одно из значений Disconnected, Connecting, Connected, Reconnecting и
Disposed. StateChanged сообщает о переходах с причиной сбоя в Reason; Error
сообщает о фоновых сбоях.
- Один экземпляр владеет одной серверной сессией и поддерживает параллельные вызовы. Для разных аккаунтов или endpoint используйте отдельные экземпляры.
ConnectAsyncприсоединяется к одной общей попытке подключения. Отмена со стороны вызывающего кода прекращает только его ожидание.- При сбоях транспорта клиент переподключается с экспоненциальным jitter до 30 секунд. Уже отправленные запросы завершаются ошибкой и никогда не воспроизводятся повторно; новые запросы ждут готовности.
- При
Reconnect = falseоборванный сокет остаётся закрытым, и последующие запросы завершаются ошибкой до явного вызоваConnectAsync. - Отклонённые сервером учётные данные, сбой провайдера токена или callback-а сокета, HTTP 401 или 403, ошибки протокола и переполнение очереди уведомлений прекращают автоматические повторы.
CloseAsyncпрекращает восстановление и отклоняет ожидающих до следующегоConnectAsync.DisposeAsyncкорректно закрывает соединение в пределахCloseTimeout, затем ожидает очистки.Disposeпрерывает соединение немедленно. Освобождение окончательно и идемпотентно.
Наблюдатели событий выполняются вне контекста сокета и UI; делайте их короткими. Исключения в
наблюдателях Error изолируются. Если наблюдатель Error не успевает, ожидают не более
NotificationQueueCapacity ошибок, а более новые отбрасываются; события StateChanged
никогда не отбрасываются.
Исключения
| Исключение | Значение |
|---|---|
DarkWsResponseException | Сервер ответил ошибкой |
DarkWsTimeoutException | Stage — Connection, Send или Response |
DarkWsConnectionException | Подключение не удалось или закрыто; CloseStatus, CloseReason |
DarkWsProtocolException | Сервер нарушил протокол; CloseStatus |
DarkWsClientLimitException | Достигнут MaxPendingRequests; ничего не отправлено |
JsonException | Не удалось преобразовать payload или результат |
Сообщения о сбоях транспорта называют цепочку причин по типу исключения и коду ошибки (например,
SocketError.ConnectionRefused), но никогда не по тексту, который может содержать
токен из query-строки endpoint.
Опции
| Опция | По умолчанию | Назначение |
|---|---|---|
Endpoint | обязательна | URI ws:// или wss:// |
ConnectionTimeout | 30 с | Открытие сокета |
SendTimeout | 30 с | Запись сообщения |
RequestTimeout | 5 мин | Ожидание ответа; Timeout.InfiniteTimeSpan отключает |
CloseTimeout | 5 с | Корректное закрытие в DisposeAsync |
Reconnect | true | Переподключаться после сбоев транспорта |
PingInterval / PongTimeout | по 30 с | Heartbeat |
MaxMessageSizeBytes | 1 MiB | Максимальный размер входящего сообщения с учётом всех фрагментов |
MaxPendingRequests | 256 | Ожидающие вызовы, включая аутентификацию и ожидание подключения |
NotificationQueueCapacity | 256 | Уведомления в очереди до переполнения |
JsonOptions | JsonSerializerDefaults.Web | Собственная копия опций сериализации |
ConfigureWebSocketOptionsAsync | нет | Настройка каждого запроса upgrade |
AuthenticationTokenProvider | нет | Токен для автоматической аутентификации |
Конечные таймауты должны быть от 1 до 4294967294 миллисекунд. Ожидания подключения, отправки и ответа
независимы, поэтому вызов может занять их сумму; для общего срока используйте токен отмены.
Backpressure на сервере может задерживать pong, поэтому настраивайте heartbeat под
свою нагрузку.
Клиент закрывает сокет со статусом 1003, если сервер отправляет бинарный фрейм.
Внедрение зависимостей
DarkWS.Client.DependencyInjection регистрирует клиент в Microsoft DI. Пакет
ссылается только на абстракции DI, а не на ASP.NET или Generic Host.
dotnet add package DarkWS.Client.DependencyInjection
services.AddDarkWsClient(options => {
options.Endpoint = new Uri("wss://example.com/ws");
});
public sealed class Calculator(IDarkWsClient client) {
public Task<int> SumAsync(int left, int right) =>
client.RequestAsync<int>("math:sum", new { left, right });
}
AddDarkWsClient регистрирует один ленивый singleton IDarkWsClient. Его разрешение не
подключает клиент; подключает первый запрос или ConnectAsync. Освобождением владеет контейнер:
потребители освобождают свои подписки, а не клиент. Повторные или конфликтующие
регистрации выбрасывают исключение до изменения коллекции.
Настройка с использованием других сервисов:
services.AddDarkWsClient((provider, options) => {
options.Endpoint = new Uri("wss://example.com/ws");
var tokens = provider.GetRequiredService<TokenStore>();
options.AuthenticationTokenProvider = ct => tokens.GetAccessTokenAsync(ct);
});
TokenStore должен быть безопасен для использования из singleton. Не захватывайте scoped-сервисы в
callback-ах singleton.
Одна сессия на область
Singleton подходит для одной общей серверной идентичности. Для отдельной идентичности в каждой области зарегистрируйте клиент сами:
services.AddScoped<IDarkWsClient>(provider => {
var tokens = provider.GetRequiredService<UserTokenStore>();
return new DarkWsClient(new DarkWsClientOptions {
Endpoint = new Uri("wss://example.com/ws"),
AuthenticationTokenProvider = ct => tokens.GetAccessTokenAsync(ct),
});
});
Согласуйте область со временем жизни сессии: область HTTP-запроса заканчивается вместе с
запросом. Не сочетайте эту регистрацию с AddDarkWsClient. Для нескольких
endpoint используйте keyed-регистрации или экземпляры, которыми владеет приложение.
Поддержка платформ
Native AOT, trimming, .NET Framework, Unity, браузерный WebAssembly и интеграция с жизненным циклом мобильных приложений не сертифицированы.