Using SignalR 5.0
INFO
The SignalR transport has been requested several times, but finally got built specifically for the forthcoming "CritterWatch" product that will be used to monitor and manage Wolverine applications. In other words, the Wolverine team has heavily dog-fooded this feature.
TIP
Much of the sample code is taken from a runnable sample application in the Wolverine codebase called WolverineChat.
The SignalR library from Microsoft isn't hard to use from Wolverine for simplistic WebSockets or Server Side Events usage , but what if you want a server side application to exchange any number of different messages between a browser (or other WebSocket client because that's actually possible) and your server side code in a systematic way? To that end, Wolverine now supports a first class messaging transport for SignalR. To get started, just add a Nuget reference to the WolverineFx.SignalR
library:
dotnet add package WolverineFx.SignalR
Configuring the Server
TIP
Wolverine.SignalR does not require any usage of Wolverine.HTTP, but these two libraries can certainly be used in the same application as well.
The Wolverine.SignalR library sets up a single SignalR Hub
type in your system (WolverineHub
) that will be used to both send and receive messages from the browser. To set up both the SignalR transport and the necessary SignalR services in your DI container, use this syntax in the Program
file of your web application:
builder.UseWolverine(opts =>
{
// This is the only single line of code necessary
// to wire SignalR services into Wolverine itself
// This does also call IServiceCollection.AddSignalR()
// to register DI services for SignalR as well
opts.UseSignalR(o =>
{
// Optionally configure the SignalR HubOptions
// for the WolverineHub
o.ClientTimeoutInterval = 10.Seconds();
});
// Using explicit routing to send specific
// messages to SignalR
opts.Publish(x =>
{
// WolverineChatWebSocketMessage is a marker interface
// for messages within this sample application that
// is simply a convenience for message routing
x.MessagesImplementing<WolverineChatWebSocketMessage>();
x.ToSignalR();
});
});
That handles the Wolverine configuration and the SignalR service registrations, but you will also need to map an HTTP route for the SignalR hub with this Wolverine.SignalR helper:
var app = builder.Build();
app.UseRouting();
app.UseAuthorization();
app.MapStaticAssets();
app.MapRazorPages()
.WithStaticAssets();
// This line puts the SignalR hub for Wolverine at the
// designated route for your clients
app.MapWolverineSignalRHub("/api/messages");
return await app.RunJasperFxCommands(args);
Messages and Serialization
For the message routing above, you'll notice that I utilized a marker interface just to facilitate message routing like this:
// Marker interface for the sample application just to facilitate
// message routing
public interface WolverineChatWebSocketMessage : WebSocketMessage;
The Wolverine WebSocketMessage
marker interface does have a little bit of impact in that:
- It implements the
IMessage
interface that's just a helper for Wolverine to discover message types in your application upfront for diagnostics or upfront resource creation - By marking your message types as
WebSocketMessage
, it changes Wolverine's message type name rules to using a Kebab-cased version of the message type name
For example, these three message types:
public record ChatMessage(string User, string Text) : WolverineChatWebSocketMessage;
public record ResponseMessage(string User, string Text) : WolverineChatWebSocketMessage;
public record Ping(int Number) : WolverineChatWebSocketMessage;
will result in these message type names according to Wolverine:
.NET Type | Wolverine Message Type Name |
---|---|
ChatMessage | "chat_message" |
ResponseMessage | "response_message" |
Ping | "ping" |
That message type name is important because the Wolverine SignalR transport uses and expects a very light CloudEvents wrapper around the raw message being sent to the client and received from the browser. Here's an example of the JSON payload for the ChatMessage
message:
{
"type": "chat_message",
"data": {
"user": "Hank",
"text": "Hey"
}
}
You can always preview the message type name by using the dotnet run -- describe
command and finding the "Message Routing" table in that output, which should look like this from the sample application:
Message Routing
┌───────────────────────────────┬────────────────────┬──────────────────────┬──────────────────┐
│ .NET Type │ Message Type Alias │ Destination │ Content Type │
├───────────────────────────────┼────────────────────┼──────────────────────┼──────────────────┤
│ WolverineChat.ChatMessage │ chat_message │ signalr://wolverine/ │ application/json │
│ WolverineChat.Ping │ ping │ signalr://wolverine/ │ application/json │
│ WolverineChat.ResponseMessage │ response_message │ signalr://wolverine/ │ application/json │
└───────────────────────────────┴────────────────────┴──────────────────────┴──────────────────┘
The only elements that are mandatory are the type
node that should be the Wolverine message type name and data
that is the actual message serialized by JSON. Wolverine will send the full CloudEvents envelope structure because it's reusing the envelope mapping from our CloudEvents interoperability, but the browser code only needs to send type
and data
.
The actual JSON serialization in the SignalR transport is isolated from the rest of Wolverine and uses this default System.Text.Json
configuration:
JsonOptions = new(JsonSerializerOptions.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
JsonOptions.Converters.Add(new JsonStringEnumConverter());
But of course, if you needed to override the JSON serialization for whatever reason, you can just push in a different JsonSerializerOptions
like this:
var builder = WebApplication.CreateBuilder();
builder.UseWolverine(opts =>
{
// Just showing you how to override the JSON serialization
opts.UseSignalR().OverrideJson(new JsonSerializerOptions
{
IgnoreReadOnlyProperties = false
});
});
Interacting with the Server from the Browser
It's not mandatory, but in developing and dogfooding the Wolverine.SignalR transport, we've found it helpful to use the actual signalr Javascript library and our sample SignalR application uses that library for the browser to server communication.
"use strict";
// Connect to the server endpoint
var connection = new signalR.HubConnectionBuilder().withUrl("/api/messages").build();
//Disable the send button until connection is established.
document.getElementById("sendButton").disabled = true;
// Receiving messages from the server
connection.on("ReceiveMessage", function (json) {
// Note that you will need to deserialize the raw JSON
// string
const message = JSON.parse(json);
// The client code will need to effectively do a logical
// switch on the message.type. The "real" message is
// the data element
if (message.type == 'ping'){
console.log("Got ping " + message.data.number);
}
else{
const li = document.createElement("li");
document.getElementById("messagesList").appendChild(li);
li.textContent = `${message.data.user} says ${message.data.text}`;
}
});
connection.start().then(function () {
document.getElementById("sendButton").disabled = false;
}).catch(function (err) {
return console.error(err.toString());
});
document.getElementById("sendButton").addEventListener("click", function (event) {
const user = document.getElementById("userInput").value;
const text = document.getElementById("messageInput").value;
// Remember that we need to wrap the raw message in this slim
// CloudEvents wrapper
const message = {type: 'chat_message', data: {'text': text, 'user': user}};
// The WolverineHub method to call is ReceiveMessage with a single argument
// for the raw JSON
connection.invoke("ReceiveMessage", JSON.stringify(message)).catch(function (err) {
return console.error(err.toString());
});
event.preventDefault();
});
Note that the method ReceiveMessage
is hard coded into the WolverineHub
service.
Our vision for this usage is that you probably integrate directly with a client side state tracking tool like Pinia (how we're using the SignalR transport to build "CritterWatch").
Sending Messages to SignalR
For the most part, sending a message to SignalR is just like sending messages with any other transport like this sample:
public class Pinging : BackgroundService
{
private readonly IWolverineRuntime _runtime;
public Pinging(IWolverineRuntime runtime)
{
_runtime = runtime;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var number = 0;
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(1.Seconds(), stoppingToken);
// This is being published to all connected SignalR
// applications
await new MessageBus(_runtime).PublishAsync(new Ping(++number));
}
}
}
The call above will occasionally send a Ping
message to all connected clients. But of course, you'll frequently want to more selectively send messages to reply to the current connection or maybe to a specific group.
If you are handling a message that originated from SignalR, you can send a response back to the originating connection like this:
public record RequestSum(int X, int Y) : WebSocketMessage;
public record SumAnswer(int Value) : WebSocketMessage;
public static class RequestSumHandler
{
public static ResponseToCallingWebSocket<SumAnswer> Handle(RequestSum message)
{
return new SumAnswer(message.X + message.Y)
// This extension method will wrap the raw message
// with some helpers that will
.RespondToCallingWebSocket();
}
}
In the next section we'll learn a bit more about working with SignalR groups.
SignalR Groups
One of the powerful features of SignalR is being able to work with groups of connections. The SignalR transport currently has some simple support for managing and publishing to groups. Let's say you have these web socket messages in your system:
public record EnrollMe(string GroupName) : WebSocketMessage;
public record KickMeOut(string GroupName) : WebSocketMessage;
public record BroadCastToGroup(string GroupName, string Message) : WebSocketMessage;
The following code is a set of simplistic message handlers that handle these messages with some SignalR connection group mechanics:
// Declaring that you need the connection that originated
// this message to be added to the named SignalR client group
public static AddConnectionToGroup Handle(EnrollMe msg)
=> new(msg.GroupName);
// Declaring that you need the connection that originated this
// message to be removed from the named SignalR client group
public static RemoveConnectionToGroup Handle(KickMeOut msg)
=> new(msg.GroupName);
// The message wrapper here sends the raw message to
// the named SignalR client group
public static SignalRMessage<Information> Handle(BroadCastToGroup msg)
=> new Information(msg.Message)
// This extension method wraps the "real" message
// with an envelope that routes this original message
// to the named group
.ToWebSocketGroup(msg.GroupName);
In the code above:
AddConnectionToGroup
andRemoveConnectionToGroup
are both examples of Wolverine "side effects" that are specific to adding or removing the current SignalR connection (whichever connection originated the message and where the SignalR transport received the message)ToWebSocketGroup(group name)
is an extension method in Wolverine.SignalR that restricts the message being sent to SignalR to only being sent to connections in that named group
SignalR Client Transport
TIP
If you want to use the .NET SignalR Client for test automation, just know that you will need to bootstrap the service that actually hosts SignalR with the full stack including Kestrel. WebApplicationFactory
will not be suitable for this type of integration testing through SignalR.
Wolverine.SignalR is actually two transports in one library! There is also a full fledged messaging transport built around the .NET SignalR client that we've used extensively for test automation, but could technically be used as a "real" messaging transport. The SignalR Client transport was built specifically to enable end to end testing against a Wolverine server that hosts SignalR itself. The SignalR Client transport will use the same CloudEvents mechanism to send and receive messages from the main Wolverine SignalR transport and is 100% compatible.
If you wanted to use the SignalR client as a "real" messaging transport, you could do that like this sample:
var builder = Host.CreateApplicationBuilder();
builder.UseWolverine(opts =>
{
// this would need to be an absolute Url to where SignalR is
// hosted on your application and include the exact route where
// the WolverineHub is listening
var url = builder.Configuration.GetValue<string>("signalr.url");
opts.UseClientToSignalR(url);
// Setting this up to publish any messages implementing
// the WebSocketMessage marker interface with the SignalR
// client
opts.Publish(x =>
{
x.MessagesImplementing<WebSocketMessage>();
x.ToSignalRWithClient(url);
});
});
Or a little more simply, if you are just using this for test automation, you would need to give it the port number where your SignalR hosting service is running on the local computer:
// Ostensibly, *something* in your test harness would
// be telling you the port number of the real application
int port = 5555;
using var clientHost = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
// Just so you know it's possible, you can override
// the relative url of the SignalR WolverineHub route
// in the hosting application
opts.UseClientToSignalR(port, "/api/messages");
// Setting this up to publish any messages implementing
// the WebSocketMessage marker interface with the SignalR
// client
opts.Publish(x =>
{
x.MessagesImplementing<WebSocketMessage>();
x.ToSignalRWithClient(port);
});
}).StartAsync();
To make this a little more concrete, here's a little bit of the test harness setup we used to test the Wolverine.SignalR transport:
public abstract class WebSocketTestContext : IAsyncLifetime
{
protected WebApplication theWebApp;
private readonly int Port = PortFinder.GetAvailablePort();
protected readonly Uri clientUri;
private readonly List<IHost> _clientHosts = new();
public WebSocketTestContext()
{
clientUri = new Uri($"http://localhost:{Port}/messages");
}
public async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(opts =>
{
opts.ListenLocalhost(Port);
});
In the same test harness class, we bootstrap new IHost
instances with the SignalR Client to mimic browser client communication like this:
var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.ServiceName = serviceName;
opts.UseClientToSignalR(Port);
opts.PublishMessage<ToFirst>().ToSignalRWithClient(Port);
opts.PublishMessage<RequiresResponse>().ToSignalRWithClient(Port);
opts.Publish(x =>
{
x.MessagesImplementing<WebSocketMessage>();
x.ToSignalRWithClient(Port);
});
}).StartAsync();
The key point here is that we stood up the service using a port number for Kestrel, then stood up IHost
instances for a Wolverine application using the SignalR Client using the same port number for easy connectivity.
And of course, after all of that we should probably talk about how to publish messages via the SignalR Client. Fortunately, there's really nothing to it. You merely need to invoke the normal IMessageBus.PublishAsync()
APIs that you would use for any messaging. In the sample test below, we're utilizing the tracked session functionality as normal to send a message from the IHost
hosting the SignalR Client transport and expect it to be successfully handled in the IHost
for our actual SignalR server:
[Fact]
public async Task receive_message_from_a_client()
{
// 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<ToSecond>();
record.ServiceName.ShouldBe("Server");
record.Envelope.Destination.ShouldBe(new Uri("signalr://wolverine"));
record.Message.ShouldBeOfType<ToSecond>()
.Name.ShouldBe("Hollywood Brown");
}
Conveniently enough as I write this documentation today using existing test code, Hollywood Brown had a huge game last night. Go Chiefs!
Web Socket "Sagas"
INFO
The functionality described in this section was specifically built for "CritterWatch" where a browser request kicks off a "scatter/gather" series of messages from CritterWatch to other Wolverine services and finally back to the originating browser client.
Let's say that you have a workflow in your system something like:
- The browser makes a web socket call to the server to request some information or take a long running action
- The server application needs to execute several messages or even call out to additional Wolverine services
- Once the server application has finally completed the work that the client requested, the server needs to send a message to the originating SignalR connection with the status of the long running activity or the data that the original client requested
The SignalR transport can leverage some of Wolverine's built in saga tracking to be able to route the eventual Web Socket response back to the originating caller even if the work required intermediate steps. The easiest way to enroll in this behavior today is the usage of the [EnlistInCurrentConnectionSaga]
that should be on either