SignalR no .NET: do primeiro Hub à produção
Introdução#
SignalR no .NET é uma biblioteca do ASP.NET Core para comunicação em tempo real entre servidor e clientes. Ela negocia um transporte compatível e oferece uma API de alto nível para chamadas entre browser e servidor, envio de eventos, grupos e streaming. Eu a escolheria quando a interface precisa receber atualizações com baixa latência ou enviar comandos interativos sem transformar cada mudança em uma nova consulta periódica. SignalR simplifica o canal; não fornece armazenamento durável, autorização de domínio pronta nem garantia de entrega exatamente uma vez.
Essa diferença muda o desenho: conexões caem, eventos podem se perder e um usuário autenticado ainda pode não ter acesso ao ambiente solicitado. No Dokpod, o browser recebe uma invalidação pequena, consulta o estado pela API REST e só entra no grupo depois que o Hub verifica a autorização. O BFF mantém os tokens fora do navegador; no artigo sobre BFF e segurança em SPAs, explico esse padrão em mais detalhes.
Este é o terceiro artigo da série APIs e Comunicação em .NET. A parte sobre gRPC no .NET 10 explica o canal bidirecional protegido por mTLS entre API e agentes. Já o comparativo Tempo real no Angular e .NET discute em mais detalhe WebSockets, SSE, SignalR e Long Polling. Aqui o foco é outro: ciclo de vida do Hub, chamadas, autorização, grupos, recuperação de assinaturas e decisões operacionais.
ℹ️ Informação: O repositório público do Dokpod não possui uma release ou tag estável no momento da consulta. Os links de código deste artigo apontam para
main, que é mutável; os arquivos podem mudar depois desta revisão.
Pré-requisitos#
Os exemplos usam ASP.NET Core/.NET 10 e Angular; conhecimento básico de C#, HTTP e autenticação web basta. Os trechos do Dokpod são ilustrativos, não um sample independente.
É útil distinguir quatro termos. Hub é a abstração que expõe métodos do servidor e envia eventos aos clientes. Conexão lógica é a sessão que o SignalR administra, mesmo quando o transporte subjacente muda. Grupo é uma associação efêmera de conexões para envio direcionado. Invalidação é um aviso para o cliente consultar novamente uma fonte de estado, não uma cópia do próprio estado.
O que SignalR abstrai, e o que continua sendo protocolo#
SignalR organiza mensagens sobre uma conexão administrada pela biblioteca. O cliente chama métodos do Hub com invoke (e pode aguardar resultado) ou send (sem aguardar o resultado da aplicação). O Hub envia eventos ao chamador, a grupos ou a todos os clientes. Argumentos são serializados pelo protocolo de Hub: JSON é o padrão e MessagePack é uma alternativa binária.
Essa API não é o próprio WebSocket. SignalR negocia um transporte e usa o protocolo de Hub sobre ele: WebSockets é preferido quando disponível; Server-Sent Events (SSE) e Long Polling são alternativas. O fallback depende do suporte e da configuração do cliente, servidor e intermediários. Ele não torna todos os transportes disponíveis em qualquer ambiente nem converte um transporte unidirecional em duplex.
Transporte e formato são camadas distintas: JSON ou MessagePack podem usar um transporte SignalR compatível. WebSockets puros são uma opção quando a aplicação precisa controlar o protocolo, mas então assume detalhes de framing, reconexão e roteamento. O Hub também não é uma sessão de domínio duradoura: instâncias atendem invocações, e estado persistente deve ficar em serviços ou armazenamento. Para enviar mensagens fora de uma invocação, use IHubContext<T>.
Negociação automática é conveniência de compatibilidade, não uma medição de desempenho; valide o transporte real por cliente e ambiente.
Do registro do Hub à autorização por ambiente#
No ASP.NET Core, AddSignalR registra a infraestrutura e MapHub expõe o endpoint. O Hub do Dokpod em /hubs/control-plane tem [Authorize], assim como sua rota. Isso impede conexões anônimas, mas autenticação não autoriza o acesso a todo ambiente.
JoinEnvironmentAsync valida o identificador, obtém do contexto autenticado o sub e o access token recebido do BFF e pede ao Keycloak uma decisão para urn:dokpod:environment:{id} com o scope environment:read. Só a decisão permitida leva a Groups.AddToGroupAsync; negação, indisponibilidade ou resultado indeterminado não concedem acesso.
O grupo tem nome determinístico, como environment-{guid}, mas esse nome não é uma credencial. A autorização ocorre na API antes da associação; o identificador recebido não permite ao cliente escolher qualquer grupo. Operações assíncronas recebem ConnectionAborted para cancelar trabalho quando a conexão some.
public async Task JoinEnvironmentAsync(string environmentId)
{
if (string.IsNullOrWhiteSpace(environmentId) || environmentId.Length > 64 ||
environmentId.Any(character => !char.IsLetterOrDigit(character) && character != '-'))
throw new HubException("Identificador de ambiente inválido.");
var httpContext = Context.GetHttpContext();
var accessToken = ExtractAccessToken(httpContext);
var subject = Context.User?.FindFirstValue("sub");
if (string.IsNullOrWhiteSpace(accessToken) || string.IsNullOrWhiteSpace(subject) ||
!Guid.TryParse(environmentId, out var environmentGuid))
throw new HubException("Acesso negado.");
var decision = await authorizationDecider.DecideAsync(
$"urn:dokpod:environment:{environmentGuid:D}", EnvironmentResourceScopes.Read,
AuthenticatedActor.FromSubject(subject), accessToken, GetCorrelationId(httpContext),
Context.ConnectionAborted);
if (!decision.Allowed) throw new HubException("Acesso negado.");
await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(environmentId),
Context.ConnectionAborted);
}
📂 Código-fonte: ControlPlaneHub.cs e ApiHost.cs.
O Hub é apropriado para validações ligadas ao chamador e para coordenar esse fluxo curto. Regras maiores ficam em serviços de aplicação: assim, autorização, auditoria, limites e efeitos de domínio não dependem de uma conexão específica. Se várias invocações concorrerem, a implementação não deve assumir que chamadas da mesma conexão serão serializadas em todos os cenários; estado compartilhado precisa de sincronização e de um dono explícito.
Cliente Angular pelo BFF, sem token no browser#
O browser usa uma URL same-origin. O HubConnectionBuilder registra handlers, habilita reconexão e envia o cookie de sessão; antes, o serviço obtém do BFF um token antiforgery para X-Dokpod-Antiforgery. O cookie é HttpOnly: o Angular não lê nem encaminha o Bearer da API.
O BFF autentica a sessão e acrescenta o token obtido do ticket server-side apenas no salto BFF → API. No WebSocket downstream, o Bearer vai em Authorization, nunca de volta ao browser. O BFF protege o POST de negociação contra CSRF e valida Origin; a API ainda autoriza cada ambiente.
invoke aguarda a conclusão da invocação e rejeita a promise em caso de falha. send não confirma a conclusão da regra de negócio. Registre handlers antes de start para não perder eventos recebidos logo após a conexão.
const { requestToken } = await firstValueFrom(
this.http.get<AntiforgeryResponse>('bff/antiforgery'),
);
const connection = new HubConnectionBuilder()
.withUrl(controlPlaneHubUrl(document.baseURI), {
headers: { 'X-Dokpod-Antiforgery': requestToken },
withCredentials: true,
})
.withAutomaticReconnect()
.build();
connection.on('invalidated', (notification: { environmentId: string; kind: InvalidationKind }) =>
this.publish(notification.environmentId, notification.kind));
connection.onreconnected(() => void this.rejoinEnvironment(connection, environmentId));
await connection.start();
await connection.invoke('JoinEnvironmentAsync', environmentId);
📂 Código-fonte: console-realtime.service.ts e BffHost.cs.
O snippet omite limpeza e tratamento de estado. No serviço real, uma falha de start() é tratada como erro inicial; withAutomaticReconnect() cobre uma conexão já estabelecida que caiu. A interface deve distinguir esses casos, sem inferir conectividade pela existência do serviço.
Grupos e invalidações: aviso rápido, leitura durável#
Depois da autorização, o cliente entra no grupo do ambiente. Quando uma projeção muda, SignalRControlPlaneNotifier usa IHubContext<ControlPlaneHub> para enviar invalidated; o publicador não instancia um Hub nem depende de uma chamada do browser.
O payload contém somente identificador do ambiente e tipo de recurso, como inventory, events ou alerts. O notifier agrega emissões por ambiente e tipo, inicialmente a uma por segundo. A tela consulta então o endpoint REST, que autoriza novamente e devolve a representação atual. SignalR reduz latência sem duplicar a fonte de verdade.
await hubContext.Clients
.Group(ControlPlaneHub.GroupFor(environmentId.ToString("D")))
.SendAsync("invalidated",
new InvalidationNotification(environmentId, kind),
CancellationToken.None);
📂 Código-fonte: SignalRControlPlaneNotifier.cs e AgentControlService.cs.
Um grupo não é fila, histórico ou assinatura durável. Sua associação pode desaparecer com desconexão ou reinício. Não envie snapshots grandes nem interprete a ausência de aviso como prova de que nada mudou; na volta, busque o estado atual pela API.
O retorno de SendAsync também não confirma que o browser recebeu ou processou a invalidação. Se várias alterações forem agregadas, a leitura REST ainda precisa convergir para a representação mais recente.
Autenticação, autorização e limites de confiança#
[Authorize] protege a entrada, mas não a autorização de cada recurso. O principal fica associado à conexão e mudanças de roles ou claims não são automaticamente revalidadas em toda invocação. O Dokpod consulta Keycloak ao entrar no grupo e reavalia permissões em alguns fluxos sensíveis de longa duração.
O browser usa cookie same-origin através do BFF, que acrescenta o Bearer somente server-side. Em uma conexão direta autenticada por bearer, APIs de WebSocket e SSE do navegador não permitem configurar livremente Authorization; o cliente pode colocar access_token na query string. HTTPS protege o tráfego, mas URLs podem aparecer em logs. Se essa opção for inevitável, restrinja-a à rota do Hub e remova a query dos logs. O relay do Dokpod evita expor o token ao browser ou à URL downstream.
Cookies também criam risco de CSRF. O BFF valida Origin, restringe origens e exige antiforgery no POST de negociação. CORS controla chamadas cross-origin do browser, mas não autentica usuários nem autoriza ambientes; same-origin tampouco elimina validação de Origin para uma conexão por cookie.
Grupos não são fronteiras de segurança: o servidor deriva recurso e scope do principal e falha fechada quando Keycloak nega ou não consegue decidir. Limite mensagens, duração e concorrência, use TLS e evite registrar tokens, logs ou conteúdo de terminal.
⚠️ Atenção: o cliente JavaScript pode enviar bearer tokens em query string para WebSockets/SSE por limitação das APIs do browser. O relay BFF do Dokpod autentica o browser por cookie e acrescenta o Bearer apenas no salto server-side para a API.
Reconexão, fluxos e eventos que podem se perder#
O cliente JavaScript não reconecta por padrão. withAutomaticReconnect() usa atrasos de 0, 2, 10 e 30 segundos; após quatro falhas, para e chama onclose. A política não cobre falhas do start() inicial, que precisam de estratégia própria. onreconnecting, onreconnected e onclose representam estados distintos.
Após reconectar, o servidor vê uma nova conexão. ConsoleRealtimeService guarda os ambientes observados e chama JoinEnvironmentAsync novamente. Atualizar apenas o indicador para “connected” deixa a interface sem receber invalidações se o cliente não restaurar a associação ao grupo.
connection.onreconnecting(() => this.status.set('connecting'));
connection.onreconnected(() => {
const environmentIds = [...this.joined];
this.joined.clear();
this.status.set('connected');
void this.watch(environmentIds);
});
connection.onclose(() => this.status.set('disconnected'));
📂 Código-fonte: console-realtime.service.ts.
Reconexão não recupera mensagens emitidas durante a queda. Para inventário, faça refresh REST após reconectar e use SignalR para mudanças seguintes. Histórico, replay ou confirmação exigem sequência durável e retomada explícita, não são garantias do Hub.
Essa estratégia funciona melhor quando a consulta é idempotente e retorna uma visão consistente do recurso, em vez de depender de reaplicar cada notificação perdida.
O Hub também oferece fluxos longos. StreamContainerLogs autoriza container:logs, dura no máximo 15 minutos, propaga cancelamento e reavalia o acesso a cada 60 segundos. OpenTerminal recebe e devolve streams, exige WebSocket e aplica limites próprios. Esses recursos transportam conteúdo sob demanda e não devem herdar as garantias de uma invalidação.
Escala e operação sem prometer o que ainda não existe#
Uma conexão SignalR consome socket, memória e keep-alive. O Dokpod configura 15 segundos para KeepAliveInterval e 30 para ClientTimeoutInterval, proporção recomendada pela documentação do ASP.NET Core. São valores do projeto: ajuste após medir proxy, latência e custo por conexão.
O proxy precisa encaminhar o upgrade WebSocket, evitar buffering que atrase SSE e alinhar timeouts aos pings. No Dokpod, o browser passa por nginx e BFF; um teste direto ao Kestrel não valida essa borda.
Com múltiplos processos, as requisições de uma conexão normalmente exigem afinidade. Há exceções documentadas, como instância única, Azure SignalR Service e WebSockets exclusivos com negociação ignorada. Redis backplane encaminha mensagens entre servidores, mas geralmente não remove a afinidade nem define ownership de sessões de agente ou roteamento de comandos.
Azure SignalR Service e Redis são opções com topologias, custos e dependências diferentes; o Dokpod não usa nenhuma delas. O plano começa com uma instância de API. Escala horizontal requer decisão sobre ownership, fencing e roteamento durável de comandos, motivada por métricas e ADR.
Observe conexões atuais e encerradas, timeouts, falhas de autorização e latência até o refresh. O ASP.NET Core oferece logs e métricas de conexão. Aumente a verbosidade somente durante diagnóstico: logs, HAR e traces podem conter payloads sensíveis.
Exemplo Prático: browser, BFF, Keycloak e invalidação#
O fluxo abaixo usa o console do Dokpod: SignalR não substitui REST nem é o canal do agente.
- O Angular registra handlers, solicita antiforgery ao BFF e conecta-se por
/hubs/control-planecom o cookie de sessão. - O BFF valida sessão e origem e acrescenta o Bearer server-side no salto até a API.
JoinEnvironmentAsyncvalida o GUID e consulta Keycloak paraenvironment:readnaquele recurso.- Somente uma decisão permitida adiciona a conexão ao grupo determinístico do ambiente.
- A API emite
invalidated; o Angular consulta REST para obter o estado atual. - Após reconectar, o cliente reinscreve ambientes e atualiza o estado por REST; avisos do intervalo podem ter sido perdidos.
builder.Services.AddSignalR(options =>
{
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
});
app.MapHub<ControlPlaneHub>("/hubs/control-plane")
.RequireAuthorization();
📂 Código-fonte: ApiHost.cs. A API atual também configura autenticação JWT e uma política que exige usuário autenticado.
ConsoleRealtimeService é o cliente Angular e SignalRControlPlaneNotifier publica fora do Hub. Serviços notificam mudanças de inventário, eventos e hosts; a leitura seguinte continua usando uma rota HTTP autorizada. Agentes permanecem no canal gRPC/mTLS.
O relatório de prontidão de 25/09 registra conexão browser autorizada e negação do ambiente sem acesso; testes também cobrem rotas protegidas, grupo estável e identificador inválido. Isso valida uma fatia, não carga nem release: o veredito geral continua NO-GO, com gates de operação e distribuição pendentes. docs/bff.md preserva status histórico de 13/09, anterior à autorização horizontal; considerei o código e a avaliação mais recentes sem alterar esse registro.
📖 Artigo completo com exemplos de código: SignalR no .NET: do primeiro Hub à produção