The Wayback Machine - https://web.archive.org/web/20211027190235/https://github.com/dotnet/aspnetcore/issues/32534
Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Design proposal] Strongly-typed SignalR hub proxies and callback handlers #32534

Open
mehmetakbulut opened this issue May 9, 2021 · 15 comments
Open

Comments

@mehmetakbulut
Copy link
Contributor

@mehmetakbulut mehmetakbulut commented May 9, 2021

Summary

This proposal aims to provide end-to-end strong typing for all interactions between SignalR clients and SignalR hubs.

Motivation and goals

  • Currently, SignalR clients can neither make strongly-typed calls to a SignalR hub nor register handlers for server-to-client calls in a strongly-typed manner. As such, developers are forced to write magic strings as well as guess argument sequence and return type.
  • Community has asked for this over the years and people had to implement their own workarounds including mine.
  • With end-to-end strong typing:
    • Many run-time errors will be caught as build-time errors.
    • Refactoring solutions that has both client and server projects will be easier since both ends can now share an actual interface.

In scope

  1. Strongly-typed remote procedure calls from a SignalR client to a SignalR hub
  2. Strongly-typed streaming calls from a SignalR client to a SignalR hub
    1. Support for both single direction streaming in either direction and bi-directional streaming
    2. Support for cancellation token in streaming calls.
    3. Support for both ChannelReader<> and IAsyncEnumerable<> as the underlying stream
  3. Strongly-typed handlers for calls from a SignalR hub to a SignalR client

Out of scope

  1. Non-C# usage. For the time being, we should focus on design and implementation of the ideal solution. Later we can think about how this design can be implemented in languages other than C# based on collaboration with the community.

Risks / unknowns

  1. Transformative features (e.g. HubMethodName) can break strongly-typed clients depending on the particular implementation.
    1. Should we allow interface definitions to be annotated with attributes so the consumers can account for a subset of these issues?
  2. Different implementations have different performance.
    1. Is there a level of performance that we would want to guarantee across all target platforms?
  3. Some designs may not be possible to implement for some platforms.
    1. Which platforms should we target?
    2. Would we be OK with different implementations for different platforms?
  4. Versioning may be hard.

Examples

Client to Server Calls

Let's say there is an interface IMyHub as defined below which is implemented by an ASP.NET Core application as MyHub : SignalR.Hub, IMyHub.

    public interface IMyHub
    {
        Task Do();

        Task<int> Get();

        Task Set(int a);
        
        Task<int> GetAndSet(int a);

        Task<ChannelReader<int>> StreamToClientViaChannel();

        Task<ChannelReader<int>> StreamToClientViaChannelWithToken(CancellationToken cancellationToken);

        Task StreamFromClientViaChannel(ChannelReader<int> reader);

        IAsyncEnumerable<int> StreamToClientViaEnumerableWithToken([EnumeratorCancellation] CancellationToken cancellationToken);

        Task StreamFromClientViaEnumerable(IAsyncEnumerable<int> reader);
    }

A developer currently needs to consume it as below.

await hubConnection.InvokeAsync("Do");
var ret = (int) await hubConnection.SendAsync("GetAndSet", 100);

Instead, developer could be making strongly-typed calls.

await myHub.Do();
var ret = await myHub.GetAndSet(100);

We can either have such a proxy be acquired from a hub connection or a builder.

var myHub = myHubConnection.AsHubProxy<IMyHub>();
// vs
var myHub = new HubProxyBuilder<IMyHub>()
    .WithConnection(myHubConnection)
    .ConfigureAnythingElse()
    .Build();

Acquisiton from a hub connection is simpler while builder provides more room for extension in future.

Server to Client Call Handlers

One can similarly define an interface IMyClient as below which can then be used in Hub<IMyClient> on server-end and implemented by any consumer to provide callbacks.

    public interface IMyClient
    {
        void Callback1();
        void Callback2(string arg);
    }

A developer currently needs to provide such callbacks as below.

await hubConnection.On("Callback1", (req) => { someActivity1(); });
await hubConnection.On<string>("Callback2", (arg) => { someActivity2(arg); });

Instead, developer could be registering callback in a strongly-typed manner.

public class MyClient : IMyClient { .. }
var myClient = new MyClient();
await hubConnection.RegisterCallbacks<IMyClient>(myClient);

Multiple callback providers can be registered against the same hub connection so different callbacks can be provided by different classes. However this does mean overlap is possible. We'd want to decide how to handle this and whether to impose restrictions.

Detailed design

To be decided.

Some alternatives for client-to-server call implementation are:
Source-generated proxies utilizes C# 9 / .NET 5 source generator feature.

Pros:

  • Supported on JIT and AOT platforms

Cons:

  • Consumer must be a C# 9 / .NET 5 project (though indirect consumption may be possible)

Dynamic proxies utilizes Reflection.Emit to dynamically generate proxy.

Pros:

  • Consumer can be a .NET Standard 2.0 project

Cons:

  • Not supported on AOT platforms
  • Third-party dependency is needed if we don't want to reimplement a lot of proxy generation code or introduce significant constraints.

Expressive proxies utilizes expressions to emulate a strongly-typed user experience.

Pros:

  • Mostly universal platform support

Cons:

  • Slowest
  • More verbose usage (e.g. await hubConnection.AsExpressive<IMyHub>().InvokeAsync(hub => hub.Do()))

Server-to-client calls can be registered with just reflection (no Reflection.Emit) which is simple enough and would work on practically any platform. Other alternatives are possible such as source generation as well.

@msftbot
Copy link
Contributor

@msftbot msftbot bot commented May 10, 2021

Thanks for contacting us.

We're moving this issue to the Next sprint planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@BrennanConroy
Copy link
Contributor

@BrennanConroy BrennanConroy commented May 13, 2021

await hubConnection.RegisterCallbacks(myClient);
Multiple callback providers can be registered against the same hub connection so different callbacks can be provided by different classes. However this does mean overlap is possible. We'd want to decide how to handle this and whether to impose restrictions.

My main issue with this approach is that you are forced to define all methods on the client side. We've seen many cases where some clients don't register for all methods. And you can't just have them throw NotImplementedException because that isn't a good design and the SignalR library will just swallow that (we probably log as well).

client-to-server call implementation are:

Source-generated proxies is my favorite. It should work everywhere (in some environments you might need to generate the code before using it but that shouldn't be too hard).

Transformative features (e.g. HubMethodName) can break strongly-typed clients depending on the particular implementation.

1. Should we allow interface definitions to be annotated with attributes so the consumers can account for a subset of these issues?

I'd say this is a P2 concern. We don't support this today, but if we add support for it, then we'll update the proxy to handle it as well.

Different implementations have different performance.

1. Is there a level of performance that we would want to guarantee across all target platforms?

As fast as possible 😄 Ideally there shouldn't be very much observable perf difference with this. Most of the code will be a pass-through wrapper right?

Some designs may not be possible to implement for some platforms.

1. Which platforms should we target?

2. Would we be OK with different implementations for different platforms?

I don't think a different implementation for different platforms is good. We already feel the burden for that sort of thing by having different clients for different languages. And it looks like we'll already be needing a different proxy generation for .NET vs. Typescript.

We should be targeting everything .NET Core runs on. i.e Xamarin, WPF, Console, WASM, etc.

4\. Versioning may be hard.

What versioning are you envisioning? So far we haven't made any breaking changes to the protocol or main APIs and aren't planning on doing that. New APIs will not be used by old proxy generators which should be fine. Although if possible we can try to make new proxy generation work on older clients.

var myHub = myHubConnection.AsHubProxy<IMyHub>();
// vs
var myHub = new HubProxyBuilder<IMyHub>()
    .WithConnection(myHubConnection)
    .ConfigureAnythingElse()
    .Build();

I currently like the first option as it allows use of both the strongly typed connection, as well as the less-typed connection. Although I guess the type returned from the builder could include a property with the less-typed connection.

@mehmetakbulut
Copy link
Contributor Author

@mehmetakbulut mehmetakbulut commented May 14, 2021

My main issue with this approach is that you are forced to define all methods on the client side.

That makes sense. I'm OK with restricting registration to a single "callback provider". However are you saying you don't like await hubConnection.RegisterCallbacks<IMyClient>(myClient); because IMyClient could be defined by client instead of server?

I was envisioning that IMyClient would normally be defined by server and used as Hub<IMyClient> as well as provided to clients as part of a library or common project.

Source-generated proxies is my favorite.

Agreed. I plan to focus on a detailed design based on source generation unless there is strong opposition.

I'd say this is a P2 concern.

👍

Most of the code will be a pass-through wrapper right?

With source generation, it is just one extra vcall. So yes :)

And it looks like we'll already be needing a different proxy generation for .NET vs. Typescript.

Yes. Are you envisioning .NET source-generators would generate Typescript source or some other more Typescript-native feature would be utilized? I don't use Typescript so I'd definitely want more feedback on how we could approach that.

What versioning are you envisioning?

Versioning of the interfaces defined by users. When I make changes to IMyHub, if the interface is used by third parties, I'm really only limited to backwards compatible changes. I suspect there isn't much that can be done here. It could always be a second/third pass addition..

I currently like the first option as it allows use of both the strongly typed connection, as well as the less-typed connection.

Same. I think for a first pass also it reduces complexity.

@BrennanConroy
Copy link
Contributor

@BrennanConroy BrennanConroy commented May 14, 2021

However are you saying you don't like await hubConnection.RegisterCallbacks<IMyClient>(myClient); because IMyClient could be defined by client instead of server?

No, I'm saying that myClient is a class that must define all methods. It is a nice way of doing things though, you just implement a class that inherits IMyClient, so we might want to consider keeping it and also providing a way to register individual methods?

Are you envisioning .NET source-generators would generate Typescript source or some other more Typescript-native feature would be utilized? I don't use Typescript so I'd definitely want more feedback on how we could approach that.

I'm not sure yet. I believe there are .NET libraries that can produce JS or TS, so we might want to try and structure the source-generator in a way that we can possibly plug in other language generators in the future.

Versioning of the interfaces defined by users. When I make changes to IMyHub, if the interface is used by third parties, I'm really only limited to backwards compatible changes. I suspect there isn't much that can be done here. It could always be a second/third pass addition..

Yeah, not sure what we can do there :)

@BrennanConroy
Copy link
Contributor

@BrennanConroy BrennanConroy commented May 14, 2021

Would you like to propose what the API will look like and we can start iterating on it?

@nenoNaninu
Copy link

@nenoNaninu nenoNaninu commented May 15, 2021

Another proposal

I have published a library called "TypedSignalR.Client" which is Source Generator to create a strongly typed SignalR Client. So this proposal has an implementation(slightly different from here).

I think my proposal can address some of the current requirements.

  • No breaking changes.
  • We don't have to define all methods.

Summary

The work flow for developers is as follows.

  1. Annotate Attribute to generate code.
  2. Inherit the generated class and override only the necessary methods to implement.
  3. Call Hub through the implemented class.

Detail and Example

First, suppose we have the following interface or class definition. This interface and class definition are assumed to be shared in the server and client projects by project reference etc.

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

// The return type of the client-side method must be Task. 
// This is because Hub<T> will cause a runtime error if a non-Task type is returned.
public interface IClientContract
{
    // Of course, user defined type is OK. 
    Task SomeClientMethod1(string user, string message, UserDefine userDefine);
    Task SomeClientMethod2();
}

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

And suppose that it is implemented on the server as follows.

using Microsoft.AspNetCore.SignalR;

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

    public async Task SomeHubMethod2()
    {
        await this.Clients.Caller.SomeClientMethod2();
    }
}

Under these assumptions, on the client side, annotate the HubClientBaseAttribute to the partial class as follows.

using TypedSignalR.Client;

[HubClientBase(typeof(IHubContract), typeof(IClientContract))]
partial class ClientBase
{
}

By annotating the HubClientBaseAttribute, the following code will be generated (simplified here).

partial abstract class ClientBase : IHubClient<IHubContract>, IClientContract, IAsyncDisposable
{
    private class HubInvoker : IHubContract
    {
        private readonly HubConnection _connection;

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

        public Task<string> SomeHubMethod1(string user,string message)
        {
            return _connection.InvokeAsync<string>(nameof(SomeHubMethod1), user, message);
        }

        public Task SomeHubMethod2()
        {
            return _connection.InvokeAsync(nameof(SomeHubMethod2));
        }
    } // class HubInvoker

    public HubConnection Connection { get; }
    public IHubContract Hub { get; }
    protected List<IDisposable> disposableList = new();

    public ClientBase(HubConnection connection)
    {
        Connection = connection;
        Hub = new HubInvoker(connection);

        Connection.Closed += OnClosed;
        Connection.Reconnected += OnReconnected;
        Connection.Reconnecting += OnReconnecting;

        var d1 = Connection.On<string, string, UserDefineClass>(nameof(SomeClientMethod1), SomeClientMethod1);
        var d2 = Connection.On(nameof(SomeClientMethod2), SomeClientMethod2);

        disposableList.Add(d1);
        disposableList.Add(d2);
    }

    public virtual Task SomeClientMethod1(string user,string message, UserDefineClass userDefine) => Task.CompletedTask;

    public virtual Task SomeClientMethod2() => Task.CompletedTask;

    public async ValueTask DisposeAsync()
    {
        Connection.Closed -= OnClosed;
        Connection.Reconnected -= OnReconnected;
        Connection.Reconnecting -= OnReconnecting;

        await Connection.DisposeAsync();

        foreach(var d in disposableList)
        {
            d.Dispose();
        }
    }

    public virtual Task OnClosed(Exception e) => Task.CompletedTask;
    public virtual Task OnReconnected(string connectionId) => Task.CompletedTask;
    public virtual Task OnReconnecting(Exception e) => Task.CompletedTask;
} // class ClientBase

The generated code is inherited and used. The usability is very similar to the case of inheriting Hub<T>.

  • Hub to Client : this.Clients.All.SomeClientMethod()
  • Client to Hub : this.Hub.SomeHubMethod()

Also, the definition of the function is not a callback, but writing feeling similar to Hub-side.

class HubClient : ClientBase
{
    public HubClient(HubConnection connection, string arg) : base(connection)
    {
    }

    // override and impl
    public override async Task SomeClientMethod1(string user, string message, UserDefineClass userDefine)
    {
        await this.Hub.SomeHubMethod1(string user, string message);
        Console.WriteLine("Call SomeClientMethod1!");
        return Task.CompletedTask;
    }

    // We don't have to implement all the methods !
    // public override Task SomeClientMethod2()

    // I think it is very comfortable to write SignalR event as follows.
    public override Task OnClosed(Exception e)
    {
        Console.WriteLine($"[On Closed!]");
        return Task.CompletedTask;
    }

    // Of course, we don't have to define all events.
    // public override Task OnReconnecting(Exception e)
}

Since the base class comes with an implementation, developers can selectively override it, not all functions need to be implemented.

It's also easy to use.

HubConnection connection = ...;

var client = new HubClient(connection, "some parameter");

await client.Connection.StartAsync();

// Invoke hub methods
var response = await client.Hub.SomeHubMethod1("user", "message");
Console.WriteLine(response);

await client.Connection.StopAsync();
await client.DisposeAsync();

Personally, I don't hete writing new HubClient(connection, "some parameter"). If you are not comfortable with this, how about the following API?

public static T Build<T>(this IHubConnectionBuilder source, Func<HubConnection,T> factoryMethod)
{
    HubConnection connection = source.Build();
    return factory.Invoke(connection);
}

static void Main(string[] args)
{
    var client = new HubConnectionBuilder()
        .WithUrl("https://~~~")
        .Build(connection => new HubClient(connection, "some parameter"));
}

@mehmetakbulut
Copy link
Contributor Author

@mehmetakbulut mehmetakbulut commented May 17, 2021

I'd suggest the following basic API surface.

// Get strongly typed hub proxy
public T GetProxy<T>(this HubConnection conn);

// Register callback method and get a disposable which can unregister callback
public IDisposable RegisterCallback<T1, T2, ..>(this HubConnection conn, string name, Action<T1, T2, ..> callback);

// Register callback provider (i.e. a class providing the callback methods defined in TInterface) and get a disposable which can unregister callbacks
public IDisposable RegisterCallbackProvider<TInterface, TImplementation>(this HubConnection conn, TImplementation callbackProvider) where TImplementation : TInterface;

This keeps it simple to grab a proxy as well as register single and/or multiple callbacks. I'm not very certain of the single callback registration because it is not a use case I had before. I'm a bit worried that it may be not much better than what SignalR already offers.

@mehmetakbulut
Copy link
Contributor Author

@mehmetakbulut mehmetakbulut commented May 17, 2021

@nenoNaninu It appears what you are proposing is one layer higher than what I've proposed here. Your proposal essentially wraps proxy acquisition and callback registration behind a single base client class which seem to serve almost as a replacement for HubConnection (e.g. you also provide automatic subscriptions for connection/disconnection etc..).

My intent is a bit different. I'd like to see the essential strong typing features implemented without imposing conditions on implementations within end-user codebase (i.e. not require inheritance of an abstract class) and then such abstractions as yours could be added on top very easily.

@BrennanConroy
Copy link
Contributor

@BrennanConroy BrennanConroy commented May 18, 2021

public IDisposable RegisterCallback<T1, T2, ..>(this HubConnection conn, string name, Action<T1, T2, ..> callback);

Now that I think about it, this is just hubConnection.On<T>(string name, ...). For some reason I was thinking there would be a difference. We probably don't want that. It would be interesting if there was a more strongly-typed API like, hubProxy.MethodName.Register((param) => Console.WriteLine(param)); food for thought. But I do think we should not have RegisterCallback in it's current state since it adds nothing over .On.

public IDisposable RegisterCallbackProvider<TInterface, TImplementation>(this HubConnection conn, TImplementation callbackProvider) where TImplementation : TInterface;

Should this be taking the HubConnection or the proxy type? And if it's on the proxy type, it wouldn't need to be an extension method, and the TInterface could be pre-defined based on the GetProxy call earlier.

public T GetProxy(this HubConnection conn);

This should return the proxy type right? We also want to see what the proxy type looks like.

@mehmetakbulut
Copy link
Contributor Author

@mehmetakbulut mehmetakbulut commented May 18, 2021

Here is a more concrete proxy example. Given following interface in a shared/common project/library:

public interface IMyHub
{
    Task DoSomething();
    Task<int> DoSomethingElse(float arg);
    IAsyncEnumerable<int> StreamBidirectional(IAsyncEnumerable<float> clientToServer, [EnumeratorCancellation] CancellationToken token);
}

Generated proxy would be: (types would be written in fully qualified form but just simplifying for showing..)

public sealed class GeneratedIMyHub : IMyHub
{
    private readonly HubConnection conn;
    public GeneratedIMyHub(HubConnection connection)
    {
        this.conn = connection;
    }
    
    public Task DoSomething()
    {
        return this.conn.InvokeAsync("DoSomething");
    }
    
    public Task<int> DoSomethingElse(float arg)
    {
        return this.conn.InvokeAsync<int>("DoSomethingElse", arg);
    }
    
    public IAsyncEnumerable<int> StreamBidirectional(IAsyncEnumerable<float> clientToServer, CancellationToken token)
    {
        return this.conn.StreamAsync<int>("StreamBidrectional", clientToServer, token);
    }
}

Methods with void rtype are the only caveat. These would be called via SendAsync but that returns a task so we have to ignore or discard the returned task.

GeneratedIMyHub is an implementation detail that is not shown to the user on any API (though they can browse to source if they want). When the user calls hubConnection.GetProxy<IMyHub>(), they just get an object that satisfies IMyHub which happens to really be of type GeneratedIMyHub.

Should this be taking the HubConnection or the proxy type?

RegisterCallbackProvider should indeed take the HubConnection object and not the proxy type or the proxy object. This is because the callbacks aren't related to the strongly-typed representation of the hub. TInterface here would nominally be a IMyClient published/shared by server in a common project/library. In my opinion, we shouldn't tie this to IMyHub which is implemented by proxy as well as the actual hub.

In the case of RegisterCallbackProvider, you can imagine two variants:

public IDisposable RegisterCallbackProvider<TInterface, TImplementation>(this HubConnection conn, TImplementation callbackProvider) where TImplementation : TInterface;

public IDisposable RegisterCallbackProvider<TClient>(this HubConnection conn, TClient callbackProvider);

Underlying code would look like:

public IDisposable RegisterCallbackProvider<TInterface, TImplementation>(this HubConnection conn, TImplementation callbackProvider) where TImplementation : TInterface
{
    var intfType = typeof(TInterface);
    var regs = new List<IDisposable>();
    var methods = intfType.GetMethods(BindingFlags.Instance | BindingFlags.Public);
    foreach (var method in methods)
    {
        var reg = conn.On(method.Name, method.GetParameters().Select(a => a.ParameterType).ToArray(),
                objects =>
                    {
                        method.Invoke(spoke, objects);
                        return Task.CompletedTask;
                    });
                regs.Add(reg);
    }
    return MakeDisposableOf(regs);
}

public IDisposable RegisterCallbackProvider<TClient>(this HubConnection conn, TClient callbackProvider)
{
    return RegisterCallbackProvider<TClient, TClient>(conn, callbackProvider);
}

As a more concrete example, we can have the following interface in our shared/common project:

public interface IMyClient
{
    void SomeCallback(float arg);
}

In client project, user can utilize a callback provider like:

public class MyClient : IMyClient
{
    public void SomeCallback(float arg) { .. }
}

var registration = hubConnection.RegisterCallbackProvider<IMyClient, MyClient>(new MyClient());
// or hubConnection.RegisterCallbackProvider(new MyClient());
registration.Dispose();

There aren't many restrictions on what could be a callback provider. For example, if the user doesn't have a IMyClient, they can still register a provider by using the 2nd overload. That would just use all public instance methods as the "interface" so the user would need to ensure they don't have public methods that aren't meant to be callable. Though we can restrict registration to always supply an interface (and remove the 2nd overload) if we don't like this option.

@BrennanConroy
Copy link
Contributor

@BrennanConroy BrennanConroy commented May 27, 2021

Couple notes:

  • We would prefer only needing to run the source generator from the client-side, don't need to look at server project.
    This would require an attribute for each interface, Hub<T1> and MyHub : T2

  • Throw for void methods/source gen time error

    • we call async code underneath, lets not hide that and force users interfaces to be accurate
  • Make sure to follow the interface chain (note from previous code we've written where we didn't take inheritance into consideration)

  • The RegisterCallbackProvider method above was using reflection, it should be simplified with the source generator

IDisposable RegisterCallbackProvider<TInterface>(TInterface impl)
{
    var disposableCollection;
    // for loop is just for pseudo code purposes, this would be source generated to have individual calls for each .On callback
    foreach (method)
    {
        disposableCollection.Add(conn.On("blah", (param1) => impl.blah(param1)));
    }

    return disposableCollection;
}
  • Consider having a runtime fallback for F# as it doesn't support source generators currently

@bradygaster
Copy link
Member

@bradygaster bradygaster commented Jun 1, 2021

@mehmetakbulut - would you mind reaching out with your email or a good way to schedule you for a teams meeting? The team likes your approach and discussed some ideas we have around it. We'd like to iterate on our design ideas and the discussion we had, so if you could provide a way for us to get in touch we'd like to schedule a meeting with you. You could also DM me on twitter at @bradygaster if that is easier (ping me so I can follow so we can DM if that works).

@mehmetakbulut
Copy link
Contributor Author

@mehmetakbulut mehmetakbulut commented Jun 3, 2021

@bradygaster I just sent you an email from my personal.

@BrennanConroy
Copy link
Contributor

@BrennanConroy BrennanConroy commented Jun 15, 2021

HubConnection connection;
connection.GetProxy<T>(); // find "GetProxy", stash syntax node for second pass
// example:
// https://github.com/davidfowl/uController/blob/aa8bcb4b30764e42bd72d326cb2718a4d4eaf4a9/src/uController.SourceGenerator/uControllerGenerator.cs#L163-L179
// https://github.com/davidfowl/uController/blob/aa8bcb4b30764e42bd72d326cb2718a4d4eaf4a9/src/uController.SourceGenerator/uControllerGenerator.cs#L37-L43

Try a similar method for RegisterCallbackProvider as well.

For testing:
We can consider adding IHubConnection to make unit testing possible/easier. Today you need to either do E2E testing or something super hacky to verify that the correct HubConnection methods are called.

@BrennanConroy
Copy link
Contributor

@BrennanConroy BrennanConroy commented Oct 26, 2021

Initial source generator work has been merged! Thanks a ton @mehmetakbulut!

To use the source generator:

  1. add a reference to Microsoft.AspNetCore.SignalR.Client.SourceGenerator from feed https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet7%40Local/nuget/v3/index.json
  2. add a HubServerProxyAttribute class to your project:
[AttributeUsage(AttributeTargets.Method)]
internal class HubServerProxyAttribute : Attribute
{
}
  1. add a HubClientProxyAttribute class to your project:
[AttributeUsage(AttributeTargets.Method)]
internal class HubClientProxyAttribute : Attribute
{
}
  1. add a static partial class to your project and write a static partial method with the [HubClientProxy] attribute
internal static partial class MyCustomExtensions
{
    [HubClientProxy]
    public static partial IDisposable AnyNameYouWant<T>(this HubConnection connection, T provider);
}
  1. add a static partial class to your project (could be the same as step 4) and write a static partial method with the [HubServerProxy] attribute
internal static partial class MyCustomExtensions
{
    [HubServerProxy]
    public static partial T AnotherName<T>(this HubConnection connection);
}
  1. use the partial methods from your code!
public interface IServerHub
{
    Task SendMessage(string message);
    Task<int> Echo(int i);
}
public interface IClient
{
    Task ReceiveMessage(string message);
}
public class Client : IClient
{
    // Equivalent to HubConnection.On("ReceiveMessage", (message) => {});
    Task ReceiveMessage(string message)
    {
        return Task.CompletedTask;
    }
}

HubConnection connection = new HubConnectionBuilder().WithUrl("...").Build();
var stronglyTypedConnection = connection.AnotherName<IServerHub>();
var registrations = connection.AnyNameYouWant<IClient>(new Client());

await stronglyTypedConnection.SendMessage("Hello world");
var echo = await stronglyTypedConnection.Echo(10);

Follow up source generator work:

  • Add HubClientProxyAttribute and HubServerProxyAttribute to the product
    • Put it in the client dll or add a dll to the source generator with these classes
  • remove <T> requirement from methods
  • allow non-extension method syntax private static partial IServerHub GetProxy(HubConnection connection);
  • support multiple attributed methods (currently limited to 1 HubClientProxy and 1 HubServerProxy)
  • Test diagnostic messages, like
    var diagnostics = await Runner.GetDiagnosticsAsync(source.Source);
  • See how the experience feels and improve/add more diagnostics
  • code-fix for writing the methods? Triggers on writing the attribute maybe? [HubServerProxy]

@BrennanConroy BrennanConroy reopened this Oct 26, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

6 participants