Skip to content

ServerEvents pt BR

Edgar Mesquita edited this page Oct 14, 2026 · 7 revisions

Eventos do servidor

🌐 Esta página em: English · Português · Español · Français · Deutsch

O que o servidor publica, ouvido por um componente enquanto a pessoa olha: um preço que se mexe, uma mensagem numa sala, um pedido que mudou de estado. O componente assina um tópico tipado, qualquer código do servidor publica no mesmo tópico, e o SDK leva isso por uma conexão por página e entrega ao componente cada payload como o tipo C# que ele era.

Desde 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}");
}

Um tópico é um valor. O nome é do app, sem convenção imposta, e o parâmetro de tipo é o contrato: o Subscribe entrega ao componente uma Action<RoomMessage>, o PublishAsync recebe uma RoomMessage, então um payload do tipo errado é um erro de compilação, e não um undefined no browser. Declare uma vez, no C# do app, e use o mesmo valor dos dois lados.

Construa um tópico onde o tipo do payload é concreto, como acima. Um tópico construído onde esse tipo é um parâmetro de tipo, num helper como static ServerTopic<T> Topic<T>(string name), falha o build com EQ2013: o JavaScript apaga o tipo ali, e o tópico não conseguiria reviver o que recebe. Um tópico que a página recebe do servidor, o resultado de um Server Action ou o estado de uma página, mantém o tipo do seu payload.

Ouvir um tópico

IServerEvents é uma capacidade, recebida pelo construtor como o 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)),
        ]);
}
  • Assine no OnMount, descarte no OnUnmount. Descartar duas vezes não tem problema. Uma página renderizada no servidor não assina nada lá: o IServerEvents do servidor nunca conecta, então o mesmo componente renderiza, e o do browser assina quando a página hidrata.
  • Uma conexão por página. A primeira assinatura a abre e a última a fecha, e todo tópico que a página tem passa por ela, seja qual for o componente que assinou.
  • O payload chega revivido. Ele é escrito no formato de fio do resultado de um Server Action e revivido do mesmo jeito, então um record, um enum, um decimal e um long leem como leem em C#, e o argumento de tipo de um record genérico também (o Value de um Box<long> chega como long).
  • Uma recusa diz o porquê. O onRefused recebe um ServerTopicRefusal(Topic, Reason): Forbidden (uma regra casou e recusou este pedido), Unknown (nenhuma regra casa com o tópico), LimitReached (a página já tem tantos tópicos quanto o servidor permite) ou Failed (o servidor, ou a rede, não respondeu, perguntado quatro vezes). A assinatura então não recebe nada. Sem um handler, o console do browser diz qual tópico e por quê.

A conexão

O Connection responde onde ela está agora, como um ServerConnection(State, LastEventId), e o OnConnectionChanged ouve cada mudança de estado: Connecting, Connected, Reconnecting, Disconnected.

Quando a conexão cai (uma rede que piscou, um servidor que reiniciou, um deploy), ela é aberta de novo sozinha e toda assinatura viva é ligada de novo. O Connected é avisado quando elas estão ligadas, com o id do último evento que a página recebeu, então uma página que pede ao servidor o que perdeu, por um Server Action, pede quando nada mais pode se perder no meio. A entrega é no máximo uma vez; o id é como uma página alcança o que perdeu.

Um bind a que o servidor não consegue responder é pedido de novo antes de a página ser avisada: um stream que acabou de terminar é desconhecido do servidor um instante antes de o browser o ver terminar, e um proxy responde por uma instância que um deploy está tirando. A página pede de novo depois de meio segundo, um segundo e dois segundos, na conexão que tiver até lá, e só uma quarta falha seguida recusa o tópico. Um release sem resposta é pedido de novo do mesmo jeito, e na última falha a página abre o seu stream de novo, o que libera tudo o que o antigo tinha. Cada pedido tem dez segundos, com a resposta incluída, e termina na hora quando o seu stream cai. Um bind que ficou sem resposta pode ter ligado o tópico mesmo assim, então a página também o libera quando o solta.

Publicar

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);
}

Qualquer código do servidor publica: um Server Action, um hosted service, o handler de um webhook. O id é opcional e viaja como o id do evento (o LastEventId na página); ele não pode ter quebra de linha. Um payload maior que o limite lança onde é publicado, nomeando o tópico e a configuração.

Uma classe do projeto do app que só roda no servidor, incluindo um hosted service que publica, leva [ServerOnly], ou o compilador tenta construí-la para o browser e diz isso (EQ2004).

Quem pode ouvir um tópico

Nada é ouvido até o app dizer quem pode ouvir:

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 são rotas. Um tópico é comparado com cada template na sintaxe de rotas do ASP.NET Core, e decide o template cujo texto literal fixa mais do tópico: room:{roomId} e room:{roomId}:participant:{participantId} casam os dois com room:a:participant:7, e o segundo decide. Templates que fixam o mesmo tanto decidem todos, e cada um precisa permitir a assinatura. O default de um parâmetro preenche um tópico que o deixa de fora: room/{roomId=lobby} casa com room. Um tópico com que nenhum template casa é recusado como Unknown: fechado por padrão.
  • RequireAuthorization() sem policy pede a policy padrão do app, um usuário autenticado a menos que o app a tenha mudado. Com policies, elas são avaliadas como o middleware de autorização do ASP.NET Core avalia as de um endpoint: combinadas, com os schemes de autenticação delas autenticados antes, então uma policy restrita a um token bearer só é atendida pelo usuário desse token, nunca pelo do cookie. O tópico é o recurso delas: um AuthorizationHandler<TRequirement, ServerTopicContext> lê os valores do template (topic.Values["roomId"]), o nome do tópico, o id da conexão e o pedido.
  • Authorize(…) recebe um delegate, síncrono ou assíncrono, para uma verificação em que uma policy seria pesada.
  • Na inicialização, uma regra que não diz nada falha, e um parâmetro com restrição ({id:int}) também: uma restrição recusaria em silêncio um tópico que o próprio app escreveu.
  • Os endpoints de eventos admitem pedidos anônimos, então a fallback policy de autorização de um app não recusa a própria conexão: decide a regra de cada tópico, e uma conexão não leva nada até um tópico ser ligado a ela.
  • O UseServerEvents pode ser chamado mais de uma vez. Uma biblioteca e o app podem declarar cada um os seus tópicos: toda chamada soma a uma configuração, servida por um só conjunto de endpoints. Um tópico que os dois declaram é decidido pelos dois, cada regra permitindo ou não, então o app aperta um tópico que uma biblioteca declarou, e nada o afrouxa.
  • Um app que nunca chama UseServerEvents diz isso às suas páginas. Uma assinatura ali é recusada na hora como Unknown, e o console do browser nomeia o UseServerEvents, em vez de a página abrir um stream a que nada responde.

O que acontece em volta de uma assinatura

O IServerEventHandler ouve uma conexão abrir e fechar e um tópico ser ligado e liberado, cada um com o pedido em mãos e os valores do template. Presença, ocupação e auditoria são construídas em cima dele:

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>())

Um tópico liberado pela página, e todo tópico de uma conexão que termina, é ouvido como liberado. Um handler que lança é registrado no log, e os outros ouvem do mesmo jeito. Os handlers de uma conexão ouvem as suas transições em ordem: uma entrada ainda aguardada quando o stream fecha é ouvida antes da saída daquele tópico e da desconexão, então um handler que nunca retorna segura o fechamento da sua conexão.

Várias instâncias

O IServerEventBackplane leva cada evento publicado a toda instância do app; o padrão os mantém no processo, que é tudo de que uma instância precisa. Um app que roda várias registra o seu, sobre o pub/sub do Redis ou um service bus, com UseBackplane<T>(): ele publica um ServerEventEnvelope (o tópico, o payload já escrito, o id) e entrega à instância cada um que recebe.

Uma página liga os tópicos com pedidos que nomeiam a sua conexão, então, atrás de um load balancer, eles precisam chegar à instância que tem o seu stream: afinidade de sessão, como o SignalR pede. Um bind que continua chegando a outra instância é avisado como Failed depois de quatro tentativas, e o console do browser nomeia a causa.

Limites

Vinculados de EQuantic:ServerEvents no appsettings.json, ou ajustados com Configure(…) no builder:

Chave Padrão O que limita
HeartbeatInterval 00:00:15 Quanto tempo uma conexão fica em silêncio antes de um heartbeat impedir os proxies de fechá-la
MaxTopicsPerConnection 64 Os tópicos que uma página pode ter ao mesmo tempo
MaxPayloadBytes 65536 O tamanho de um payload, verificado onde é publicado
MaxQueuedEventsPerConnection 256 Os eventos esperando uma página que lê devagar; passado isso a conexão fecha, e a página conecta de novo
{
  "EQuantic": {
    "ServerEvents": {
      "MaxTopicsPerConnection": 16
    }
  }
}

Um valor com que as conexões não conseguem rodar, um heartbeat de zero ou menos ou um limite abaixo de um, impede o app de iniciar, e o erro nomeia a chave e o valor que recebeu.

No fio

  • GET /_equantic/events é um stream de Server-Sent Events: o primeiro evento entrega à página o id da sua conexão, cada payload é um evento message com { "topic", "payload" }, e uma linha de comentário é o heartbeat. Nunca vai para cache, nunca é comprimido, e pede a um proxy reverso que não o retenha (X-Accel-Buffering: no).
  • POST /_equantic/events/{connection}/subscribe e …/release, com { "topic": "…" }, ligam e liberam um tópico: 204 quando feito, 403 com o motivo, 404 para uma conexão que o servidor não conhece. Nada que uma página envia publica; o caminho da página até o servidor é um Server Action.
  • O corpo deles precisa ser JSON (415 senão) de no máximo 8 KB (413). Um tópico é autorizado como o pedido que o liga, cookies incluídos, então um corpo que outro site conseguisse enviar, o de um formulário ou de um fetch no-cors, deixaria esse site ligar os tópicos dos seus visitantes a um stream que ele abriu; um JSON de outra origem precisa do preflight do browser, que a política de CORS do app recusa.
  • Em HTTP/1.1 um browser abre seis conexões por origem, e cada aba tem um stream. O HTTP/2, padrão do Kestrel sobre TLS, leva todos numa só.
  • Quando o app para, todo stream termina, e cada página conecta de novo: a esta instância quando ela volta, ou a outra num deploy em rolagem.
  • O cliente faz parte do runtime que o app já serve. Nenhum script vem de outro lugar.

Ainda não está aqui

Um Server Action que transmite (IAsyncEnumerable<T>), presença como recurso próprio, replay a partir do id do último evento, um transporte WebSocket, um pacote de backplane Redis e a realização no Photon. Veja o Roadmap.

Clone this wiki locally