Mert Özen Her Satırda Daha İleri
Backend Real-time Uygulamalar — #03

Server-Sent Events Derinlemesine: TypedResults.ServerSentEvents, Last-Event-ID ile Kaldığı Yerden Devam ve Proxy Tuzakları

Mert Özen 8 Kas 2026 14 dk 3 görüntülenme
Server-Sent Events Derinlemesine: TypedResults.ServerSentEvents, Last-Event-ID ile Kaldığı Yerden Devam ve Proxy Tuzakları

SSE'de kaçırılan event'leri telafi etmek için her event'e EventId ver, Last-Event-ID header'ından sonrasını yeniden gönder ve bağlanır bağlanmaz bir heartbeat yolla. SseItem<T>, retry, CancellationToken, Nginx gzip ve timeout tuzakları, HTTP/1.1'in 6 bağlantı limiti, Angular'da EventSource'u Observable'a sarmak ve header gerektiğinde fetch ile okumak.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Angular 22.2 ve Nginx 1.29 ile derlenip denenmiştir.

Önce Tahmin Et

Bir SSE (Server-Sent Events) endpoint'in var ve TypedResults.ServerSentEvents ile bir IAsyncEnumerable döndürüyor. İstemci bağlanıyor, ama izlediği siparişte henüz hiçbir şey olmadı; ilk event 40 saniye sonra gelecek. İstemci 200 OK yanıtını ne zaman alır? Hemen mi, 40 saniye sonra mı? Önünde varsayılan ayarlarla çalışan bir Nginx varsa ne olur?

Bu yazıda bunun gibi, SSE'yi local'de kusursuz çalışıp production'da sessizce bozan ayrıntıların peşine düşüyoruz. Cevap ilk event'le ilgili bölümde.

SseItem<T>: Event Tipi, Id ve Retry

ASP.NET Core Web API (Application Programming Interface) serisinin ".NET 10 Yenilikleri" yazısında TypedResults.ServerSentEvents'e bir IAsyncEnumerable<T> verip her öğeyi bir event olarak yollamıştık. Kaldığı yerden devam ve heartbeat için daha fazla kontrol lazım; o da SseItem<T> ile geliyor:

public sealed record OrderEvent(long Sequence, int OrderId, string Status, DateTimeOffset At);

public async IAsyncEnumerable<SseItem<OrderEvent?>> ReadAsync(
    int orderId, long lastEventId, [EnumeratorCancellation] CancellationToken cancellationToken)
{
    logger.LogInformation("Client connected for order {OrderId}, Last-Event-ID: {LastEventId}", orderId, lastEventId);
    int sent = 0;
    try
    {
        // ilk item gidene kadar response header'ları da gitmez: hemen bir heartbeat gönder,
        // tarayıcıya da "koparsa 3 sn sonra dene" de
        yield return new SseItem<OrderEvent?>(null, "heartbeat") { ReconnectionInterval = TimeSpan.FromSeconds(3) };

        while (!cancellationToken.IsCancellationRequested)
        {
            Task changed;
            OrderEvent[] pending;
            lock (_gate)
            {
                pending = [.. _events.Where(e => e.OrderId == orderId && e.Sequence > lastEventId)];
                changed = _changed.Task;
            }

            foreach (OrderEvent e in pending)
            {
                yield return new SseItem<OrderEvent?>(e, "order-status") { EventId = e.Sequence.ToString() };
                lastEventId = e.Sequence;
                sent++;
            }

            if (!await WaitOrTimeoutAsync(changed, HeartbeatInterval, cancellationToken))
            {
                // 15 sn sessizlik: proxy'ler bağlantıyı boşta sanıp kesmesin
                yield return new SseItem<OrderEvent?>(null, "heartbeat");
            }
        }
    }
    finally
    {
        logger.LogInformation("Client left order {OrderId} after {Sent} events", orderId, sent);
    }
}

OrderEventLog sınıfı event'leri sıra numarasıyla bir listede tutuyor; _changed da bir önceki yazıdaki gibi "yeni bir şey eklendi" sinyali veren bir TaskCompletionSource. WaitOrTimeoutAsync ise changed.WaitAsync(timeout, cancellationToken)'ı çağırıp timeout'ta false dönen küçük bir yardımcı; iterator'da catch bloğunun içinden yield return yapılamadığı için (CS1631) bu ayrımı dışarı aldık.

SseItem<T>'nin dört parçası SSE formatının dört alanına karşılık geliyor: Data → data:, EventType → event:, .NET 10'da eklenen EventId → id: ve ReconnectionInterval → retry:. Üç sipariş event'i ekleyip curl ile bağlandığımda:

$ curl -s -i -N http://localhost:17603/orders/42/events
HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Sun, 08 Nov 2026 02:19:51 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Pragma: no-cache
Transfer-Encoding: chunked

event: heartbeat
data:
retry: 3000

event: order-status
data: {"sequence":1,"orderId":42,"status":"Received","at":"2026-11-08T02:19:51.95221+00:00"}
id: 1

event: order-status
data: {"sequence":2,"orderId":42,"status":"Packed","at":"2026-11-08T02:19:51.963727+00:00"}
id: 2

event: order-status
data: {"sequence":3,"orderId":42,"status":"Shipped","at":"2026-11-08T02:19:51.9701+00:00"}
id: 3

Header'lara bak: Cache-Control: no-cache,no-store ve Content-Encoding: identity'yi biz yazmadık, ServerSentEventsResult yazıyor. Kaynak koduna baktığımda response buffering'i de DisableBuffering() ile kapattığını gördüm. Bu üç ayrıntının neden önemli olduğunu proxy bölümünde göreceğiz.

Event tipi tarayıcı tarafını da belirliyor: event: satırı olmayan mesajlar onmessage'a düşer, event: order-status olanlar yalnızca addEventListener('order-status', ...) ile dinlenir. Heartbeat'leri bu yüzden ayrı bir tiple gönderiyoruz; kimse heartbeat dinlemediği için tarayıcıda hiçbir kod tetiklenmiyor.

Last-Event-ID ile Kaldığı Yerden Devam

app.MapGet("/orders/{id:int}/events", (int id, HttpRequest request, OrderEventLog log, CancellationToken cancellationToken) =>
{
    // tarayıcı yeniden bağlanırken son gördüğü id'yi bu header'da gönderir
    long lastEventId = long.TryParse(request.Headers["Last-Event-ID"], out long parsed) ? parsed : 0;
    return TypedResults.ServerSentEvents(log.ReadAsync(id, lastEventId, cancellationToken));
});

SSE'nin WebSocket'e karşı en büyük avantajı bu: bağlantı koptuğunda EventSource kendiliğinden yeniden bağlanıyor ve son aldığı id'yi Last-Event-ID header'ında gönderiyor. Senin işin o numaradan sonrasını tekrar yollamak. curl ile elle denediğimde:

$ curl -s -N http://localhost:17603/orders/42/events -H "Last-Event-ID: 2"
event: heartbeat
data:
retry: 3000

event: order-status
data: {"sequence":3,"orderId":42,"status":"Shipped","at":"2026-11-08T02:19:51.9701+00:00"}
id: 3

Asıl testi tarayıcıda yaptım. Angular uygulaması Nginx üzerinden bağlıyken Nginx container'ını docker restart -t 0 ile yeniden başlattım, o sırada siparişe "Shipped" ekledim, birkaç saniye sonra da "OutForDelivery". Tarayıcı konsolu:

[sse] open
[sse] id=1
[sse] id=2
[sse] error, readyState=0
[sse] open
[sse] id=3
[sse] id=4

Sunucu log'u:

info: Bookstore.Realtime.OrderEventLog[0]
      Client connected for order 42, Last-Event-ID: 0
info: Bookstore.Realtime.OrderEventLog[0]
      Client left order 42 after 2 events
info: Bookstore.Realtime.OrderEventLog[0]
      Client connected for order 42, Last-Event-ID: 2

Kopuş sırasında eklenen 3 numaralı event kaybolmadı. Tarayıcı retry: 3000'e uyup 3 saniye bekledi, Last-Event-ID: 2 ile geldi ve eksik event'i aldı. Bunu bedavaya alamazsın: sunucu tarafında event'leri sıra numarasıyla saklaman gerekiyor. Burada bellek içi bir liste kullandık; birden fazla sunucuda ya da yeniden başlatmada da çalışması için bu log'un Redis Streams, bir veritabanı tablosu ya da outbox gibi paylaşılan bir yerde durması lazım. Log'u sonsuza kadar tutamayacağına göre bir de saklama süresi belirle; istemcinin istediği id artık log'da yoksa ona "full refresh yap" diyen özel bir event gönder.

Heartbeat, İlk Byte ve Client'ın Ayrılması

Baştaki sorunun cevabı: ilk sürümde koddaki o ilk heartbeat satırı yoktu ve hiçbir event yokken curl'e 3 saniye boyunca tek byte gelmedi; 200 OK dahil. ServerSentEventsResult header'ları ilk item'la birlikte yazıyor. Önüne proxy_read_timeout 10s olan bir Nginx koyduğumda istemcinin gördüğü:

HTTP/1.1 504 Gateway Time-out
Content-Type: text/html
Content-Length: 167
Connection: keep-alive
upstream timed out (110: Operation timed out) while reading response header from upstream

Nginx "header'ı bekliyordum, gelmedi" diyor. Tarayıcıda bunun anlamı EventSource'un onopen'ı hiç tetiklenmeden hata alması. Çözüm basit: bağlanır bağlanmaz bir item gönder. Biz bunu, aynı zamanda retry değerini de taşıyan bir heartbeat ile yapıyoruz.

İkinci sorun boşta kalan bağlantı. Nginx'in proxy_read_timeout varsayılanı 60 saniye; upstream'den bu süre boyunca tek byte gelmezse bağlantıyı kesiyor. Load balancer'ların da benzer idle timeout'ları var. Heartbeat aralığı bu sürelerin hepsinden kısa olmalı. Bilerek ters bir ayar yaptım (heartbeat 15 sn, proxy_read_timeout 10s):

HTTP/1.1 200 OK

event: heartbeat
data:
retry: 3000

real 10.01

İlk heartbeat sayesinde 200 geldi, ama 10 saniye sonra bağlantı kesildi. Tarayıcı yeniden bağlanırdı, ama her 10 saniyede bir kopan bir akış hem log'ları kirletir hem de gereksiz yeniden bağlanma maliyeti yaratır. Heartbeat'i SSE comment satırı (: ping) olarak göndermek de standart bir yol; SseItem<T> comment desteklemediği için burada ayrı bir event tipi kullandık.

Son parça client'ın ayrılması. Endpoint'e gelen CancellationToken request'in RequestAborted token'ı; istemci sekmeyi kapatınca tetikleniyor, WaitAsync iptal oluyor ve iterator'ın finally bloğu çalışıyor. curl'ü --max-time 2 ile çalıştırdığımdaki log'lar yukarıda: "Client left order 42 after 3 events". Bu finally bloğu, bağlantı başına tuttuğun her şeyi (abonelik, sayaç, presence kaydı) temizlemek için doğru yer.

İki Gerçek Senaryo: Rapor İlerlemesi ve AI Token Akışı

public sealed record ReportProgress(int ReportId, int Percent, string? DownloadUrl = null);

public static class ReportGenerator
{
    public static async IAsyncEnumerable<SseItem<ReportProgress>> RunAsync(
        int reportId, [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        for (int percent = 0; percent < 100; percent += 25)
        {
            yield return new SseItem<ReportProgress>(new ReportProgress(reportId, percent), "progress");
            await Task.Delay(500, cancellationToken);
        }
        yield return new SseItem<ReportProgress>(
            new ReportProgress(reportId, 100, $"/reports/{reportId}/download"), "completed");
    }
}

app.MapGet("/reports/{id:int}/progress", (int id, CancellationToken cancellationToken) =>
    TypedResults.ServerSentEvents(ReportGenerator.RunAsync(id, cancellationToken)));
event: progress
data: {"reportId":9,"percent":0,"downloadUrl":null}

event: progress
data: {"reportId":9,"percent":25,"downloadUrl":null}

event: progress
data: {"reportId":9,"percent":50,"downloadUrl":null}

event: progress
data: {"reportId":9,"percent":75,"downloadUrl":null}

event: completed
data: {"reportId":9,"percent":100,"downloadUrl":"/reports/9/download"}

İş bitince iterator bitiyor ve response kapanıyor. Burada bir tuzak var: tarayıcıdaki EventSource sunucunun kapattığı bağlantıyı bir kopuş sanıp yeniden bağlanır ve rapor baştan üretilir. completed event'ini aldığında istemcinin close() çağırması şart. Gerçek uygulamada rapor üretimini bir background job'a verip SSE endpoint'ini yalnızca job'un durumunu okuyan bir izleyici yapmak daha sağlam; o zaman yeniden bağlanma işi baştan başlatmaz.

İkinci senaryo AI (Artificial Intelligence) yanıtlarının token token akması. Burada gerçek bir model çağırmıyoruz; sabit bir cevabı kelime kelime akıtan sahte bir assistant var, çünkü konu modelden çok taşıma katmanı:

public sealed class FakeAssistant
{
    // gerçek bir model yerine sabit bir cevabı kelime kelime akıtır
    public static async IAsyncEnumerable<string> StreamAsync(
        string question, [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        string answer = "Clean Code is a good next read after Refactoring.";
        foreach (string token in answer.Split(' '))
        {
            await Task.Delay(150, cancellationToken);
            yield return token + " ";
        }
    }
}

app.MapGet("/assistant/answer", (string question, HttpRequest request, ILogger<FakeAssistant> logger, CancellationToken cancellationToken) =>
{
    logger.LogInformation("Authorization header present: {Present}", request.Headers.Authorization.Count > 0);
    return TypedResults.ServerSentEvents(FakeAssistant.StreamAsync(question, cancellationToken), eventType: "token");
});

IAsyncEnumerable<string> verdiğinde string'ler JSON'a (JavaScript Object Notation) çevrilmeden data: satırına yazılıyor. Gerçek bir LLM (Large Language Model) client'ının streaming API'si de genelde bir IAsyncEnumerable döndürdüğü için iki uç doğrudan birbirine bağlanabiliyor. Kullanıcı "durdur"a basıp bağlantıyı kapatınca CancellationToken model çağrısına kadar iniyor ve çoğu sağlayıcıda üretimi, dolayısıyla token harcamayı durduruyor; bu, faturada görünen bir fark.

Proxy Tuzakları: Buffering, Compression ve Timeout

SSE düz HTTP (Hypertext Transfer Protocol) olduğu için her proxy'den geçer; ama her proxy onu "uzun süren normal bir yanıt" sanıp optimize etmeye çalışabilir. Bunu Nginx 1.29 container'ında üç farklı location ile denedim:

server {
    listen 80;

    # varsayılan ayarlar: proxy_buffering on
    location /buffered/ {
        proxy_pass http://host.docker.internal:17603/;
        proxy_http_version 1.1;
    }

    # sıkıştırma açık bir location
    location /gzip/ {
        proxy_pass http://host.docker.internal:17603/;
        proxy_http_version 1.1;
        proxy_set_header Accept-Encoding "";
        gzip on;
        gzip_types text/event-stream;
    }

    # SSE için doğru ayarlar
    location /live/ {
        proxy_pass http://host.docker.internal:17603/;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 1h;
    }
}

Her satırın geliş zamanını ölçtüm. Önce, kendimiz text/event-stream yazıp her event'ten sonra flush eden, TypedResults kullanmayan "elle" bir endpoint'i /gzip/ üzerinden çağırdım:

== /gzip/orders/42/events-manual
Content-Encoding: gzip
 2.99s data: {"orderId":42,"tick":1}
 2.99s data: {"orderId":42,"tick":2}
 2.99s data: {"orderId":42,"tick":3}

Birer saniye arayla üretilen üç event istemciye aynı anda, akış bitince geldi. Nginx'in gzip modülü sıkıştırma için veriyi biriktirdi. Aynı location'dan TypedResults.ServerSentEvents kullanan rapor endpoint'i:

== /gzip/reports/9/progress
Content-Encoding: identity
 0.00s data: {"reportId":9,"percent":0,"downloadUrl":null}
 0.49s data: {"reportId":9,"percent":25,"downloadUrl":null}
 0.99s data: {"reportId":9,"percent":50,"downloadUrl":null}
 1.50s data: {"reportId":9,"percent":75,"downloadUrl":null}
 2.00s data: {"reportId":9,"percent":100,"downloadUrl":"/reports/9/download"}

Fark Content-Encoding: identity header'ında: yanıt "zaten kodlanmış" olarak işaretlendiği için Nginx onu sıkıştırmaya kalkmadı. Uygulamanın kendi UseResponseCompression middleware'ine text/event-stream'i eklediğimde de aynı tablo çıktı: elle yazılan endpoint gzip'lendi (bu middleware her FlushAsync'te sıkıştırıcıyı da flush ettiği için olaylar yine zamanında geldi), TypedResults olan hiç sıkıştırılmadı. Benim ortamımda tek başına proxy_buffering on gecikme yaratmadı; istemci hızlı okuduğunda Nginx gelen veriyi bekletmeden iletti. Yine de /live/'daki gibi kapatmak, yavaş istemcide ve araya giren cache'lerde sürprizi önler. Uygulamadan tek bir endpoint için kapatmak istersen yanıta X-Accel-Buffering: no header'ı eklemek de Nginx'e aynı şeyi söyler.

Tarayıcının 6 bağlantı limiti

HTTP/1.1 üzerinde tarayıcılar aynı host'a en fazla 6 eşzamanlı bağlantı açıyor ve her EventSource bunlardan birini sürekli meşgul ediyor. Tarayıcıda aynı sunucuya 7 EventSource açtım:

{"openedAfter3s":[1,5,4,3,2,6],"afterClosingFirst":[1,5,4,3,2,6,7]}

Yedincisi, birincisini kapatana kadar bekledi. Kullanıcı uygulamanı 7 sekmede açarsa ya da tek sayfada birkaç farklı akış açarsan, sıradaki istekler (normal API çağrıları dahil) askıda kalır. Çözüm HTTP/2: tek TCP (Transmission Control Protocol) bağlantısı üzerinde çok sayıda stream taşıdığı için bu limit pratikte kalkıyor (Kestrel'in varsayılanı bağlantı başına 100 eşzamanlı stream). Tarayıcılar HTTP/2'yi yalnızca TLS (Transport Layer Security) üzerinde kullandığı için production'da HTTPS (HTTP Secure) zaten şart. İkinci çözüm tek sayfada tek bir akış açıp içinde farklı event tipleri taşımak.

Angular: EventSource'u Observable'a Sarmak

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

export interface OrderEvent {
  sequence: number;
  orderId: number;
  status: string;
  at: string;
}

@Injectable({ providedIn: 'root' })
export class OrderEventsService {
  private readonly baseUrl = 'http://localhost:17630/live';

  watch(orderId: number): Observable<OrderEvent> {
    return new Observable<OrderEvent>((subscriber) => {
      const source = new EventSource(`${this.baseUrl}/orders/${orderId}/events`, { withCredentials: true });

      source.addEventListener('order-status', (event: MessageEvent<string>) => {
        console.log(`[sse] id=${event.lastEventId}`);
        subscriber.next(JSON.parse(event.data) as OrderEvent);
      });
      source.onopen = () => console.log('[sse] open');
      source.onerror = () => {
        // CONNECTING (0): tarayıcı retry süresi sonunda kendisi yeniden bağlanacak
        // CLOSED (2): tarayıcı vazgeçti (ör. 200 dışı yanıt), artık bizim sorunumuz
        console.log(`[sse] error, readyState=${source.readyState}`);
        if (source.readyState === EventSource.CLOSED) {
          subscriber.error(new Error('SSE connection closed by the browser'));
        }
      };

      // unsubscribe olunca bağlantıyı kapat
      return () => source.close();
    });
  }
}

Observable'ın teardown fonksiyonu source.close()'u çağırıyor; component yok olunca bağlantı da kapanıyor. onerror'da readyState'e bakmak önemli: CONNECTING "tarayıcı halledecek" demek, burada Observable'ı hata ile bitirirsen RxJS tarafındaki retry ile tarayıcının kendi yeniden bağlanması çakışır ve iki bağlantı açılır. Tarayıcı ancak sunucu 200 dışı bir yanıt ya da yanlış bir content type döndüğünde vazgeçiyor (CLOSED).

import { Component, inject, input } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { scan, switchMap } from 'rxjs';
import { OrderEvent, OrderEventsService } from './order-events.service';

@Component({
  selector: 'app-order-timeline',
  template: `
    <ul>
      @for (event of events(); track event.sequence) {
        <li>#{{ event.sequence }} {{ event.status }}</li>
      }
    </ul>
  `,
})
export class OrderTimelineComponent {
  private readonly orderEvents = inject(OrderEventsService);

  readonly orderId = input.required<number>();

  protected readonly events = toSignal(
    toObservable(this.orderId).pipe(
      switchMap((id) => this.orderEvents.watch(id).pipe(scan((all, e) => [...all, e], [] as OrderEvent[]))),
    ),
    { initialValue: [] },
  );
}

Yukarıdaki kopuş testinde sayfada "#1 Received, #2 Packed, #3 Shipped, #4 OutForDelivery" listesi eksiksiz göründü. Cross-origin bir EventSource için sunucuda CORS (Cross-Origin Resource Sharing) policy'si de gerekiyor; withCredentials: true kullandığımız için policy'de AllowCredentials() ve açık bir origin listesi var.

Auth Problemi: EventSource Header Gönderemez

EventSource constructor'ı yalnızca URL (Uniform Resource Locator) ve withCredentials alıyor; Authorization header'ı ekleyemezsin. Üç yol var. Birincisi cookie: uygulama zaten cookie tabanlı authentication kullanıyorsa withCredentials: true yeterli. İkincisi token'ı query string'e koymak; SignalR'ın yaptığı bu ve riskini (token'ın access log'lara düşmesi) serinin altıncı yazısında konuşuyoruz. Üçüncüsü EventSource'u bırakıp fetch ile akışı kendin okumak:

// EventSource header gönderemez; token gerekiyorsa fetch ile kendin oku
export async function* streamWithFetch(url: string, token: string, signal?: AbortSignal): AsyncGenerator<string> {
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
    signal,
  });
  if (!response.ok || !response.body) {
    throw new Error(`SSE request failed: ${response.status}`);
  }

  let buffer = '';
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      return;
    }
    buffer += value;
    // event'ler boş satırla biter; yarım kalan son parça bir sonraki okumaya kalır
    const events = buffer.split('\n\n');
    buffer = events.pop() ?? '';
    for (const event of events) {
      const data = event
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).replace(/^ /, ''))
        .join('\n');
      if (data) {
        yield data;
      }
    }
  }
}

Bu dosyada Angular bağımlılığı olmadığı için Node 24 ile doğrudan çalıştırabildim:

"Clean "
"Code "
"is "
"a "
"good "
"next "
"read "
"after "
"Refactoring. "
Clean Code is a good next read after Refactoring.

Sunucu log'unda "Authorization header present: True" vardı. Bedeli: otomatik yeniden bağlanma, Last-Event-ID ve retry artık senin sorumluluğun. Bu parser bilerek minimal; yalnızca \n\n ile ayrılmış event'leri ve data: satırlarını ele alıyor, \r\n satır sonlarını ve id: alanını işlemiyor. Production'da bunları da ele alan, test edilmiş bir library tercih et.

Üretimde Dikkat

Bağlantı sayısını izle: her açık SSE akışı sunucuda bir request, Kestrel'de bir bağlantı. ASP.NET Core'un kestrel.active_connections ve http.server.active_requests metric'leri bunu doğrudan gösteriyor; aniden artan bir sayı, istemcilerin completed sonrası bağlantıyı kapatmadığının ya da bir proxy'nin bağlantıları kesip yeniden açtırdığının işareti.

Deploy sırasında ne olacağını düşün. Sunucu kapanırken açık SSE bağlantıları kopar ve bütün tarayıcılar retry süresi sonunda aynı anda yeniden bağlanır. retry değerini birkaç saniye tut, sunucu tarafında event log'un yeni sürece de erişilebilir olsun; yoksa yeniden bağlanan herkes Last-Event-ID ile boş bir log'a gelir.

Uzun süreli bağlantılar için timeout'ları tek tek kontrol et: Nginx proxy_read_timeout, bulut load balancer'ının idle timeout'u, CDN (Content Delivery Network) kullanıyorsan onun streaming desteği. Heartbeat aralığın bunların en küçüğünden kısa olmalı.

Sık Düşülen Tuzaklar

İlk event'i geç göndermek. Header'lar ilk item ile gittiği için proxy 504 döner, tarayıcı onopen'ı geç alır. Bağlanır bağlanmaz bir heartbeat gönder.

SSE'yi elle yazıp response compression'a yakalanmak. Kendi formatını yazıyorsan Content-Encoding'i ve buffering'i kendin yönetmek zorundasın; TypedResults.ServerSentEvents bunları senin yerine yapıyor.

id göndermeyip kopuşta event kaybetmek. EventId yoksa tarayıcı Last-Event-ID gönderemez, kopuş sırasındaki her şey kaybolur.

Biten bir akışı istemcide kapatmamak. Sunucu iterator'ı bitirince EventSource bunu kopuş sanar ve yeniden bağlanır; son event'te close() çağır.

onerror'da Observable'ı hata ile bitirip RxJS retry'ı eklemek. Tarayıcının yerleşik yeniden bağlanmasıyla çakışır.

HTTP/1.1'de birden fazla akış açmak. Altıncı bağlantıdan sonra sayfanın normal API çağrıları bile sıraya girer.

Küçük Bir Deneme

★☆☆ Endpoint'teki ilk heartbeat satırını kaldır, boş bir siparişe curl -i -N ile bağlan ve header'ların ilk heartbeat'e (15 saniye) kadar gelmediğini kendin gör.

★★☆ OrderEventLog'a "son 100 event" sınırı ekle. İstemci log'da artık olmayan bir Last-Event-ID ile gelirse reset tipinde bir event gönder ve Angular tarafında bu event gelince listeyi HTTP ile baştan yükle.

★★★ streamWithFetch'e id: ve retry: alanlarını işleme, \r\n satır sonlarını destekleme ve bağlantı koptuğunda Last-Event-ID header'ıyla yeniden bağlanma ekle. Nginx'i yeniden başlatarak test et.

Bir sonraki yazıda bu iki seçeneğin üstüne kurulmuş framework'e geçiyoruz: ASP.NET Core SignalR'da hub, strongly typed hub, transport negotiation ve Angular'dan @microsoft/signalr ile bağlanmak.