Create Typed SignalR Clients with ... TypedSignalR.Client
Much better to avoid magic strings
SignalR is, I think, a good bidirectional networking library. I like its interface; it offers fully-typed server implementations without onerous reflection overhead - other libraries like MediatR are no-gos for me due to this. However, the typing experience in SignalR doesn't extend to its C# clients. Microsoft's own documentation recommends magic strings, disappointingly.
Curiously, Microsoft does have a Nuget package to support source-generated typed clients, and I have made good use of it. Disappointingly again though they do not reference this library in their documentation. More disappointingly yet, this is surely as they have left this in a pre-release version for years. Most disappointingly, when the release of .NET 9 brought updates to SignalR, this library was left behind, now lacking support for some SignalR uses.
Nonetheless, I have continued using Microsoft's solution; I refuse to use magic strings. A couple months ago I ran into a need to support this feature on clients, leaving me worried that this would spiral into a side project of writing my own source generator, spinning off new side projects to support yet-unfinished projects. Lucky me though, someone else has beaten me to it.
TypedSignalR.Client, aptly named, is a production-ready and up-to-date source generator for typed SignalR clients. I've switched myself over to this library, and I would recommend it. Here's how client implementations work -
As a refresher, servers can be implemented in a typed fashion using interfaces to define the clients:
public interface IClient{ Task OnSomeEventAsync();} public class MyServer : IHub<IClient>{ public async Task DoSomething() { await Clients.Group(someId).OnSomeEventAsync(); }}
This can be called from clients using magic strings:
public class MyClient{ private readonly HubConnection _connection; public MySignalRClient(string hubUrl) { _connection = new HubConnectionBuilder() .WithUrl(hubUrl) .WithAutomaticReconnect() .Build(); _connection.On("OnSomeEventAsync", OnSomeEventAsync); } public async Task DoSomethingAsync() => await _connection.InvokeAsync("DoSomethingAsync"); public async Task OnSomeEventAsync() { ... }}
And that's all yucky. To use the new TypedSignalR.Client, we'll first need a server interface:
public interface IServer{ Task DoSomethingAsync();}
The server class should implement this now. We'll also want to update the client to implement IClient, but while we're at it we'll also implement a new IHubConnectionObserver interface that comes from our new Nuget package, letting us observe SignalR events.
+public class MyClient : IClient, IHubConnectionObserver { private readonly HubConnection _connection; public MySignalRClient(string hubUrl) { _connection = new HubConnectionBuilder() .WithUrl(hubUrl) .WithAutomaticReconnect() .Build(); _connection.On("OnSomeEventAsync", OnSomeEventAsync); } + public async Task OnClosed(Exception? exception) { ... } + public async Task OnReconnected(string? connectionId) { ... } + public async Task OnReconnecting(Exception? exception) { ... } public async Task DoSomethingAsync() => await _connection.InvokeAsync("DoSomethingAsync"); public async Task OnSomeEventAsync() { ... } }
In order to call the typed server, we'll use the new _connection.CreateHubProxy extension:
public class MyClient : IClient, IHubConnectionObserver { private readonly HubConnection _connection;+ private readonly IServer _serverProxy; // public MySignalRClient(string hubUrl) { _connection = new HubConnectionBuilder() .WithUrl(hubUrl) .WithAutomaticReconnect() .Build(); + _serverProxy = connection.CreateHubProxy<IHub>(); // _connection.On("OnSomeEventAsync", OnSomeEventAsync); } public async Task OnClosed(Exception? exception) { ... } public async Task OnReconnected(string? connectionId) { ... } public async Task OnReconnecting(Exception? exception) { ... } public async Task DoSomethingAsync() =>+ await _serverProxy.DoSomethingAsync(); // public async Task OnSomeEventAsync() { ... } }
Then to be rid of the magic string in the event listener we'll use the Register extension.
public class MyClient : IClient, IHubConnectionObserver { private readonly HubConnection _connection; private readonly IServer _serverProxy;+ private readonly IDisposable _subscription; public MySignalRClient(string hubUrl) { _connection = new HubConnectionBuilder() .WithUrl(hubUrl) .WithAutomaticReconnect() .Build(); _serverProxy = connection.CreateHubProxy<IHub>();- _connection.On("OnSomeEventAsync", OnSomeEventAsync); + _subscription = connection.Register<IReceiver>(this); } public async Task OnClosed(Exception? exception) { ... } public async Task OnReconnected(string? connectionId) { ... } public async Task OnReconnecting(Exception? exception) { ... } public async Task DoSomethingAsync() => await _serverProxy.DoSomethingAsync(); public async Task OnSomeEventAsync() { ... } }
To wrap it up, it's good practice to implement IDisposable. Further, the library author recommends using a custom cancellation token source when creating the proxy:
+public class MyClient : IClient, IHubConnectionObserver, IDisposable { private readonly HubConnection _connection; private readonly IServer _serverProxy; private readonly IDisposable _subscription; private readonly CancellationTokenSource _cancellationTokenSource = new(); public MySignalRClient(string hubUrl) { _connection = new HubConnectionBuilder() .WithUrl(hubUrl) .WithAutomaticReconnect() .Build(); - _serverProxy = connection.CreateHubProxy<IHub>(); + _serverProxy = connection.CreateHubProxy<IHub>(_cancellationTokenSource.Token); _subscription = connection.Register<IReceiver>(this); } public async Task OnClosed(Exception? exception) { ... } public async Task OnReconnected(string? connectionId) { ... } public async Task OnReconnecting(Exception? exception) { ... } public async Task DoSomethingAsync() => await _serverProxy.DoSomethingAsync(); public async Task OnSomeEventAsync() { ... } + public void Dispose() + { + _subscription.Dispose(); + // ... // + } }
Bit of extra work but not enough to keep the juice from being worth the squeeze!