ASP.NET Core SignalR'a Giriş: Hub, Strongly Typed Hub ve Angular'dan @microsoft/signalr ile Bağlanmak
SignalR hub'ı her çağrıda yeniden oluşturulan, transient bir handler'dır; state tutmaz, istemciye gösterilecek hatayı HubException ile verir. AddSignalR ve MapHub, Hub<IClient> ile tip güvenli çağrılar, transport negotiation, Angular'da HubConnection'ı signal yayınlayan bir service'e kapsüllemek ve AllowCredentials ile açık origin listesi.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Angular 22.2 ve @microsoft/signalr 10.0.11 ile derlenip denenmiştir.
Önce Tahmin Et
Bir SignalR hub'ına private int _calls field'ı koyuyorsun ve her method çağrısında bir artırıyorsun. Aynı istemci aynı bağlantı üzerinden method'u iki kez çağırıyor. İkinci çağrıda _calls kaç olur?
İkinci soru: hub method'u içinde iki farklı exception fırlatıyorsun; biri HubException("Order 999 was not found."), diğeri InvalidOperationException("Carrier API is down"). Production ortamında istemci bu mesajlardan hangisini görür?
İki sorunun cevabı da SignalR'ın nasıl tasarlandığını anlatıyor. Bu yazıda Bookstore'un depo ekranındaki canlı sipariş panosunu kuruyoruz: yeni sipariş gelince tablo kendiliğinden güncelleniyor, "Pack" düğmesine basan çalışanın değişikliğini diğer herkes anında görüyor.
Hub'ı Kurmak: AddSignalR ve MapHub
using Bookstore.Realtime;
using Microsoft.AspNetCore.Http.Connections;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options => options.EnableDetailedErrors = builder.Environment.IsDevelopment());
builder.Services.AddSingleton<OrderBoardStore>();
builder.Services.AddScoped<IOrderQueries, OrderQueries>();
builder.Services.AddCors(options => options.AddPolicy("spa", policy => policy
.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowCredentials()));
WebApplication app = builder.Build();
app.UseCors("spa");
app.MapHub<OrderBoardHub>("/hubs/order-board", options =>
{
if (args.Contains("--no-websockets"))
{
// WebSocket'i engelleyen bir proxy'yi taklit etmek için
options.Transports = HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling;
}
});
app.MapPost("/orders", async (NewOrder order, OrderBoardStore store, Microsoft.AspNetCore.SignalR.IHubContext<OrderBoardHub, IOrderBoardClient> hub) =>
{
OrderSummary created = store.Add(order.CustomerName, order.Total);
await hub.Clients.All.OrderUpdated(created);
return TypedResults.Created($"/orders/{created.Id}", created);
});
app.Run();
SignalR ASP.NET Core'un parçası, ayrı bir NuGet paketi gerekmiyor. AddSignalR hub altyapısını DI (Dependency Injection) container'ına ekliyor, MapHub ise bir endpoint değil bir endpoint grubu açıyor: /hubs/order-board/negotiate ve transport'ların kullandığı adres. MapHub'ın options parametresinde transport'ları, buffer boyutlarını ve ileride göreceğimiz authentication ayarlarını yapıyorsun.
MapPost("/orders") sıradan bir HTTP (Hypertext Transfer Protocol) endpoint'i ve hub'ın dışından, IHubContext ile bütün bağlı istemcilere mesaj gönderiyor. Hub'a kim nereden mesaj gönderebilir sorusunu bir sonraki yazıda derinlemesine ele alıyoruz.
Strongly Typed Hub: Hub<IOrderBoardClient>
public sealed record NewOrder(string CustomerName, decimal Total);
public sealed record OrderSummary(int Id, string CustomerName, decimal Total, string Status);
public interface IOrderBoardClient
{
Task OrderUpdated(OrderSummary order);
Task OrderRemoved(int orderId);
}
public sealed class OrderBoardHub(IOrderQueries queries, OrderBoardStore store, ILogger<OrderBoardHub> logger)
: Hub<IOrderBoardClient>
{
private int _calls; // her çağrıda yeni hub instance'ı: bu sayaç hep 1 olur
public override Task OnConnectedAsync()
{
string transport = Context.Features.Get<Microsoft.AspNetCore.Http.Connections.Features.IHttpTransportFeature>()?.TransportType.ToString() ?? "?";
logger.LogInformation("Connected {ConnectionId} via {Transport}", Context.ConnectionId, transport);
return base.OnConnectedAsync();
}
public Task<IReadOnlyList<OrderSummary>> GetOpenOrders()
{
_calls++;
logger.LogInformation("Hub instance {Hash}, calls on this instance: {Calls}", GetHashCode(), _calls);
return queries.GetOpenOrdersAsync(Context.ConnectionAborted);
}
public async Task MarkPacked(int orderId)
{
OrderSummary? updated = store.UpdateStatus(orderId, "Packed")
?? throw new HubException($"Order {orderId} was not found.");
await Clients.All.OrderUpdated(updated);
}
public Task Ship(int orderId) =>
throw new InvalidOperationException($"Carrier API is down for order {orderId}");
}
Klasik Hub'da istemciye mesajı Clients.All.SendAsync("OrderUpdated", order) diye, method adını string olarak yazıp gönderirsin. Yazım hatası yaparsan derleyici sesini çıkarmaz, istemci hiçbir şey almaz ve hiçbir yerde hata görmezsin. Hub<IOrderBoardClient> ile Clients.All artık IOrderBoardClient tipinde: Clients.All.OrderUpdated(updated). Method adı interface'ten geliyor, parametre tipleri derleme zamanında kontrol ediliyor. Interface'teki method'lar Task dönmek zorunda, çünkü "gönder" işlemi asenkron.
İstemci tarafında bu tip güvenliği yok: TypeScript'te connection.on('OrderUpdated', ...) yine bir string. Bu yüzden method adlarını tek bir yerde sabit olarak tutmak ve backend ile birlikte değiştirmek iyi bir alışkanlık.
Hub Method'ları: Parametreler, Dönüş Tipleri ve Hatalar
Hub method'unun parametreleri ve dönüş değeri JSON (JavaScript Object Notation) ile serialize ediliyor (System.Text.Json, camelCase). Task<T> dönen method istemcide invoke ile çağrılınca promise o değerle çözülüyor; Task dönen method için de istemci method'un bitmesini bekliyor. Cevap beklemeden "gönder ve unut" istiyorsan istemcide send var. Node 24 üzerinde @microsoft/signalr ile yazdığım test istemcisi bağlandı, bir HTTP POST ile sipariş ekledi, sonra method'ları çağırdı. Development ortamında (EnableDetailedErrors = true):
[2026-11-08T02:25:55.219Z] Information: WebSocket connected to ws://localhost:17604/hubs/order-board?id=yDqAGuME_7ZiBPZDCI1F4Q.
[2026-11-08T02:25:55.222Z] Information: Using HubProtocol 'json'.
state: Connected id: jevGXZ6fTtGN0F3hUP8PKQ
OrderUpdated {"id":1001,"customerName":"Ada","total":42.5,"status":"Received"}
open orders: [{"id":1001,"customerName":"Ada","total":42.5,"status":"Received"}]
OrderUpdated {"id":1001,"customerName":"Ada","total":42.5,"status":"Packed"}
MarkPacked failed: An unexpected error occurred invoking 'MarkPacked' on the server. HubException: Order 999 was not found.
Ship failed: An unexpected error occurred invoking 'Ship' on the server. InvalidOperationException: Carrier API is down for order 1001
[2026-11-08T02:25:55.448Z] Information: Connection disconnected.
state: Disconnected
Aynı istemci Production ortamında:
MarkPacked failed: An unexpected error occurred invoking 'MarkPacked' on the server. HubException: Order 999 was not found.
Ship failed: An unexpected error occurred invoking 'Ship' on the server.
Baştaki ikinci sorunun cevabı: production'da yalnızca HubException'ın mesajı istemciye gidiyor. Diğer exception'lar sunucu log'una tam stack trace ile yazılıyor, istemci ise genel bir mesaj alıyor. Bu bilinçli bir güvenlik kararı: bir veritabanı exception'ının mesajı tablo adlarını, bağlantı bilgilerini sızdırabilir. Kural basit: kullanıcıya gösterilecek her hata HubException, gerisi log için. EnableDetailedErrors'ı yalnızca Development'ta açtığımıza dikkat et.
Hub'ın Yaşam Ömrü: Transient
Test istemcisi GetOpenOrders'ı aynı bağlantı üzerinden iki kez çağırdı. Sunucu log'u:
info: Bookstore.Realtime.OrderBoardHub[0]
Connected jevGXZ6fTtGN0F3hUP8PKQ via WebSockets
info: Bookstore.Realtime.OrderBoardHub[0]
Hub instance 66502754, calls on this instance: 1
info: Bookstore.Realtime.OrderBoardHub[0]
Hub instance 49891972, calls on this instance: 1
Baştaki ilk sorunun cevabı: 1. Her hub method çağrısı için yeni bir hub instance'ı oluşturuluyor ve çağrı bitince dispose ediliyor. Hub bir controller'a benziyor; bağlantının kendisi değil, bağlantı üzerinden gelen tek bir isteğin işleyicisi. Bu yüzden hub'da field ile state tutmak her zaman hatadır. Bağlantıya ait bir şey saklaman gerekiyorsa Context.Items (bağlantı boyunca yaşayan bir sözlük) ya da hub dışında bir singleton store kullan; birden fazla sunucuda çalışacaksan paylaşılan bir yer (Redis, veritabanı) gerekir.
Her çağrı için ayrıca bir DI scope'u açılıyor. IOrderQueries scoped kayıtlı ve hub'ın constructor'ından geliyor; gerçek uygulamada bu bir EF (Entity Framework) Core DbContext kullanan service olurdu ve her hub çağrısı kendi DbContext'ini alırdı. Hub method'unda Task.Run ile işi arka plana atıp method'dan dönersen, scope kapanır ve arka plandaki kod dispose edilmiş bir DbContext ile kalır. Uzun süren işler bir background job'a gider; sonucu IHubContext ile gönderir.
Transport Negotiation
Yukarıdaki log'un ilk satırında istemci WebSocket ile bağlandı. Bunun arkasında bir pazarlık var: istemci önce POST /hubs/order-board/negotiate ile sunucunun hangi transport'ları desteklediğini soruyor, sonra WebSocket, SSE (Server-Sent Events) ve long polling'i bu sırayla deniyor. Kurumsal bir proxy'nin WebSocket'i engellediğini taklit etmek için sunucuyu --no-websockets ile başlattım:
[2026-11-08T02:26:09.266Z] Information: SSE connected to http://localhost:17604/hubs/order-board?id=Yo3n-PWm7fTh9BjXiu0f5A
[2026-11-08T02:26:09.269Z] Information: Using HubProtocol 'json'.
state: Connected id: lpCHk_Vw62XdNj_Ru1m2bQ
info: Bookstore.Realtime.OrderBoardHub[0]
Connected lpCHk_Vw62XdNj_Ru1m2bQ via ServerSentEvents
Uygulama kodunda tek satır değişmedi; method çağrıları ve OrderUpdated mesajları aynen çalıştı. SSE tek yönlü olduğu için istemciden sunucuya giden her hub çağrısı ayrı bir HTTP POST olarak gidiyor; gecikme ve sunucu yükü artıyor ama uygulama çalışıyor. Transport'u istemcide de kısıtlayabilirsin: withUrl(url, { transport: HttpTransportType.WebSockets, skipNegotiation: true }) negotiate isteğini hiç atmadan doğrudan WebSocket açar. Bunun scale-out tarafında önemli bir sonucu var, serinin sekizinci yazısında göreceğiz.
Log'da gözden kaçabilecek bir ayrıntı: URL'deki (Uniform Resource Locator) id=yDqAGuME... ile connectionId (jevGXZ6f...) farklı. Negotiate yanıtında ikisi ayrı geliyor; URL'deki connectionToken yalnızca bağlantının sahibi tarafından bilinmeli, connectionId ise diğer kullanıcılarla paylaşılabilir. Başka birinin connectionId'sini bilmek onun bağlantısını ele geçirmeye yetmiyor.
Angular: HubConnection'ı Service İçinde Kapsüllemek
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr';
export interface OrderSummary {
id: number;
customerName: string;
total: number;
status: string;
}
// component seviyesinde provide edilir: bağlantının ömrü component'in ömrü kadar
@Injectable()
export class OrderBoardService {
private readonly connection: HubConnection = new HubConnectionBuilder()
.withUrl('http://localhost:17604/hubs/order-board')
.configureLogging(LogLevel.Information)
.build();
private readonly ordersById = signal(new Map<number, OrderSummary>());
readonly orders = computed(() => [...this.ordersById().values()]);
readonly state = signal<HubConnectionState>(HubConnectionState.Disconnected);
constructor() {
this.connection.on('OrderUpdated', (order: OrderSummary) =>
this.ordersById.update((map) => new Map(map).set(order.id, order)),
);
this.connection.on('OrderRemoved', (orderId: number) =>
this.ordersById.update((map) => {
const next = new Map(map);
next.delete(orderId);
return next;
}),
);
this.connection.onclose(() => this.state.set(this.connection.state));
inject(DestroyRef).onDestroy(() => {
this.connection.off('OrderUpdated');
this.connection.off('OrderRemoved');
void this.connection.stop();
});
}
async start(): Promise<void> {
if (this.connection.state !== HubConnectionState.Disconnected) {
return;
}
this.state.set(HubConnectionState.Connecting);
await this.connection.start();
this.state.set(this.connection.state);
// bağlandıktan sonra güncel listeyi çek; push mesajları bunun üstüne gelir
const open = await this.connection.invoke<OrderSummary[]>('GetOpenOrders');
this.ordersById.set(new Map(open.map((order) => [order.id, order])));
}
markPacked(orderId: number): Promise<void> {
return this.connection.invoke('MarkPacked', orderId);
}
}
Birkaç tasarım kararı var. HubConnection service'in içinde ve dışarıya yalnızca signal'lar ve anlamlı method'lar (markPacked) açılıyor; component SignalR'ı bilmiyor. Bağlantı durumu da bir signal, template onu doğrudan gösterebiliyor. @Injectable()'da providedIn yok, service'i component'in providers dizisinde veriyoruz; böylece DestroyRef component'inki oluyor ve sayfa kapanınca handler'lar kaldırılıp bağlantı durduruluyor. Uygulama boyunca tek bir bağlantı istiyorsan providedIn: 'root' kullan ve stop()'u component'e bırakma.
Bir de sıra: önce bağlanıp sonra GetOpenOrders ile listeyi çekiyoruz. Tersini yaparsan (önce HTTP ile listeyi çek, sonra bağlan), aradaki birkaç yüz milisaniyede gelen sipariş hiçbir zaman ekrana düşmez.
import { Component, OnInit, inject, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { OrderBoardService } from './order-board.service';
@Component({
selector: 'app-order-board',
imports: [CurrencyPipe],
providers: [OrderBoardService],
template: `
<p>Connection: {{ board.state() }}</p>
@if (error(); as message) {
<p class="error">{{ message }}</p>
}
<table>
@for (order of board.orders(); track order.id) {
<tr>
<td>#{{ order.id }}</td>
<td>{{ order.customerName }}</td>
<td>{{ order.total | currency: 'USD' }}</td>
<td>{{ order.status }}</td>
<td><button (click)="pack(order.id)" [disabled]="order.status !== 'Received'">Pack</button></td>
</tr>
}
</table>
`,
})
export class OrderBoardComponent implements OnInit {
protected readonly board = inject(OrderBoardService);
protected readonly error = signal<string | null>(null);
async ngOnInit(): Promise<void> {
try {
await this.board.start();
} catch (e) {
this.error.set(`Could not connect: ${(e as Error).message}`);
}
}
async pack(orderId: number): Promise<void> {
try {
await this.board.markPacked(orderId);
} catch (e) {
this.error.set((e as Error).message);
}
}
}
Tarayıcıda denedim: sayfa açılmadan önce Ada ve Grace'in siparişlerini ekledim, sayfa açıkken curl ile Alan'ınkini, sonra Grace'in satırındaki "Pack" düğmesine bastım. Sayfanın son hâli:
Connection: Connected
#1001 Ada $42.50 Received Pack
#1002 Grace $42.50 Packed Pack
#1003 Alan $19.90 Received Pack
İlk iki satır GetOpenOrders'tan, üçüncüsü OrderUpdated push'undan, Grace'in "Packed" durumu da kendi tıklamamın sunucudan bütün istemcilere yayınlanan sonucundan geldi. Angular 22'de zoneless çalışıyoruz; SignalR callback'leri Zone.js'in haberi olmadan çalışsa da signal güncellemesi ekranı yeniliyor.
CORS: AllowCredentials ve Açık Origin Listesi
Angular uygulaması 4200'de, API (Application Programming Interface) başka bir port ya da domain'deyse tarayıcı negotiate isteği için CORS (Cross-Origin Resource Sharing) kontrolü yapıyor. SignalR'ın tarayıcı client'ı istekleri withCredentials ile gönderiyor (cookie ve sticky session cookie'leri için), bu yüzden policy'de AllowCredentials() şart. "Hepsine izin vereyim" diye AllowAnyOrigin() eklersen uygulama hiç ayağa kalkmıyor:
System.InvalidOperationException: The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time. Configure the CORS policy by listing individual origins if credentials needs to be supported.
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
Bu iyi bir hata; credentials ile her origin'e izin vermek, herhangi bir sitenin kullanıcının oturumuyla senin hub'ına bağlanabilmesi demek. Çözüm açık bir origin listesi (WithOrigins). SetIsOriginAllowed(_ => true) ile hatayı susturmak aynı güvenlik açığını geri getirir. CORS'un ayrıntıları ASP.NET Core Web API serisinin CORS yazısında.
Üretimde Dikkat
Hub method'ları kısa sürmeli. Aynı bağlantıdan gelen hub çağrıları varsayılan olarak sırayla işleniyor (MaximumParallelInvocationsPerClient varsayılanı 1); bir method 10 saniye sürerse o istemcinin sonraki çağrıları da 10 saniye bekler. Uzun işleri kuyruğa at, sonucu push et.
Mesaj boyutunu bil. Sunucunun kabul ettiği en büyük gelen mesaj varsayılan olarak 32 KB (MaximumReceiveMessageSize). İstemciden büyük bir payload göndermen gerekiyorsa SignalR yerine HTTP upload kullan ya da serinin yedinci yazısındaki client-to-server streaming'e bak.
Log'larda Microsoft.AspNetCore.SignalR ve Microsoft.AspNetCore.Http.Connections kategorilerini tanı. İlki hub çağrılarını, ikincisi transport ve bağlantı olaylarını yazıyor; bağlantı sorunlarını araştırırken ikisini geçici olarak Debug seviyesine çekmek ilk adım.
Sık Düşülen Tuzaklar
Hub'da field ile state tutmak. Her çağrıda yeni instance; field'lar çağrılar arasında yaşamaz.
Hub'ı static bir yerde saklayıp oradan mesaj göndermek. Hub dispose edildikten sonra Clients'ı kullanmak hata verir; hub dışından gönderim için IHubContext var.
Klasik Hub'da method adını string olarak yazıp yazım hatası yapmak. İstemci hiçbir şey almaz, hiçbir yerde hata görmezsin; strongly typed hub kullan.
Kullanıcıya göstermek istediğin hatayı sıradan bir exception ile fırlatmak. Production'da istemci yalnızca "An unexpected error occurred" görür; HubException kullan.
Önce veriyi çekip sonra bağlanmak. Aradaki boşlukta gelen mesajlar kaybolur; önce bağlan, sonra çek.
CORS'ta AllowAnyOrigin ya da her origin'i kabul eden bir predicate kullanmak.
Küçük Bir Deneme
★☆☆ OrderBoardHub'ı klasik Hub'a çevir ve SendAsync("OrderUpdate", ...) diye bir harf eksik yaz. Tarayıcıda ve sunucu log'unda ne gördüğünü not et.
★★☆ Sunucuyu --no-websockets ile başlat, Angular uygulamasını aç ve tarayıcının network sekmesinde hub çağrılarının nasıl ayrı POST istekleri olarak gittiğini incele. Sonra transport'u yalnızca LongPolling'e indir ve aynı şeye bak.
★★★ MarkPacked'e await Task.Delay(5000) ekle ve aynı sekmeden iki farklı siparişi peş peşe paketle. İkinci çağrının neden 5 saniye beklediğini açıkla, sonra MaximumParallelInvocationsPerClient'ı 2 yapıp tekrar dene.
Bir sonraki yazıda mesajın kime gittiğine odaklanıyoruz: Clients.User, Clients.Group, connection yaşam döngüsü, bir kullanıcının birden fazla sekmesi, presence ve IHubContext ile controller'dan, background job'dan ve message consumer'dan bildirim göndermek.