Repository navigation
ServerEvents pt BR
🌐 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.
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 noOnUnmount. Descartar duas vezes não tem problema. Uma página renderizada no servidor não assina nada lá: oIServerEventsdo 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
decimale umlongleem como leem em C#, e o argumento de tipo de um record genérico também (oValuede umBox<long>chega comolong). -
Uma recusa diz o porquê. O
onRefusedrecebe umServerTopicRefusal(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) ouFailed(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ê.
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.
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).
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}eroom:{roomId}:participant:{participantId}casam os dois comroom: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 comroom. Um tópico com que nenhum template casa é recusado comoUnknown: 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: umAuthorizationHandler<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
UseServerEventspode 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
UseServerEventsdiz isso às suas páginas. Uma assinatura ali é recusada na hora comoUnknown, e o console do browser nomeia oUseServerEvents, em vez de a página abrir um stream a que nada responde.
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.
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.
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.
-
GET /_equantic/eventsé um stream de Server-Sent Events: o primeiro evento entrega à página o id da sua conexão, cada payload é um eventomessagecom{ "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}/subscribee…/release, com{ "topic": "…" }, ligam e liberam um tópico:204quando feito,403com o motivo,404para 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 (
415senã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 fetchno-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.
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.
🏁 Comece aqui
🏗️ Arquitetura
- Visão geral da arquitetura
- Componentes write-once
- Superfície declarativa
- Arquitetura de pacotes
- Componentes
- Estilo
- Localização
- Analytics e GTM
📱 Write-once
- Motor Photon
- Design System
- Capacidades
- Armazenamento
- Formulários
- Editor de código
- Markdown
- Mermaid
- Renderização de Email
⚙️ Compilação
⚡ Runtime
🔌 Servidor
🎨 Ecossistema
🚀 Desenvolvimento