SignalR Broadcaster
NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.
samples/SignalRBroadcaster in Trax.Samples pushes each
train's lifecycle events to a browser as they happen. It is one process with one train, a plain HTML
page using the JavaScript SignalR client, and a hub that only a signed-in operator may join. Effects
are in memory, so it needs no database.
What it proves
| Feature | Where |
|---|---|
UseBroadcaster(b => b.UseSignalRHub(...)) and MapTraxTrainEventHub(...) are the whole server side | Program.cs |
The hub refuses a client that is not signed in, with 401 on negotiate | HubPostureTests |
| A browser cookie as the hub credential, sent on negotiate and on the WebSocket with no client code | Program.cs, wwwroot/index.html |
| A custom projection that sends a failure reason only when the train wrote it for clients | LiveTrainEvent.cs, LiveEventTests |
| The demo sign-in exists only in Development | HubPostureTests.Outside_development_there_is_no_way_to_sign_in |
Run
From the Trax.Samples root:
dotnet run --project samples/SignalRBroadcaster/Trax.Samples.SignalRBroadcasterOpen http://localhost:5270. dotnet run starts in Development through
Properties/launchSettings.json, the only environment that maps the demo sign-in.
Try it
- Press Try to connect without signing in: the hub's negotiate request answers
401, and no connection opens. Then press Sign in as the demo operator; the page connects to/hubs/trax-events. - Press A ping that succeeds: a run card appears and fills in live,
StartedthenCompleted, with the time between them. - Press A ping that fails, for readers: the run fails with a
TrainException, and its card shows the reasonThe ping target did not answer. - Press A ping that fails, internally: the card shows the reason withheld,
The run failed. The reason is in the server log., and the server's console shows the real message, which names an internal host and user.
Show the raw traffic lists what the page sends and every TrainEvent the hub pushes. POST /pings answers
202 Accepted and nothing else: the run's events reach the page over the hub.
Without signing in, both of these answer 401:
curl -i -X POST http://localhost:5270/pings
curl -i -X POST "http://localhost:5270/hubs/trax-events/negotiate?negotiateVersion=1"How it works
The server
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication; // SignInAsync
using Microsoft.AspNetCore.Authentication.Cookies;
using Trax.Effect.Broadcaster.SignalR.Extensions;
using Trax.Effect.Data.InMemory.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
builder
.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(cookie =>
{
cookie.Cookie.SameSite = SameSiteMode.Strict;
cookie.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
builder.Services.AddSignalR();
builder.Services.AddTrax(trax =>
trax.AddEffects(effects =>
effects
.UseInMemory()
.UseBroadcaster(broadcaster =>
broadcaster.UseSignalRHub(hub =>
hub.OnlyForEvents("Started", "Completed", "Failed")
.OnlyForTrains<IPingTrain>()
.WithProjection(LiveTrainEvent.From)
)
)
)
.AddMediator(typeof(IPingTrain).Assembly)
);
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapTraxTrainEventHub(hub => hub.RequireRoles("Operator"));UseSignalRHub is a sink, not a transport: with no RabbitMQ it serves the trains this process runs.
Add UseRabbitMq(...) beside it and events from workers in other processes reach the same hub (see
Broadcaster Sinks).
The hub sends every matching event to every client it admits, so MapTraxTrainEventHub will not
start without a posture. RequireRoles("Operator") admits authenticated callers in that role; the
negotiate request of anyone else gets 401 (not signed in) or 403 (signed in without the role).
AddSignalR() is required: without it MapTraxTrainEventHub throws at startup. See
MapTraxTrainEventHub.
The browser
<script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@8.0.7/dist/browser/signalr.min.js"
integrity="sha384-mU1xC5yC2LldSW74Rj1Ax8wPiLw/28V5eh51uKJMlBbRVsOtUYd4xyzNsgIAJARB"
crossorigin="anonymous"></script>
<script>
const hub = new signalR.HubConnectionBuilder()
.withUrl("/hubs/trax-events")
.withAutomaticReconnect()
.build();
hub.on("TrainEvent", (evt) => {
// evt is the projection: { externalId, trainName, eventType, timestamp, failureReason }
});
await hub.start();
</script>The page is served by the same host, so the browser sends the sign-in cookie on the negotiate request
and on the WebSocket upgrade by itself; there is no token code. A page on another origin, or a
bearer-token API, passes its credential with accessTokenFactory instead. SameSite=Strict keeps the
cookie off requests another site starts, which is what makes the cookie-authenticated POST /pings
safe from cross-site forgery.
The sign-in itself is a demo endpoint, mapped only in Development:
if (app.Environment.IsDevelopment())
app.MapPost("/demo/sign-in", async (HttpContext context) =>
{
var identity = new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "demo-operator"), new Claim(ClaimTypes.Role, "Operator")],
CookieAuthenticationDefaults.AuthenticationScheme
);
await context.SignInAsync(new ClaimsPrincipal(identity));
return Results.Redirect("/");
});Anywhere else nobody can sign in, so the hub admits nobody until you add a real sign-in.
Why the default payload has no failure reason
The default projection, TraxClientEvent, carries the run's ids, train name, event type and timestamp,
and leaves FailureReason off the wire. A failure reason is the text of the exception the failing
code threw. It can name a host, a user or a credential, and the hub sends every train's events to
every client it admits, whoever started the run. So the sink sends none unless you choose to.
This sample chooses the same rule GraphQL subscriptions use: show the message only when the run failed
with a TrainException, the type a train author throws for a message meant to be read.
using Trax.Core.Exceptions;
using Trax.Effect.Services.TrainEventBroadcaster;
public sealed record LiveTrainEvent(
string ExternalId,
string TrainName,
string EventType,
DateTime Timestamp,
string? FailureReason
)
{
public const string MaskedReason = "The run failed. The reason is in the server log.";
public static LiveTrainEvent From(TrainLifecycleEventMessage message) =>
new(
message.ExternalId,
message.TrainName[(message.TrainName.LastIndexOf('.') + 1)..],
message.EventType,
message.Timestamp,
message.EventType == "Failed" ? ClientReason(message) : null
);
private static string ClientReason(TrainLifecycleEventMessage message) =>
message.FailureException == nameof(TrainException) && message.FailureReason is not null
? message.FailureReason
: MaskedReason;
}FailureException is the short type name of the exception the run recorded (TrainException,
InvalidOperationException). A subclass of TrainException reports its own name, so list it too if
you throw one. See UseSignalRHub: Default projection.
A temporary workaround in the sample
Workarounds/SignalRSinkKeepAlive.cs is not part of the pattern. The current Trax.Effect disposes the
shared SignalR sink when the first train run ends, after which the hub delivers nothing; the file keeps
the sink alive until that is fixed upstream. Do not copy it.
Tests
dotnet test tests/Trax.Samples.SignalRBroadcaster.E2EA .NET SignalR client (Microsoft.AspNetCore.SignalR.Client) joins the hub through
WebApplicationFactory, using the test server's handler and long polling, and carrying the cookie the
demo sign-in returned:
var connection = new HubConnectionBuilder()
.WithUrl(
new Uri(factory.Server.BaseAddress, "/hubs/trax-events"),
options =>
{
options.HttpMessageHandlerFactory = _ => factory.Server.CreateHandler();
options.Transports = HttpTransportType.LongPolling;
options.Headers["Cookie"] = cookie;
}
)
.Build();
connection.On<LiveTrainEvent>("TrainEvent", received.Enqueue);
await connection.StartAsync();| Test class | Proves |
|---|---|
HubPostureTests | No cookie: StartAsync throws HttpRequestException with 401. With the cookie: connected. POST /pings refuses an anonymous caller. Production maps no sign-in. |
LiveEventTests | A ping's Started and Completed arrive live; a TrainException shows its message; any other failure shows the masked sentence and none of the real message |
SDK Reference
UseSignalRHub | MapTraxTrainEventHub | UseBroadcaster | TrainException