A SignalR-based communication framework for .NET with built-in message handler patterns for request-response and fire-and-forget messaging between clients and servers.
- Fire-and-forget messaging - Send one-way messages from client to server or server to client(s)
- Request-response messaging - Send a request and await a typed response with configurable timeout
- Automatic handler discovery - Message handlers are discovered and registered via dependency injection
- Client connection tracking - Track connected clients with metadata (machine name, app type, version)
- Automatic reconnection - Configurable reconnect delays for client connections
- Extensible storage - Abstract repository pattern for client state with an in-memory default
dotnet add package Tharga.Communication
var builder = WebApplication.CreateBuilder(args);
builder.AddThargaCommunicationServer(options =>
{
options.RegisterClientStateService<MyClientStateService>();
options.RegisterClientRepository<MemoryClientRepository<ClientConnectionInfo>, ClientConnectionInfo>();
});
var app = builder.Build();
app.UseThargaCommunicationServer();
app.Run();Add the configuration section to appsettings.json:
{
"Tharga": {
"Communication": {
"ServerAddress": "https://localhost:5001"
}
}
}Register the client services:
var builder = Host.CreateApplicationBuilder(args);
builder.AddThargaCommunicationClient();public record MyNotification(string Text);
public class MyNotificationHandler : PostMessageHandlerBase<MyNotification>
{
public override Task Handle(MyNotification message)
{
Console.WriteLine(message.Text);
return Task.CompletedTask;
}
}Register the handler in DI:
builder.Services.AddTransient<PostMessageHandlerBase<MyNotification>, MyNotificationHandler>();public record PingRequest(string Message);
public record PingResponse(string Reply);
public class PingHandler : SendMessageHandlerBase<PingRequest, PingResponse>
{
public override Task<PingResponse> Handle(PingRequest message)
{
return Task.FromResult(new PingResponse($"Pong: {message.Message}"));
}
}From the client:
public class MyService(IClientCommunication client)
{
public async Task NotifyServer()
{
await client.PostAsync(new MyNotification("Hello from client"));
}
public async Task<PingResponse> PingServer()
{
return await client.SendMessage<PingRequest, PingResponse>(new PingRequest("Ping"));
}
}From the server:
public class MyServerService(IServerCommunication server)
{
public async Task NotifyClient(string connectionId)
{
await server.PostAsync(connectionId, new MyNotification("Hello from server"));
}
public async Task NotifyAll()
{
await server.PostToAllAsync(new MyNotification("Broadcast message"));
}
public async Task<PingResponse> PingClient(string connectionId)
{
var response = await server.SendMessageAsync<PingRequest, PingResponse>(
connectionId, new PingRequest("Ping"));
return response.Value;
}
}public class MyClientStateService : ClientStateServiceBase<ClientConnectionInfo>
{
public MyClientStateService(IServiceProvider sp, IOptions<CommunicationOptions> options)
: base(sp, options) { }
protected override ClientConnectionInfo Build(IClientConnectionInfo info) =>
new()
{
Instance = info.Instance,
ConnectionId = info.ConnectionId,
Machine = info.Machine,
Type = info.Type,
Version = info.Version,
IsConnected = info.IsConnected,
ConnectTime = info.ConnectTime
};
protected override ClientConnectionInfo BuildDisconnect(ClientConnectionInfo info, DateTime disconnectTime) =>
info with { IsConnected = false, DisconnectTime = disconnectTime };
}| Property | Description | Default |
|---|---|---|
ServerAddress |
The server URL to connect to | (required) |
Pattern |
The hub endpoint pattern | "hub" |
ReconnectDelays |
Delays between reconnection attempts | [0s, 2s, 10s, 30s] |
ApiKey |
API key sent to the server for authentication | (none) |
AdditionalAssemblies |
Extra assemblies to scan for message handlers | (none) |
SendMessageTimeout |
Default timeout for request-response messages | 60s |
The server requires registering a ClientStateServiceBase implementation and a ClientRepositoryBase implementation via the options callback. Use MemoryClientRepository<T> for an in-memory default.
| Property | Description | Default |
|---|---|---|
PrimaryApiKey |
Primary API key for client authentication | (none) |
SecondaryApiKey |
Secondary API key for zero-downtime key rotation | (none) |
AdditionalAssemblies |
Extra assemblies to scan for message handlers | (none) |
When no API keys are configured on the server, all connections are accepted (backwards compatible). When one or both keys are set, clients must provide a matching key via the X-Api-Key header.
To secure the SignalR connection with API key authentication:
Server — configure one or both keys:
builder.AddThargaCommunicationServer(options =>
{
options.PrimaryApiKey = builder.Configuration["Communication:PrimaryApiKey"];
options.SecondaryApiKey = builder.Configuration["Communication:SecondaryApiKey"];
options.RegisterClientStateService<MyClientStateService>();
options.RegisterClientRepository<MemoryClientRepository<ClientConnectionInfo>, ClientConnectionInfo>();
});Client — provide the matching key:
{
"Tharga": {
"Communication": {
"ServerAddress": "https://localhost:5001",
"ApiKey": "your-secret-key"
}
}
}Or via the options callback:
builder.AddThargaCommunicationClient(o =>
{
o.ApiKey = builder.Configuration["Communication:ApiKey"];
});API keys can also be configured via User Secrets or environment variables. To rotate keys without downtime, set both PrimaryApiKey and SecondaryApiKey on the server — either key is accepted.
By default, message handlers are discovered by scanning assemblies that match the entry assembly name prefix. If your handlers are in an external package (e.g. a separate NuGet), they won't be found automatically. Use AdditionalAssemblies to include them:
builder.AddThargaCommunicationClient(o =>
{
o.ServerAddress = "https://localhost:5001";
o.AdditionalAssemblies = [typeof(MyExternalHandler).Assembly];
});The same option is available on the server side:
builder.AddThargaCommunicationServer(options =>
{
options.AdditionalAssemblies = [typeof(MyExternalHandler).Assembly];
options.RegisterClientStateService<MyClientStateService>();
options.RegisterClientRepository<MemoryClientRepository<ClientConnectionInfo>, ClientConnectionInfo>();
});If a client receives a SendMessage for a type with no registered handler, it immediately returns an error response to the server instead of silently timing out.