Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions docs/broadcasting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Broadcasting

Real-time push from server to browser, layered on SignalR.

Modules emit domain events through the same `IMessageBus` they already
use; events that implement `IBroadcastEvent` are forwarded by a
framework-supplied Wolverine handler to whoever is subscribed to the
channel the event names. Public-channel subscriptions go through an
authorizer chain, and presence channels track members across multiple
connections per user.

## Hub endpoint

`/hub/broadcast` — SignalR hub mapped by the framework. Authentication is
mandatory (the `FallbackPolicy` is `RequireAuthenticatedUser`). The hub
exposes two client-callable methods:

- `Subscribe(channel)` → `SubscribeResult { authorized, reason, members }`
- `Unsubscribe(channel)` → `void`

And two server-to-client invocations:

- `broadcast` — `BroadcastEnvelope { channel, event, payload }`
- `presence` — `PresenceChange { channel, kind, member, members }`

## Server: emitting events

Mark an event with `[BroadcastEvent("wire.name")]` and implement
`IBroadcastEvent`. The wire name is what the browser subscribes to via
`useEvent(channel, 'wire.name', ...)`.

```csharp
using SimpleModule.Core.Broadcasting;
using SimpleModule.Core.Events;

[BroadcastEvent("notifications.created")]
public sealed record NotificationCreated(Guid UserId, string Title)
: DomainEvent, IBroadcastEvent
{
public string Channel(IBroadcastContext ctx) =>
BroadcastChannels.ForUser(UserId.ToString());
}
```

Publish the event through Wolverine the same way you publish anything else:

```csharp
await bus.PublishAsync(new NotificationCreated(userId, "Welcome!"));
```

The framework decorates Wolverine's `IMessageBus` with
`BroadcastingMessageBus` (Scrutor decorator, same pattern as the audit
log). Any message that is an `IBroadcastEvent` is forwarded to
`IBroadcaster` after the inner bus accepts it — no per-event handler, no
opt-in registration. Forwarding errors are logged but never propagated:
the primary business operation must not fail because SignalR fan-out
failed.

### Calling the broadcaster directly

When you want a fire-and-forget push that isn't a domain event, inject
`IBroadcaster`:

```csharp
public sealed class ChatService(IBroadcaster broadcaster)
{
public Task TypingAsync(string roomId, string userId, CancellationToken ct) =>
broadcaster.ToChannelAsync(
$"presence-rooms.{roomId}",
"chat.typing",
new { userId },
ct
);
}
```

`IBroadcaster` also exposes `ToUserAsync` / `ToTenantAsync` for the
implicit per-user and per-tenant fan-out groups the hub maintains for
every authenticated connection.

## Channel naming

Channel names are arbitrary strings, but the framework reserves two prefixes
(borrowed from Pusher / Laravel Echo):

- `private-` — requires authentication; the authorizer chain decides
whether the connected principal may subscribe.
- `presence-` — same as private, plus the server tracks members and
pushes join/leave deltas.

Helpers in `BroadcastChannels`:

- `BroadcastChannels.ForUser(userId)` → `private-users.{userId}`
- `BroadcastChannels.ForTenant(tenantId)` → `private-tenants.{tenantId}`

## Authorizers

Implement `IBroadcastChannelAuthorizer` and register it:

```csharp
public sealed class OrdersChannelAuthorizer(IOrdersContracts orders)
: IBroadcastChannelAuthorizer
{
public string ChannelPrefix => "private-tenants.";

public async Task<bool> AuthorizeAsync(
string channel,
IBroadcastContext ctx,
CancellationToken ct
)
{
// private-tenants.{tid}.orders.{orderId}
var parts = channel.Substring(ChannelPrefix.Length).Split('.');
var tenantId = parts[0];
var orderId = parts[^1];
return await orders.UserCanSeeOrderAsync(ctx.User!, tenantId, orderId, ct);
}
}

// In ConfigureServices:
services.AddBroadcastAuthorizer<OrdersChannelAuthorizer>();
```

The chain matches by longest prefix, so your specific rule overrides the
default tenant guard the framework ships.

## Browser: @simplemodule/echo

```tsx
import { EchoProvider, useEvent, usePresence } from '@simplemodule/echo';

// Mount once near the root, inside Inertia's app component.
<EchoProvider>
<App />
</EchoProvider>;

function NotificationBell({ userId }: { userId: string }) {
const [count, setCount] = useState(0);
useEvent<NotificationCreated>(
`private-users.${userId}`,
'notifications.created',
() => setCount((n) => n + 1)
);
return <span>{count}</span>;
}

function RoomRoster({ roomId }: { roomId: string }) {
const members = usePresence(`presence-rooms.${roomId}`);
return (
<ul>
{members.map((m) => (
<li key={m.userId}>{m.info?.name ?? m.userId}</li>
))}
</ul>
);
}
```

The Echo client multiplexes every subscription onto a single WebSocket and
re-subscribes automatically after reconnects. Subscriptions are
reference-counted: mounting `useEvent` ten times against the same channel
costs one network call.

## Scaling notes

The framework's `PresenceTracker` stores membership in memory, which is
fine for a single instance. For horizontal scale-out, run SignalR with a
Redis backplane (`AddStackExchangeRedis(...)`) and replace the
`PresenceTracker` with a Redis-backed implementation — the
`IBroadcaster` / `IBroadcastChannelAuthorizer` contracts stay the same.
3 changes: 3 additions & 0 deletions framework/SimpleModule.Core/Authorization/WellKnownClaims.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ namespace SimpleModule.Core.Authorization;
public static class WellKnownClaims
{
public const string Permission = "permission";

/// <summary>Tenant identifier carried on the principal in multi-tenant deployments.</summary>
public const string TenantId = "tenantid";
}
27 changes: 27 additions & 0 deletions framework/SimpleModule.Core/Broadcasting/BroadcastChannels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace SimpleModule.Core.Broadcasting;

/// <summary>
/// Canonical channel-name helpers. Keeping channel naming in one place stops
/// modules and the client SDK from drifting on how a user- or tenant-scoped
/// channel is spelled.
/// </summary>
public static class BroadcastChannels
{
/// <summary>Prefix marking a channel that requires authentication (no metadata beyond auth).</summary>
public const string PrivatePrefix = "private-";

/// <summary>Prefix marking a presence channel — server tracks members and pushes join/leave.</summary>
public const string PresencePrefix = "presence-";

public static string ForUser(string userId) => $"{PrivatePrefix}users.{userId}";

public static string ForTenant(string tenantId) => $"{PrivatePrefix}tenants.{tenantId}";

/// <summary>True if <paramref name="channel"/> requires the subscriber to be authenticated.</summary>
public static bool IsPrivate(string channel) =>
channel.StartsWith(PrivatePrefix, StringComparison.Ordinal)
|| channel.StartsWith(PresencePrefix, StringComparison.Ordinal);

public static bool IsPresence(string channel) =>
channel.StartsWith(PresencePrefix, StringComparison.Ordinal);
}
39 changes: 39 additions & 0 deletions framework/SimpleModule.Core/Broadcasting/BroadcastEnvelope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace SimpleModule.Core.Broadcasting;

/// <summary>
/// Wire format the hub sends to clients. The client SDK dispatches by
/// <see cref="Event"/> so a single channel can carry many event types.
/// </summary>
/// <param name="Channel">Channel the payload was published to.</param>
/// <param name="Event">Event name (matches <see cref="BroadcastEventAttribute.Name"/> when forwarded from <see cref="IBroadcastEvent"/>).</param>
/// <param name="Payload">JSON-serializable payload — usually the event record itself.</param>
public sealed record BroadcastEnvelope(string Channel, string Event, object Payload);

/// <summary>
/// Presence membership delta pushed to subscribers of a presence channel
/// whenever members join or leave. The framework synthesizes these from
/// connection lifecycle events; user code does not raise them directly.
/// </summary>
public sealed record PresenceChange(
string Channel,
PresenceChangeKind Kind,
PresenceMember Member,
IReadOnlyList<PresenceMember> Members
);

public enum PresenceChangeKind
{
Joined,
Left,
}

/// <summary>
/// Snapshot of a presence-channel member. <see cref="UserId"/> is the stable
/// identity (multiple connections from the same user collapse to one
/// member); <see cref="Info"/> carries any extra metadata the authorizer
/// chose to attach (display name, avatar, role).
/// </summary>
public sealed record PresenceMember(
string UserId,
IReadOnlyDictionary<string, string>? Info = null
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace SimpleModule.Core.Broadcasting;

/// <summary>
/// Marks an <see cref="IBroadcastEvent"/> record with the wire-format event
/// name that browser clients subscribe to. The same event type may be reused
/// across modules; the name is what client code listens for via
/// <c>useEvent('orders.created', ...)</c>.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class BroadcastEventAttribute(string name) : Attribute
{
public string Name { get; } = name;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace SimpleModule.Core.Broadcasting;

/// <summary>
/// Decides whether the current principal is allowed to subscribe to a given
/// channel. Authorizers are matched by channel name prefix
/// (<see cref="ChannelPrefix"/>); the longest matching prefix wins so
/// modules can declare specific guards (<c>tenants.{tid}.orders</c>) while
/// the framework owns broader ones (<c>tenants.</c>).
/// </summary>
public interface IBroadcastChannelAuthorizer
{
/// <summary>
/// Channel name prefix this authorizer claims. Use a literal channel name
/// for an exact match, or a trailing-dot prefix (e.g., <c>tenants.</c>)
/// to match all descendants. <c>""</c> matches everything (used by the
/// default deny-public-channels authorizer).
/// </summary>
string ChannelPrefix { get; }

/// <summary>
/// Returns <c>true</c> if the principal in <paramref name="context"/> may
/// subscribe to <paramref name="channel"/>. The authorizer is also
/// expected to filter channels that don't belong to the current tenant
/// even when the prefix happens to match.
/// </summary>
Task<bool> AuthorizeAsync(
string channel,
IBroadcastContext context,
CancellationToken cancellationToken = default
);
}
33 changes: 33 additions & 0 deletions framework/SimpleModule.Core/Broadcasting/IBroadcastContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Security.Claims;

namespace SimpleModule.Core.Broadcasting;

/// <summary>
/// Context exposed to <see cref="IBroadcastEvent.Channel"/> and channel
/// authorizers so the same event record can route to a tenant-scoped or
/// user-scoped channel without hard-coding identifiers into the event itself.
/// </summary>
public interface IBroadcastContext
{
/// <summary>
/// Connected user, if the broadcast was initiated in an authenticated
/// scope. Server-published events outside a request typically have a
/// null principal — the caller is responsible for setting tenant/user
/// ids on the event itself in that case.
/// </summary>
ClaimsPrincipal? User { get; }

/// <summary>
/// Active tenant id (e.g., from the request's claims or ambient tenant
/// resolution). Null in single-tenant deployments.
/// </summary>
string? TenantId { get; }
}

public sealed class BroadcastContext(ClaimsPrincipal? user, string? tenantId) : IBroadcastContext
{
public ClaimsPrincipal? User { get; } = user;
public string? TenantId { get; } = tenantId;

public static BroadcastContext Empty { get; } = new(null, null);
}
20 changes: 20 additions & 0 deletions framework/SimpleModule.Core/Broadcasting/IBroadcastEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using SimpleModule.Core.Events;

namespace SimpleModule.Core.Broadcasting;

/// <summary>
/// Marker contract for domain events that should be forwarded to connected
/// browsers via SignalR. An <see cref="IBroadcastEvent"/> participates in the
/// same Wolverine pipeline as any other <see cref="IEvent"/>, but a generated
/// bridge handler also relays it through <see cref="IBroadcaster"/> to whoever
/// is subscribed to the channel returned by <see cref="Channel"/>.
/// </summary>
public interface IBroadcastEvent : IEvent
{
/// <summary>
/// Channel name this event is broadcast on. Channels are stable, hierarchical
/// strings (e.g., <c>tenants.123.orders</c>). Authorizers run against the
/// channel name before a client is allowed to subscribe.
/// </summary>
string Channel(IBroadcastContext context);
}
Loading
Loading