TypedSignalR.Client 2.1.0

Install-Package TypedSignalR.Client -Version 2.1.0
dotnet add package TypedSignalR.Client --version 2.1.0
<PackageReference Include="TypedSignalR.Client" Version="2.1.0" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add TypedSignalR.Client --version 2.1.0
The NuGet Team does not provide support for this client. Please contact its maintainers for support.
#r "nuget: TypedSignalR.Client, 2.1.0"
#r directive can be used in F# Interactive, C# scripting and .NET Interactive. Copy this into the interactive tool or source code of the script to reference the package.
// Install TypedSignalR.Client as a Cake Addin
#addin nuget:?package=TypedSignalR.Client&version=2.1.0

// Install TypedSignalR.Client as a Cake Tool
#tool nuget:?package=TypedSignalR.Client&version=2.1.0
The NuGet Team does not provide support for this client. Please contact its maintainers for support.

TypedSignalR.Client

build-and-test

C# Source Generator to create strongly typed SignalR Client.

Table of Contents

Install

NuGet: TypedSignalR.Client

dotnet add package Microsoft.AspNetCore.SignalR.Client
dotnet add package TypedSignalR.Client

Why TypedSignalR.Client?

The C # SignalR Client is untyped. To call a Hub (server-side) function, you must specify the function defined in Hub as a string.

connection.InvokeAsync("HubMethod")

You also have to manually determine the return type.

var ret = await connection.InvokeAsync<SomeType>("HubMethod")

Registering a client function called by the server also requires a string, and the argument types must be set manually.

Connection.On<string, DateTime>("ClientMethod", (str, dateTime) => {});

Therefore, if you change the code on the server-side, the modification on the client-side becomes very troublesome. The main cause is that it is not strongly typed.

This TypedSignalR.Client (Source Generator) aims to generate a strongly typed SignalR Client by sharing the server and client function definitions as an interface.

API

This Source Generator provides three extension methods and one interface.

static class Extensions
{
    THub CreateHubProxy<THub>(this HubConnection source){...}
    IDisposable Register<TReceiver>(this HubConnection source, TReceiver receiver){...}
    (THub HubProxy, IDisposable Subscription) CreateHubProxyWith<THub, TReceiver>(this HubConnection source, TReceiver receiver){...}
}

// An interface for observing SigalR events.
interface IHubConnectionObserver
{
    Task OnClosed(Exception e);
    Task OnReconnected(string connectionId);
    Task OnReconnecting(Exception e);
}

Use it as follows.

HubConnection connection = ...;

IHub hub = connection.CreateHubProxy<IHub>();
IDisposable subscription = connection.Register<IReceiver>(new Receiver());

Usage

Suppose you have the following interface defined:

public class UserDefine
{
    public Guid RandomId { get; set; }
    public DateTime Datetime { get; set; }
}

// The return type of the client-side method must be Task. 
public interface IClientContract
{
    // Of course, user defined type is OK. 
    Task ClientMethod1(string user, string message, UserDefine userDefine);
    Task ClientMethod2();
}

// The return type of the method on the hub-side must be Task or Task <T>. 
public interface IHubContract
{
    Task<string> HubMethod1(string user, string message);
    Task HubMethod2();
}

class Receiver1 : IClientContract
{
    // impl
}

class Receiver2 : IClientContract, IHubConnectionObserver
{
    // impl
}

Client

It's very easy to use.


HubConnection connection = ...;

var hub = connection.CreateHubProxy<IHubContract>();
var subscription1 = connection.Register<IClientContract>(new Receiver1());

// When an instance of a class that implements IHubConnectionObserver is registered (Receiver2 in this case), 
// the method defined in IHubConnectionObserver is automatically registered regardless of the type argument. 
var subscription2 = connection.Register<IClientContract>(new Receiver2());

// or
var (hub2, subscription3) = connection.CreateHubProxyWith<IHubContract, IClientContract>(new Receiver());

// Invoke hub methods
hub.HubMethod1("user", "message");

// Unregister the receiver
subscription.Dispose();

Cancellation

In pure SignalR, CancellationToken is passed for each invoke.

On the other hand, in TypedSignalR.Client, CancellationToken is passed only once when creating HubProxy. The passed CancelationToken will be used for each internal invoke.

var cts = new CancellationTokenSource();

// The following two are equivalent.

// pure SignalR
var ret=  await connection.InvokeAsync<string>("HubMethod1", "user", "message", cts.Token);
await connection.InvokeAsync("HubMethod2", cts.Token);

// TypedSignalR.Client
var hubProxy = connection.CreateHubProxy<IHubContract>(cts.Token);
var ret = await hubProxy.HubMethod1("user", "message");
await hubProxy.HubMethod2();

Server

By the way, using these definitions, you can write as follows on the server side (ASP.NET Core). TypedSignalR.Client is not nessesary.

using Microsoft.AspNetCore.SignalR;

public class SomeHub : Hub<IClientContract>, IHubContract
{
    public async Task<string> HubMethod1(string user, string message)
    {
        await this.Clients.All.ClientMethod1(user, message, new UserDefineClass());
        return "OK!";
    }

    public async Task HubMethod2()
    {
        await this.Clients.Caller.ClientMethod2();
    }
}

Recommendation

I recommend that these interfaces be shared between the client and server sides, for example, by project references.

server.csproj => shared.csproj <= client.csproj

Compile-time error support

This source generator has some restrictions, including those that come from the server side.

  • Type argument of CreateHubProxy/CreateHubProxyWith/Register method must be an interface.
  • Only define methods in the interface used for HubProxy/Receiver.
    • Properties should not be defined.
  • The return type of the method in the interface used for HubProxy must be Task or Task<T>.
  • The return type of the method in the interface used for Receiver must be Task.

It is very difficult for humans to properly comply with these restrictions. Therefore, it is designed so that the compiler (Roslyn) looks for the part where the constraint is not observed at compile time and reports a detailed error. Therefore, no run-time error occurs.

compile-time-error

Generated code

In this section, we will briefly explain what kind of code will be generated. The actual generated code can be seen in the Visual Studio dependencies.

generated-code-in-dependencies

The source generator checks the type argument of a method such as'CreateHubProxy/Register' and generates the following code based on it.

If we call the methods connection.CreateHubProxy<IHubContract>() and connection.Register<IClientContract>(new Receiver()), the following code will be generated (simplified here).

public static partial class Extensions
{
    private class HubInvoker : IHubContract
    {
        private readonly HubConnection _connection;

        public HubInvoker(HubConnection connection)
        {
            _connection = connection;
        }

        public Task<string> HubMethod1(string user, string message)
        {
            return _connection.InvokeCoreAsync<string>(nameof(HubMethod1), new object[] { user, message });
        }

        public Task HubMethod2()
        {
            return _connection.InvokeCoreAsync(nameof(HubMethod2), System.Array.Empty<object>());
        }
    }

    private static CompositeDisposable BindIClientContract(HubConnection connection, IClientContract receiver)
    {
        var d1 = connection.On<string, string UserDefine>(nameof(receiver.ClientMethod1), receiver.ClientMethod1);
        var d2 = connection.On(nameof(receiver.ClientMethod2), receiver.ClientMethod2);

        var compositeDisposable = new CompositeDisposable();
        compositeDisposable.Add(d1);
        compositeDisposable.Add(d2);
        return compositeDisposable;
    }

    static Extensions()
    {
        HubInvokerConstructorCache<IHubContract>.Construct = static connection => new HubInvoker(connection);
        ReceiverBinderCache<IClientContract>.Bind = BindIClientContract;
    }
}

The generated code is used through the API as follows.

public static partial class Extensions
{
    // static type caching
    private static class HubInvokerConstructorCache<T>
    {
        public static Func<HubConnection, T> Construct;
    }

    // static type caching
    private static class ReceiverBinderCache<T>
    {
        public static Func<HubConnection, T, CompositeDisposable> Bind;
    }

    public static THub CreateHubProxy<THub>(this HubConnection connection)
    {
        return HubInvokerConstructorCache<THub>.Construct(connection);
    }

    public static IDisposable Register<TReceiver>(this HubConnection connection, TReceiver receiver)
    {
        if(typeof(TReceiver) == typeof(IHubConnectionObserver))
        {
            // special subscription
            return new HubConnectionObserverSubscription(connection, receiver as IHubConnectionObserver);;
        }

        var compositeDisposable = ReceiverBinderCache<TReceiver>.Bind(connection, receiver);

        if (receiver is IHubConnectionObserver hubConnectionObserver)
        {
            var subscription = new HubConnectionObserverSubscription(connection, hubConnectionObserver);
            compositeDisposable.Add(subscription);
        }

        return compositeDisposable;
    }
}

Demo

First, launch server. Then access it from your browser and open the console(F12).

git clone https://github.com/nenoNaninu/TypedSignalR.Client.git
cd sandbox 
dotnet run --project SignalR.Server/SignalR.Server.csproj

Execute the console app in another shell.

cd sandbox 
dotnet run --project SignalR.Client/SignalR.Client.csproj

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
2.1.0 53 12/26/2021
2.0.1 182 6/16/2021
2.0.0 135 6/10/2021
1.1.0 106 5/13/2021
1.0.2 110 5/12/2021
1.0.1 89 5/11/2021
1.0.0 76 5/11/2021
0.0.1 176 5/11/2021