Eventos do servidorO 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;
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()
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ê.
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) },
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.
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:
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
"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 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.
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.