.NET client
DarkWS.Client is an async client for .NET 8, 9, and 10. It has no runtime package
dependencies and no ASP.NET requirement, so it works in console apps, services,
desktop apps, and integration tests.
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);
The first request opens the connection. Call ConnectAsync(ct) for an early
readiness check, or when the application only listens for broadcasts.
For full configuration, pass 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),
});
Requests
| Call | Sends | Returns |
|---|---|---|
RequestAsync<T>(action, ct) | No data | Deserialized data |
RequestAsync<T>(action, payload, ct) | payload as data | Deserialized data |
RequestAsync(action, ct) | No data | Completes on acknowledgement |
RequestAsync(action, payload, ct) | payload as data | Completes on acknowledgement |
- Actions without a result still wait for acknowledgement and throw on server errors.
(object?)nullsends explicit JSON null; omitting the payload omitsdata.- Typed requests require a
datafield in the response. The DarkWS server always writes it for data results; JSON null becomesdefault. - Nullable annotations are not enforced at runtime; JSON null follows System.Text.Json rules.
- Use
JsonElementfor dynamic results. Returned elements stay valid after receive buffers are released.
A server error throws DarkWsResponseException:
try {
await client.RequestAsync("message:delete", new { id }, ct);
} catch (DarkWsResponseException error) when (error.Code == "message:forbidden") {
ShowForbidden();
}
It exposes Code, Action, RequestId, and optional ErrorData (JsonElement?).
Cancelling a request or timing out does not undo server work. Once a write has started, caller cancellation does not cancel the shared socket.
Notifications
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 a subscription to remove it. Subscriptions survive reconnects.
- Callbacks run one at a time, outside the socket reader, and may await requests on the same client.
- A callback already picked for invocation may still run after unsubscribe.
- Exceptions are reported through
Errorand do not stop delivery to others. - Typed subscriptions require a
datafield; the server always writes it for broadcasts with data.
Callbacks do not capture a UI context. Post to it yourself:
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));
Slow callbacks fill the notification queue (NotificationQueueCapacity, 256).
Overflow stops the connection instead of silently dropping notifications. On
disconnect, queued notifications are discarded and the running callback's token is
cancelled. Broadcasts received while disconnected are not replayed; reload state
after reconnecting.
Authentication
Manual
await client.ConnectAsync(ct);
await client.AuthenticateAsync(accessToken, ct);
await client.RequestAsync("message:send", new { text = "Hello" }, ct);
await client.LogoutAsync(ct);
AuthenticateAsyncsendsauth:<token>and waits forauth:success. Rejection throwsDarkWsResponseExceptionwith codeauth:failed, actionauth, and an empty request id.LogoutAsyncsendslogoutand waits forlogout:success.- Commands run one at a time. A timeout or cancellation while a command is in flight discards the socket, so a late reply cannot complete a later command. A command cancelled while still queued was never sent and leaves the socket in use.
- Manual authentication does not keep the token for reconnect.
Automatic on every socket
await using var client = new DarkWsClient(new DarkWsClientOptions {
Endpoint = new Uri("wss://example.com/ws"),
AuthenticationTokenProvider = ct => tokenStore.GetAccessTokenAsync(ct),
});
The provider returns ValueTask<string?> and runs for every fresh socket. Requests
wait until authentication succeeds. When the provider is configured, a missing token,
a provider exception, or auth:failed is a permanent readiness failure: the state
becomes Disconnected with the reason, and automatic retries stop. Fix the cause and
call ConnectAsync.
Malformed, binary, or oversized messages received while the token provider is
pending also fail readiness immediately with DarkWsProtocolException. The
provider is canceled, the protocol close status is preserved, and automatic
reconnect stops; ordinary transport failures remain retryable.
Starting LogoutAsync disables the provider, even if the acknowledgement is lost.
A logout rejected by MaxPendingRequests has not started and leaves it enabled.
Only an AuthenticateAsync invoked after that logout can re-enable it on success;
earlier calls, including queued authentication commands, cannot undo the logout.
HTTP-level credentials
ConfigureWebSocketOptionsAsync runs before every upgrade and can set headers,
cookies, certificates, or a proxy:
ConfigureWebSocketOptionsAsync = async (socket, ct) => {
var token = await tokenStore.GetAccessTokenAsync(ct);
socket.SetRequestHeader("Authorization", $"Bearer {token}");
},
Use it when the HTTP endpoint requires authentication before the socket opens. HTTP identity and the DarkWS session are separate: clear application-owned headers and cookies on logout so a reconnect cannot restore the old identity.
Provider and configuration callbacks must honor cancellation and must not call the client whose connection they are preparing.
Lifecycle
State is one of Disconnected, Connecting, Connected, Reconnecting, and
Disposed. StateChanged reports transitions with the failure in Reason; Error
reports background failures.
- One instance owns one server session and supports concurrent callers. Use separate instances for separate accounts or endpoints.
ConnectAsyncjoins one shared connection attempt. Caller cancellation stops only that caller's wait.- Transport failures reconnect with exponential jitter up to 30 seconds. Requests already sent fail and are never replayed; new requests wait for readiness.
- With
Reconnect = false, a dropped socket stays down, and later requests fail until an explicitConnectAsync. - Server-rejected credentials, a failing token provider or socket callback, HTTP 401 or 403, protocol errors, and notification overflow stop automatic retry.
CloseAsyncstops recovery and rejects waiters until the nextConnectAsync.DisposeAsynccloses gracefully withinCloseTimeout, then awaits cleanup.Disposeaborts immediately. Disposal is terminal and idempotent.
Event observers run off the socket and UI context; keep them short. Exceptions in
Error observers are contained. If an Error observer falls behind, at most
NotificationQueueCapacity errors wait and newer ones are dropped; StateChanged
events are never dropped.
Exceptions
| Exception | Meaning |
|---|---|
DarkWsResponseException | The server replied with an error |
DarkWsTimeoutException | Stage is Connection, Send, or Response |
DarkWsConnectionException | Connection failed or closed; CloseStatus, CloseReason |
DarkWsProtocolException | The server violated the protocol; CloseStatus |
DarkWsClientLimitException | MaxPendingRequests reached; nothing was sent |
JsonException | A payload or result could not be converted |
Transport failure messages name the cause chain by exception type and error code (for
example SocketError.ConnectionRefused), never by its text, which could contain the
endpoint's query token.
Options
| Option | Default | Meaning |
|---|---|---|
Endpoint | required | ws:// or wss:// URI |
ConnectionTimeout | 30 s | Opening the socket |
SendTimeout | 30 s | Writing a message |
RequestTimeout | 5 min | Waiting for a reply; Timeout.InfiniteTimeSpan disables it |
CloseTimeout | 5 s | Graceful close in DisposeAsync |
Reconnect | true | Reconnect after transport failures |
PingInterval / PongTimeout | 30 s each | Heartbeat |
MaxMessageSizeBytes | 1 MiB | Largest incoming message, all fragments included |
MaxPendingRequests | 256 | Pending calls, including authentication and connection waits |
NotificationQueueCapacity | 256 | Queued notifications before overflow |
JsonOptions | JsonSerializerDefaults.Web | Private copy of serialization options |
ConfigureWebSocketOptionsAsync | none | Configure each upgrade request |
AuthenticationTokenProvider | none | Token for automatic authentication |
Finite timeouts must be 1 to 4294967294 milliseconds. Connection, send, and reply
waits are separate, so a call can take their sum; use a cancellation token for an
overall deadline. Server backpressure can delay pong, so tune the heartbeat for
your workload.
The client closes the socket with status 1003 if the server sends a binary frame.
Dependency injection
DarkWS.Client.DependencyInjection registers the client in Microsoft DI. It
references only DI abstractions, not ASP.NET or the 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 registers one lazy singleton IDarkWsClient. Resolving it does not
connect; the first request or ConnectAsync does. The container owns disposal:
consumers dispose their subscriptions, not the client. Duplicate or conflicting
registrations throw before changing the collection.
Configure from other services:
services.AddDarkWsClient((provider, options) => {
options.Endpoint = new Uri("wss://example.com/ws");
var tokens = provider.GetRequiredService<TokenStore>();
options.AuthenticationTokenProvider = ct => tokens.GetAccessTokenAsync(ct);
});
TokenStore must be safe to use from a singleton. Do not capture scoped services in
singleton callbacks.
One session per scope
A singleton suits one shared server identity. For a separate identity per scope, register the client yourself:
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),
});
});
Match the scope to the session's lifetime: an HTTP request scope ends with the
request. Do not combine this registration with AddDarkWsClient. For several
endpoints, use keyed registrations or application-owned instances.
Platform support
Native AOT, trimming, .NET Framework, Unity, browser WebAssembly, and mobile lifecycle integration are not certified.