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

FeatureWhere
UseBroadcaster(b => b.UseSignalRHub(...)) and MapTraxTrainEventHub(...) are the whole server sideProgram.cs
The hub refuses a client that is not signed in, with 401 on negotiateHubPostureTests
A browser cookie as the hub credential, sent on negotiate and on the WebSocket with no client codeProgram.cs, wwwroot/index.html
A custom projection that sends a failure reason only when the train wrote it for clientsLiveTrainEvent.cs, LiveEventTests
The demo sign-in exists only in DevelopmentHubPostureTests.Outside_development_there_is_no_way_to_sign_in

Run

From the Trax.Samples root:

dotnet run --project samples/SignalRBroadcaster/Trax.Samples.SignalRBroadcaster

Open http://localhost:5270. dotnet run starts in Development through Properties/launchSettings.json, the only environment that maps the demo sign-in.

Try it

  1. 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.
  2. Press A ping that succeeds: a run card appears and fills in live, Started then Completed, with the time between them.
  3. Press A ping that fails, for readers: the run fails with a TrainException, and its card shows the reason The ping target did not answer.
  4. 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.E2E

A .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 classProves
HubPostureTestsNo cookie: StartAsync throws HttpRequestException with 401. With the cookie: connected. POST /pings refuses an anonymous caller. Production maps no sign-in.
LiveEventTestsA 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