Repository navigation
ServerEvents
🌐 This page in: English · Português
What the server publishes, heard by a component while the person watches: a price that moves, a message in a room, an order that changed state. The component subscribes to a typed topic, any code on the server publishes to the same topic, and the SDK carries it over one connection per page and hands the component each payload as the C# type it was.
Since 0.2.0-preview.61
public sealed record RoomMessage(string Author, string Text, long Sequence);
public static class Topics
{
public static ServerTopic<RoomMessage> Room(string id) => new($"room:{id}");
}A topic is a value. Its name is the app's own, with no convention imposed, and its type parameter is
the contract: Subscribe hands the component an Action<RoomMessage>, PublishAsync takes a
RoomMessage, so a payload of the wrong type is a compile error rather than an undefined in the
browser. Declare it once, in the app's C#, and use the same value on both sides.
Build a topic where its payload type is concrete, as above. A topic built where that type is a type
parameter, in a helper like static ServerTopic<T> Topic<T>(string name), fails the build with
EQ2013: JavaScript erases the type there, and the topic could not revive what it
receives. A topic the page receives from the server, a Server Action's result or a page's state,
keeps its payload's type.
IServerEvents is a capability, taken through the constructor like
INetworkStatus:
public sealed class RoomScreen(IServerEvents? events) : StatefulComponent
{
private readonly List<RoomMessage> _messages = [];
private IDisposable? _room;
private IDisposable? _connection;
private ServerConnectionState _state;
private bool _refused;
protected override void OnMount()
{
_room = events?.Subscribe(Topics.Room("lobby"),
message => SetState(() => _messages.Add(message)),
refusal => SetState(() => _refused = true));
_connection = events?.OnConnectionChanged(connection => SetState(() => _state = connection.State));
}
protected override void OnUnmount()
{
_room?.Dispose();
_connection?.Dispose();
}
public override VisualNode Build(ComponentContext context) => _refused
? Text("This room is not open to you.", TypeRole.BodyM)
: Column(gap: Space.S2, children: [
Text(_state == ServerConnectionState.Connected ? "Live" : "Catching up", TypeRole.Caption),
.. _messages.Select(message => Text(message.Text, TypeRole.BodyM)),
]);
}-
Subscribe in
OnMount, dispose inOnUnmount. Disposing twice is fine. A page that renders on the server subscribes to nothing there: the server'sIServerEventsnever connects, so the same component renders, and the browser's subscribes once it hydrates. - One connection per page. The first subscription opens it and the last one closes it, and every topic the page holds rides on it, whichever component subscribed.
-
The payload arrives revived. It is written in the wire format of a Server Action's result and
revived the same way, so a record, an enum, a
decimaland alongread as they read in C#, and so does a generic record's type argument (Box<long>'sValuearrives as along). -
A refusal says why.
onRefusedreceives aServerTopicRefusal(Topic, Reason):Forbidden(a rule matched and refused this request),Unknown(no rule matches the topic),LimitReached(the page holds as many topics as the server allows) orFailed(the server, or the network, did not answer, asked four times). The subscription then receives nothing. Without a handler, the browser's console says which topic and why.
Connection answers where it stands now, as a ServerConnection(State, LastEventId), and
OnConnectionChanged hears each change of state: Connecting, Connected, Reconnecting,
Disconnected.
When the connection drops (a network that blinked, a server that restarted, a deploy) it is opened
again by itself and every live subscription is bound again. Connected is reported once they are
bound, with the id of the last event the page received, so a page that asks the server for what it
missed, through a Server Action, asks when nothing more can be missed in
between. Delivery is at most once; the id is how a page catches up.
A bind the server cannot answer for is asked again before the page is told: a stream that has just ended is unknown to the server a moment before the browser sees it end, and a proxy answers for an instance a deploy is taking away. The page asks again after half a second, a second and two seconds, on whatever connection it has by then, and only a fourth miss in a row refuses the topic. A release that goes unanswered is asked again the same way, and at the last miss the page opens its stream again, which releases everything the old one held. Each request gets ten seconds, its answer included, and ends at once when its stream drops. A bind that went unanswered may still have bound the topic, so the page releases that one too when it lets go of it.
public sealed class RoomService(IServerEventPublisher events)
{
public ValueTask PostAsync(string room, RoomMessage message, CancellationToken cancellationToken) =>
events.PublishAsync(Topics.Room(room), message,
new ServerEventPublishOptions { Id = message.Sequence.ToString(CultureInfo.InvariantCulture) },
cancellationToken);
}Any code on the server publishes: a Server Action, a hosted service, the handler of a webhook. The id
is optional and travels as the event's id (LastEventId on the page); it cannot hold a line break.
A payload larger than the limit throws where it is published, naming the topic and the setting.
A class in the app's project that only ever runs on the server, a hosted service that publishes
included, is marked [ServerOnly], or the compiler tries to build it for the browser and says so
(EQ2004).
Nothing is heard until the app says who may hear it:
builder.Services.AddUI(options => options
.ScanAssembly(typeof(Program).Assembly)
.UseServerEvents(events => events
.Topic("prices", rule => rule.AllowAnonymous())
.Topic("room:{roomId}", rule => rule.RequireAuthorization("RoomMember"))
.Topic("user:{userId}", rule => rule.Authorize(topic =>
topic.Values["userId"] == topic.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)))));-
Templates are routes. A topic is matched against each template with ASP.NET Core's route
syntax, and the template whose literal text fixes more of the topic rules:
room:{roomId}androom:{roomId}:participant:{participantId}both matchroom:a:participant:7, and the second decides. Templates that fix as much all rule, and each must allow the subscription. A parameter's default fills a topic that leaves it out:room/{roomId=lobby}matchesroom. A topic no template matches is refused asUnknown: closed by default. -
RequireAuthorization()with no policy asks for the app's default policy, a signed-in user unless the app changed it. With policies, they are evaluated as ASP.NET Core's authorization middleware evaluates an endpoint's: combined, their authentication schemes authenticated first, so a policy restricted to a bearer token is met only by that token's user, never by the cookie's. The topic is their resource: anAuthorizationHandler<TRequirement, ServerTopicContext>reads the template's values (topic.Values["roomId"]), the topic's name, the connection's id and the request. -
Authorize(…)takes a delegate, synchronous or asynchronous, for a check a policy would be heavy for. -
At startup, a rule that says nothing fails, and so does a constrained parameter
(
{id:int}): a constraint would refuse in silence a topic the app wrote itself. - The events endpoints admit anonymous requests, so an app's fallback authorization policy does not refuse the connection itself: each topic's rule decides, and a connection carries nothing until a topic is bound to it.
-
UseServerEventsmay be called more than once. A library and the app may each declare their topics: every call adds to one configuration, served by one set of endpoints. A topic both declare is ruled by both, each rule allowing it or not, so the app tightens a topic a library declared, and nothing loosens it. -
An app that never calls
UseServerEventssays so to its pages. A subscription there is refused at once asUnknown, and the browser's console namesUseServerEvents, rather than the page opening a stream nothing answers.
IServerEventHandler hears a connection open and close and a topic bound and released, each with
the request at hand and the template's values. Presence, occupancy and audit are built on it:
public sealed class RoomPresence(IRoomRegistry rooms) : IServerEventHandler
{
public ValueTask OnSubscribedAsync(ServerTopicContext topic) =>
rooms.JoinAsync(topic.Values["roomId"]!, topic.ConnectionId);
public ValueTask OnReleasedAsync(ServerTopicContext topic) =>
rooms.LeaveAsync(topic.Values["roomId"]!, topic.ConnectionId);
}
// …
.UseServerEvents(events => events
.Topic("room:{roomId}", rule => rule.RequireAuthorization())
.AddHandler<RoomPresence>())A topic released by the page, and every topic of a connection that ends, is heard as released. A handler that throws is logged, and the others still hear it. A connection's handlers hear its transitions in order: a join still being awaited when the stream closes is heard before that topic's release and the disconnection, so a handler that never returns holds its connection's close.
IServerEventBackplane carries each published event to every instance of the app; the default keeps
them in the process, which is all one instance needs. An app that runs several registers its own,
over Redis pub/sub or a service bus, with UseBackplane<T>(): it publishes a ServerEventEnvelope
(the topic, the payload already written, the id) and hands each one it receives to the instance.
A page binds its topics with requests that name its connection, so behind a load balancer they must
reach the instance that holds its stream: session affinity, as SignalR asks. A bind that keeps
reaching another instance is reported as Failed after four tries, and the browser's console names
the cause.
Bound from EQuantic:ServerEvents in appsettings.json, or set with Configure(…) on the builder:
| Key | Default | What it bounds |
|---|---|---|
HeartbeatInterval |
00:00:15 |
How long a connection stays silent before a heartbeat keeps proxies from closing it |
MaxTopicsPerConnection |
64 |
The topics one page may hold at once |
MaxPayloadBytes |
65536 |
The size of one payload, checked where it is published |
MaxQueuedEventsPerConnection |
256 |
The events waiting for a page that reads slowly; past it the connection closes, and the page connects again |
{
"EQuantic": {
"ServerEvents": {
"MaxTopicsPerConnection": 16
}
}
}A value the connections cannot run with, a heartbeat of zero or less or a limit below one, stops the app from starting, and the error names the key and the value it was given.
-
GET /_equantic/eventsis a Server-Sent Events stream: its first event hands the page its connection's id, each payload is amessageevent carrying{ "topic", "payload" }, and a comment line is the heartbeat. It is never cached, never compressed, and asks a reverse proxy not to buffer it (X-Accel-Buffering: no). -
POST /_equantic/events/{connection}/subscribeand…/release, with{ "topic": "…" }, bind and release a topic:204when done,403with the reason,404for a connection the server does not know. Nothing a page sends publishes; the way from the page to the server is a Server Action. - Their body must be JSON (
415otherwise) of at most 8 KB (413). A topic is authorized as the request that binds it, cookies included, so a body another site could send, a form's or ano-corsfetch's, would let that site bind its visitors' topics to a stream it opened; a JSON one from another origin needs the browser's preflight, which the app's CORS policy refuses. - Over HTTP/1.1 a browser opens six connections per origin, and each tab holds one stream. HTTP/2, Kestrel's default over TLS, carries them all on one.
- When the app stops, every stream ends, and each page connects again: to this instance once it is back, or to another one in a rolling deploy.
- The client is part of the runtime the app already serves. No script comes from anywhere else.
A Server Action that streams (IAsyncEnumerable<T>), presence as a feature of its own, replay from
the last event's id, a WebSocket transport, a Redis backplane package, and the realization on
Photon. See the Roadmap.
🌐 English · Português
🏁 Start here
🏗️ Architecture
- Architecture Overview
- Write-Once Components
- Declarative Surface
- Package Architecture
- Components
- Styling
- Localization
- Analytics & GTM
📱 Write-once
⚙️ Compilation
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development