diff --git a/docs/guide/messaging/transports/signalr.md b/docs/guide/messaging/transports/signalr.md index 50e32befc..43729c7d9 100644 --- a/docs/guide/messaging/transports/signalr.md +++ b/docs/guide/messaging/transports/signalr.md @@ -107,6 +107,37 @@ return await app.RunJasperFxCommands(args); snippet source | anchor +## Custom hubs + +If the default `WolverineHub` isn't enough, you can provide a custom Hub that will be used for all received messages: + + + +```cs +builder.Services.AddSignalR(); +builder.Host.UseWolverine(opts => +{ + opts.ServiceName = "Server"; + + // Hooking up the SignalR messaging transport + // in Wolverine using a custom hub + opts.UseSignalR(); + + // A message for testing + opts.PublishMessage().ToSignalR(); +}); + +var app = builder.Build(); + +// Syntactic sugar, really just doing: +// app.MapHub("/messages"); +app.MapWolverineSignalRHub(); +``` +snippet source | anchor + + +Custom hubs must still inherit from `WolverineHub`. It's possible to override `ReceiveMessage`, but if you don't invoke the base functionality you're gonna have a bad time. + ## Messages and Serialization For the message routing above, you'll notice that I utilized a marker interface just to facilitate message routing diff --git a/src/Samples/WolverineChat/Program.cs b/src/Samples/WolverineChat/Program.cs index e4436dd24..e76592821 100644 --- a/src/Samples/WolverineChat/Program.cs +++ b/src/Samples/WolverineChat/Program.cs @@ -68,9 +68,16 @@ app.UseAuthorization(); +#if NET9_0_OR_GREATER app.MapStaticAssets(); app.MapRazorPages() .WithStaticAssets(); +#endif +#if NET8_0 +app.UseStaticFiles(); +app.MapRazorPages(); +#endif + // This line puts the SignalR hub for Wolverine at the // designated route for your clients diff --git a/src/Transports/SignalR/Wolverine.SignalR.Tests/WebSocketTestContext.cs b/src/Transports/SignalR/Wolverine.SignalR.Tests/WebSocketTestContext.cs index 249abdddf..b971af240 100644 --- a/src/Transports/SignalR/Wolverine.SignalR.Tests/WebSocketTestContext.cs +++ b/src/Transports/SignalR/Wolverine.SignalR.Tests/WebSocketTestContext.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Wolverine.Runtime; @@ -14,7 +15,7 @@ namespace Wolverine.SignalR.Tests; public abstract class WebSocketTestContext : IAsyncLifetime { protected WebApplication theWebApp; - private readonly int Port = PortFinder.GetAvailablePort(); + protected readonly int Port = PortFinder.GetAvailablePort(); protected readonly Uri clientUri; private readonly List _clientHosts = new(); @@ -34,12 +35,12 @@ public async Task InitializeAsync() }); #endregion - + builder.Services.AddSignalR(); builder.Host.UseWolverine(opts => { opts.ServiceName = "Server"; - + // Hooking up the SignalR messaging transport // in Wolverine opts.UseSignalR(); @@ -54,11 +55,11 @@ public async Task InitializeAsync() }); var app = builder.Build(); - + // Syntactic sure, really just doing: // app.MapHub("/messages"); app.MapWolverineSignalRHub(); - + await app.StartAsync(); // Remember this, because I'm going to use it in test code @@ -76,13 +77,13 @@ public async Task StartClientHost(string serviceName = "Client") .UseWolverine(opts => { opts.ServiceName = serviceName; - + opts.UseClientToSignalR(Port); - + opts.PublishMessage().ToSignalRWithClient(Port); - + opts.PublishMessage().ToSignalRWithClient(Port); - + opts.Publish(x => { x.MessagesImplementing(); @@ -91,7 +92,7 @@ public async Task StartClientHost(string serviceName = "Client") }).StartAsync(); #endregion - + _clientHosts.Add(host); return host; @@ -106,7 +107,96 @@ public async Task DisposeAsync() await clientHost.StopAsync(); } } - + +} + +public abstract class WebSocketTestContextWithCustomHub : IAsyncLifetime where THub : WolverineHub +{ + protected WebApplication theWebApp; + protected readonly int Port = PortFinder.GetAvailablePort(); + protected readonly Uri clientUri; + + private readonly List _clientHosts = new(); + + public WebSocketTestContextWithCustomHub() + { + clientUri = new Uri($"http://localhost:{Port}/messages"); + } + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.ConfigureKestrel(opts => + { + opts.ListenLocalhost(Port); + }); + + #region sample_custom_signalr_hub + builder.Services.AddSignalR(); + builder.Host.UseWolverine(opts => + { + opts.ServiceName = "Server"; + + // Hooking up the SignalR messaging transport + // in Wolverine using a custom hub + opts.UseSignalR(); + + // A message for testing + opts.PublishMessage().ToSignalR(); + }); + + var app = builder.Build(); + + // Syntactic sugar, really just doing: + // app.MapHub("/messages"); + app.MapWolverineSignalRHub(); + #endregion + + await app.StartAsync(); + + // Remember this, because I'm going to use it in test code + // later + theWebApp = app; + } + + // This starts up a new host to act as a client to the SignalR + // server for testing + public async Task StartClientHost(string serviceName = "Client") + { + var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.ServiceName = serviceName; + + opts.UseClientToSignalR(Port); + + opts.PublishMessage().ToSignalRWithClient(Port); + + opts.PublishMessage().ToSignalRWithClient(Port); + + opts.Publish(x => + { + x.MessagesImplementing(); + x.ToSignalRWithClient(Port); + }); + }).StartAsync(); + + _clientHosts.Add(host); + + return host; + } + + public virtual async Task DisposeAsync() + { + await theWebApp.StopAsync(); + + foreach (var clientHost in _clientHosts) + { + await clientHost.StopAsync(); + } + } + } public record ToFirst(string Name) : WebSocketMessage; diff --git a/src/Transports/SignalR/Wolverine.SignalR.Tests/custom_hub.cs b/src/Transports/SignalR/Wolverine.SignalR.Tests/custom_hub.cs new file mode 100644 index 000000000..39ec94fbd --- /dev/null +++ b/src/Transports/SignalR/Wolverine.SignalR.Tests/custom_hub.cs @@ -0,0 +1,80 @@ +using JasperFx.Core; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Shouldly; +using Wolverine.SignalR.Client; +using Wolverine.SignalR.Internals; +using Wolverine.Tracking; + +namespace Wolverine.SignalR.Tests; + +public class custom_hub : WebSocketTestContextWithCustomHub +{ + public static List ReceivedJson = new(); + + public override async Task DisposeAsync() + { + ReceivedJson.Clear(); + await base.DisposeAsync(); + } + + [Fact] + public async Task publishing_from_server_uses_custom_hub() + { + var client = await StartClientHost(); + + var tracked = await theWebApp + .TrackActivity() + .IncludeExternalTransports() + .AlsoTrack(client) + .Timeout(10.Seconds()) + .SendMessageAndWaitAsync(new FromSecond("Hollywood Brown")); + + var record = tracked.Received.SingleRecord(); + record.ServiceName.ShouldBe("Client"); + record.Envelope.ShouldNotBeNull(); + record.Envelope.Destination.ShouldBe(new Uri($"signalr-client://localhost:{Port}/messages")); + record.Message.ShouldBeOfType() + .Name.ShouldBe("Hollywood Brown"); + } + + [Fact] + public async Task publishing_from_client_uses_custom_hub() + { + // This is an IHost that has the SignalR Client + // transport configured to connect to a SignalR + // server in the "theWebApp" IHost + using var client = await StartClientHost(); + + var tracked = await client + .TrackActivity() + .IncludeExternalTransports() + .AlsoTrack(theWebApp) + .Timeout(10.Seconds()) + .ExecuteAndWaitAsync(c => c.SendViaSignalRClient(clientUri, new ToSecond("Hollywood Brown"))); + + var record = tracked.Received.SingleRecord(); + record.ServiceName.ShouldBe("Server"); + record.Envelope.ShouldNotBeNull(); + record.Envelope.Destination.ShouldBe(new Uri("signalr://wolverine")); + record.Message.ShouldBeOfType() + .Name.ShouldBe("Hollywood Brown"); + + ReceivedJson.Count.ShouldBe(1); + } +} + +public class CustomWolverineHub(SignalRTransport endpoint, ILogger logger) : WolverineHub(endpoint) +{ + public override Task OnConnectedAsync() + { + logger.LogInformation("Client connected with ID {ConnectionId}", Context.ConnectionId); + return base.OnConnectedAsync(); + } + + public override Task ReceiveMessage(string json) + { + custom_hub.ReceivedJson.Add(json); + return base.ReceiveMessage(json); + } +} diff --git a/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientEndpoint.cs b/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientEndpoint.cs index c02f4f70e..a9e0323fe 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientEndpoint.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientEndpoint.cs @@ -1,8 +1,8 @@ -using System.Diagnostics; -using System.Text.Json; using JasperFx.Core; +using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; +using System.Text.Json; using Wolverine.Configuration; using Wolverine.Runtime; using Wolverine.Runtime.Interop; @@ -23,8 +23,8 @@ internal static Uri TranslateToWolverineUri(Uri uri) { return new Uri($"{SignalRClientTransport.ProtocolName}://{uri.Host}:{uri.Port}/{uri.Segments.Last()}"); } - - public SignalRClientEndpoint(Uri uri, SignalRClientTransport parent) : base(TranslateToWolverineUri(uri),EndpointRole.Application) + + public SignalRClientEndpoint(Uri uri, SignalRClientTransport parent) : base(TranslateToWolverineUri(uri), EndpointRole.Application) { _parent = parent; SignalRUri = uri; @@ -49,7 +49,7 @@ public override async ValueTask BuildListenerAsync(IWolverineRuntime _mapper ??= BuildCloudEventsMapper(runtime, JsonOptions); Logger = runtime.LoggerFactory.CreateLogger(); - + await _connection.StartAsync(); _connection.On(SignalRTransport.DefaultOperation, [typeof(string)], (args => @@ -61,13 +61,13 @@ public override async ValueTask BuildListenerAsync(IWolverineRuntime Logger.LogDebug("Received an empty message, ignoring"); return Task.CompletedTask; } - + return ReceiveAsync(json); })); return this; } - + internal async Task ReceiveAsync(string json) { if (Receiver == null || _mapper == null) return; @@ -88,7 +88,7 @@ internal async Task ReceiveAsync(string json) Logger?.LogError(e, "Unable to receive a message from SignalR"); } } - + public IReceiver? Receiver { get; private set; } protected override ISender CreateSender(IWolverineRuntime runtime) @@ -142,7 +142,7 @@ public async ValueTask SendAsync(Envelope envelope) { if (_mapper == null || _connection == null) throw new InvalidOperationException($"SignalR Client {Uri} is not initialized"); - + var json = _mapper.WriteToString(envelope); await _connection.InvokeAsync(nameof(WolverineHub.ReceiveMessage), json); diff --git a/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientExtensions.cs b/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientExtensions.cs index aad848c52..c732eaadd 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientExtensions.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientExtensions.cs @@ -1,5 +1,6 @@ using System.Text.Json; using JasperFx.Core.Reflection; +using Microsoft.AspNetCore.SignalR; using Wolverine.Configuration; namespace Wolverine.SignalR.Client; @@ -38,9 +39,7 @@ public static Uri UseClientToSignalR(this WolverineOptions options, string url, /// Default is messages. Route pattern where you have mapped the WolverineHub /// public static Uri UseClientToSignalR(this WolverineOptions options, int port, string route = "messages") - { - return options.UseClientToSignalR($"http://localhost:{port}/{route}"); - } + => options.UseClientToSignalR($"http://localhost:{port}/{route}"); /// /// Send a message via a SignalR Client for the given server Uri in the format "http://localhost:[port]/[hub url]" @@ -79,7 +78,7 @@ public static void ToSignalRWithClient(this IPublishToExpression publishing, int var url = $"http://localhost:{port}/{relativeUrl}"; publishing.ToSignalRWithClient(url); } - + /// /// Route messages via a SignalR Client pointed at the supplied absolute Url /// diff --git a/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientTransport.cs b/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientTransport.cs index 081c22345..da4173a60 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientTransport.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Client/SignalRClientTransport.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using JasperFx.Core; using Wolverine.Transports; @@ -7,7 +6,7 @@ namespace Wolverine.SignalR.Client; public class SignalRClientTransport : TransportBase { public static readonly string ProtocolName = "signalr-client"; - + public Cache Clients { get; } public SignalRClientTransport() : base(ProtocolName, "SignalR Client", ["signalr"]) diff --git a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalREnvelope.cs b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalREnvelope.cs index 88a696273..be4b7566a 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalREnvelope.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalREnvelope.cs @@ -4,9 +4,9 @@ namespace Wolverine.SignalR.Internals; public class SignalREnvelope : Envelope { - public IHubContext HubContext { get; } + public IHubContext HubContext { get; } - public SignalREnvelope(HubCallerContext context, IHubContext hubContext) + public SignalREnvelope(HubCallerContext context, IHubContext hubContext) { HubContext = hubContext; ConnectionId = context.ConnectionId; diff --git a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRListenerConfiguration.cs b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRListenerConfiguration.cs index 32a89c3dd..f08de5a1f 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRListenerConfiguration.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRListenerConfiguration.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.SignalR; using System.Text.Json; using Wolverine.Configuration; diff --git a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRSubscriberConfiguration.cs b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRSubscriberConfiguration.cs index 80933e35f..a17b79a8f 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRSubscriberConfiguration.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRSubscriberConfiguration.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.SignalR; using System.Text.Json; using Wolverine.Configuration; diff --git a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRTransport.cs b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRTransport.cs index 58353885f..f94eec864 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRTransport.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Internals/SignalRTransport.cs @@ -63,12 +63,15 @@ ValueTask ITransport.InitializeAsync(IWolverineRuntime runtime) _mapper ??= BuildCloudEventsMapper(runtime, JsonOptions); Logger ??= runtime.LoggerFactory.CreateLogger(); - HubContext ??= runtime.Services.GetRequiredService>(); + + var hubContextType = typeof(IHubContext<>).MakeGenericType(HubType); + HubContext ??= (IHubContext)runtime.Services.GetRequiredService(hubContextType); return new ValueTask(); } - public IHubContext? HubContext { get; private set; } + public IHubContext? HubContext { get; private set; } + public Type HubType { get; internal set; } = typeof(WolverineHub); bool ITransport.TryBuildStatefulResource(IWolverineRuntime runtime, out IStatefulResource? resource) { @@ -147,6 +150,7 @@ protected override bool supportsMode(EndpointMode mode) public bool SupportsNativeScheduledSend => false; public Uri Destination => Uri; + public async Task PingAsync() { try diff --git a/src/Transports/SignalR/Wolverine.SignalR/Internals/WebSocketRouting.cs b/src/Transports/SignalR/Wolverine.SignalR/Internals/WebSocketRouting.cs index a9abac586..7e9997e2e 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/Internals/WebSocketRouting.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/Internals/WebSocketRouting.cs @@ -34,12 +34,12 @@ internal static ILocator DetermineLocator(Envelope envelope) public interface ILocator { - IClientProxy Find(IHubContext context) where T : WolverineHub; + IClientProxy Find(IHubContext context) where T : Hub; } public record Connection(string ConnectionId) : ILocator { - public IClientProxy Find(IHubContext context) where T : WolverineHub + public IClientProxy Find(IHubContext context) where T : Hub { return context.Clients.Client(ConnectionId); } @@ -52,7 +52,7 @@ public override string ToString() public record All : ILocator { - public IClientProxy Find(IHubContext context) where T : WolverineHub + public IClientProxy Find(IHubContext context) where T : Hub { return context.Clients.All; } @@ -60,7 +60,7 @@ public IClientProxy Find(IHubContext context) where T : WolverineHub public record Group(string GroupName) : ILocator { - public IClientProxy Find(IHubContext context) where T : WolverineHub + public IClientProxy Find(IHubContext context) where T : Hub { return context.Clients.Group(GroupName); } diff --git a/src/Transports/SignalR/Wolverine.SignalR/SignalRWolverineExtensions.cs b/src/Transports/SignalR/Wolverine.SignalR/SignalRWolverineExtensions.cs index 72c941850..77bf31aaa 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/SignalRWolverineExtensions.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/SignalRWolverineExtensions.cs @@ -11,16 +11,19 @@ namespace Wolverine.SignalR; public static class SignalRWolverineExtensions { - /// Quick access to the Rabbit MQ Transport within this application. - /// This is for advanced usage + /// + /// Quick access to the SignalR Transport within this application. + /// This is for advanced usage /// /// /// - internal static SignalRTransport SignalRTransport(this WolverineOptions endpoints, BrokerName? name = null) + internal static SignalRTransport SignalRTransport(this WolverineOptions endpoints, BrokerName? name = null) where THub : WolverineHub { var transports = endpoints.As().Transports; - return transports.GetOrCreate(name); + var transport = transports.GetOrCreate(name); + transport.HubType = typeof(THub); + return transport; } /// @@ -50,6 +53,15 @@ public static SignalRMessage ToWebSocketGroup(this T Message, string Group /// Optionally configure the SignalR HubOptions for Wolverine /// public static SignalRListenerConfiguration UseSignalR(this WolverineOptions options, Action? configure = null) + => options.UseSignalR(configure); + + /// + /// Adds the WolverineHub to this application for SignalR message processing + /// + /// + /// Optionally configure the SignalR HubOptions for Wolverine + /// + public static SignalRListenerConfiguration UseSignalR(this WolverineOptions options, Action? configure = null) where THub : WolverineHub { if (configure == null) { @@ -61,7 +73,7 @@ public static SignalRListenerConfiguration UseSignalR(this WolverineOptions opti } - var transport = options.SignalRTransport(); + var transport = options.SignalRTransport(); options.Services.AddSingleton(s => s.GetRequiredService().Options.Transports.GetOrCreate()); @@ -77,13 +89,23 @@ public static SignalRListenerConfiguration UseSignalR(this WolverineOptions opti /// Optionally configure the Azure SignalR options for Wolverine /// public static SignalRListenerConfiguration UseAzureSignalR(this WolverineOptions options, Action? configureHub = null, Action? configureSignalR = null) + => options.UseAzureSignalR(configureHub, configureSignalR); + + /// + /// Adds a custom Wolverine SignalR hub to this application for Azure SignalR message processing + /// + /// + /// Optionally configure the SignalR HubOptions for Wolverine + /// Optionally configure the Azure SignalR options for Wolverine + /// + public static SignalRListenerConfiguration UseAzureSignalR(this WolverineOptions options, Action? configureHub = null, Action? configureSignalR = null) where THub : WolverineHub { configureHub ??= _ => { }; configureSignalR ??= _ => { }; options.Services.AddSignalR(configureHub).AddAzureSignalR(configureSignalR); - var transport = options.SignalRTransport(); + var transport = options.SignalRTransport(); options.Services.AddSingleton(s => s.GetRequiredService().Options.Transports.GetOrCreate()); @@ -98,17 +120,25 @@ public static SignalRListenerConfiguration UseAzureSignalR(this WolverineOptions /// /// public static HubEndpointConventionBuilder MapWolverineSignalRHub(this IEndpointRouteBuilder endpoints, string route = "messages") + => endpoints.MapWolverineSignalRHub(route); + + /// + /// Syntactical shortcut to register a custom Wolverine SignalR Hub for sending + /// messages to this server. Equivalent to MapHub(route). + /// + /// + /// + public static HubEndpointConventionBuilder MapWolverineSignalRHub(this IEndpointRouteBuilder endpoints, string route = "messages") where THub : WolverineHub { - return endpoints.MapHub(route); + return endpoints.MapHub(route); } /// - /// Create a subscription rule that publishes matching messages to the SignalR Hub of type "T" + /// Create a subscription rule that publishes matching messages to the default Wolverine SignalR Hub /// /// - /// /// - public static SignalRSubscriberConfiguration ToSignalR(this IPublishToExpression publishing) + public static SignalRSubscriberConfiguration ToSignalR(this IPublishToExpression publishing) { var transports = publishing.As().Parent.Transports; var transport = transports.GetOrCreate(); diff --git a/src/Transports/SignalR/Wolverine.SignalR/WolverineHub.cs b/src/Transports/SignalR/Wolverine.SignalR/WolverineHub.cs index e0c6b83ea..2a05e77db 100644 --- a/src/Transports/SignalR/Wolverine.SignalR/WolverineHub.cs +++ b/src/Transports/SignalR/Wolverine.SignalR/WolverineHub.cs @@ -4,7 +4,7 @@ namespace Wolverine.SignalR; /// -/// Base class for Wolverine enabled SignalR Hubs +/// Base class for Wolverine enabled SignalR Hubs /// public class WolverineHub : Hub { @@ -16,7 +16,7 @@ public WolverineHub(SignalRTransport endpoint) } [HubMethodName("ReceiveMessage")] - public Task ReceiveMessage(string json) + public virtual Task ReceiveMessage(string json) { return _endpoint.ReceiveAsync(Context, json); }