Skip to main content
Uygulama Geliştirme•24 dk okuma

Flutter'da LLM Yanıtını Stream Etmek: Dio ile Kendi SSE İstemcinizi Yazmak

Mustafa Kürşad BaşerMustafa Kürşad BAŞER
•
Flutter'da LLM Yanıtını Stream Etmek: Dio ile Kendi SSE İstemcinizi Yazmak

SSE kolay, zor olan aradaki katmanlar

Özet (TL;DR)

  • SSE parser'ı baytlarla değil satırlarla çalışır. Önce Utf8Decoder stream'i decode eder, sonra LineSplitter satırlara böler. Her parçaya ayrı ayrı utf8.decode çağırmak Türkçe harfi ikiye böler.
  • Dio'nun receiveTimeout değeri stream modunda iki parça arasındaki sessizliği ölçer. Başlıklar geldikten sonra ilk parça hiç gelmezse sayaç hiç başlamaz, ilk token için ayrı bir sınır gerekir.
  • async* içinde await for ile yazılmış bir transformer, iptali kaynaktan bir sonraki olay gelene kadar bekletir. Ağ sustuğu sürece soket de açık kalır.
  • Bağlantının kopması ile kullanıcının durdurması ayrı olaylardır. Üretimi bağlantıya değil mesaja bağlayın, durdurmayı ayrı bir istekle gönderin, kopan stream'e Last-Event-ID ile dönün.
  • nginx 1.29.8 ile yapılan ölçümde varsayılan tamponlama olayları tek başına geciktirmedi. text/event-stream için gzip açılınca bütün yanıt en sona yığıldı. X-Accel-Buffering: no ikisini birden çözdü.

Model yanıt üretiyor, kelimeler ekranda birer birer beliriyor ve bir yerde ��alışıyor yazıyor. Bir sonraki denemede aynı cümle tertemiz geliyor. Başka bir gün uygulama yirmi saniye boş ekran gösteriyor, sonra yanıtın tamamı tek seferde düşüyor. İki durumda da suç protokolde değil. Sorun ya istemcinin baytları nasıl okuduğunda ya da sunucuyla telefon arasındaki bir katmanda.

LayeredMindAI Mobile'da sohbet yanıtları Server-Sent Events ile stream ediliyor ve istemci hazır bir SSE paketi değil, doğrudan Dio üzerine yazıldı. Aşağıda önce protokolü, sonra Dart'ta bağımsız bir parser'ı, en son da mobil ağın ve sunucunun ona neler yaptığını anlatıyorum. Örnekler Dart 3.10, Dio 5.9.2 ve küçük bir Node sunucusuyla çalıştırıldı.

Protokol bir sayfaya sığar

SSE, WHATWG HTML standardının 9.2 numaralı bölümünde tanımlı ve kuralları bir solukta sayılır. Stream her zaman UTF-8'dir, başka bir karakter kodlaması seçmenin yolu yoktur. Satırlar CRLF, tek başına LF ya da tek başına CR ile biter. Boş satır olayı dispatch eder, yani tamamlayıp dinleyiciye iletir. İki noktayla başlayan satır yorumdur. Geri kalan her satır alan: değer biçimindedir ve iki noktadan sonraki tek bir boşluk değere dahil edilmez, ikinci boşluk edilir.

AlanNe yaparGözden kaçan ayrıntı
dataDeğeri veri tamponuna ekler, arkasına LF koyarÇok satırlı veri art arda data: satırlarıdır. Dispatch sırasında sondaki tek LF silinir.
eventOlay türünü belirlerBoş bırakılırsa tür message olur.
idSon olay kimliği tamponunu ayarlarNULL karakteri içeren değer yok sayılır. Değersiz id kimliği sıfırlar.
retryYeniden bağlanma süresini milisaniye olarak ayarlarYalnızca ASCII rakamlarsa kabul edilir. 5s sessizce yok sayılır.
: (yorum)Hiçbir şeyHeartbeat için ideal, istemcide olay üretmez.

Kendi parser'ınızı yazarken en sık atlanan üç kural var. İlki, id değeri olay tamamlanmadan kesinleşmez. Sunucu id: 8 yazıp boş satırı gönderemeden bağlantı koparsa yeniden bağlanırken bildirilecek kimlik hâlâ 7'dir. İkincisi, stream boş satırla bitmeden kapanırsa yarım olay dispatch edilmez, atılır. Üçüncüsü, alan adları büyük-küçük harfe duyarlıdır. Data: ile başlayan satır hiçbir şey ifade etmez.

Yeniden bağlanan istemci son kimliği Last-Event-ID başlığıyla sunucuya bildirir. Tarayıcıdaki EventSource bunu kendiliğinden yapar, 200 dışında bir durum kodu ya da text/event-stream olmayan bir içerik türü görünce de bağlantıyı kalıcı olarak kapatır. Sunucu yeniden bağlanmayı kesmek isterse 204 No Content döner.

Neden hazır bir paket değil

EventSource yalnızca bir URL ve withCredentials seçeneği alır. Yöntem her zaman GET'tir, gövde gönderemez, Authorization başlığı ekleyemezsiniz. LLM sohbeti ise doğası gereği bir POST: prompt, konuşma kimliği, model ayarları ve ekler gövdede gider. GET ile POST arasındaki anlam farkı yeniden bağlanırken de önemli.

Otomatik yeniden bağlanma GET için zararsızdır. Aynı davranışı bir POST'a uygularsanız her kopuşta prompt'u baştan gönderir ve sunucuda ikinci, üçüncü bir üretim başlatırsınız. Bu hem boşa yakılan token hem de ekranda birbirine karışan iki yanıt demek. Yeniden bağlanma, hangi mesaja hangi kimlikten devam edileceğini bilen ve tekrarlanmasında sakınca olmayan ayrı bir istek olmalı.

LayeredMindAI Mobile'da kimlik doğrulama, token yenileme, hata normalleştirme ve loglama zaten Dio interceptor'larında duruyor, SSL pinning de aynı istemcide. Kendi HTTP istemcisini getiren bir SSE paketi bunların hepsinin yanından geçer. Stream aynı Dio istemcisinden geçince süresi dolmuş bir access token stream'de de diğer isteklerdeki gibi ele alınır. Geriye üç iş kalıyor: CancelToken ile iptal, durum kodlarını kendi kurallarınızla yorumlamak ve baytları olaylara çeviren küçük bir parser.

Parser: satırdan olaya

Parser'ı Dio'dan ve Flutter'dan bağımsız tutmak işin yarısı. Satır alır, olay döndürür, saf Dart olduğu için dart test ile saniyeler içinde sınanır.

dart
import 'dart:convert'; class SseEvent {  const SseEvent(this.type, this.data, this.id);  final String type;  final String data;  final String id;   @override  String toString() => 'SseEvent($type, ${jsonEncode(data)}, id=$id)';} /// WHATWG HTML §9.2.6'daki yorumlama kurallarının satır düzeyindeki hâli.class SseParser {  final _data = StringBuffer();  var _type = '';  var _idBuffer = '';   /// Yalnızca bir olay dağıtıldığında güncellenir. Yeniden bağlanırken  /// `Last-Event-ID` başlığına bu değer yazılır.  String lastEventId = '';   /// Sunucu `retry:` gönderdiyse milisaniye cinsinden bekleme süresi.  int? retryMs;   SseEvent? addLine(String line) {    if (line.isEmpty) return _dispatch();    if (line.startsWith(':')) return null; // yorum, heartbeat     final colon = line.indexOf(':');    final field = colon == -1 ? line : line.substring(0, colon);    var value = colon == -1 ? '' : line.substring(colon + 1);    if (value.startsWith(' ')) value = value.substring(1);     switch (field) {      case 'data':        _data          ..write(value)          ..write('\n');      case 'event':        _type = value;      case 'id':        if (!value.contains('\u0000')) _idBuffer = value;      case 'retry':        if (RegExp(r'^[0-9]+$').hasMatch(value)) retryMs = int.parse(value);    }    return null;  }   SseEvent? _dispatch() {    lastEventId = _idBuffer;    if (_data.isEmpty) {      _type = '';      return null;    }    var data = _data.toString();    if (data.endsWith('\n')) data = data.substring(0, data.length - 1);    final event = SseEvent(_type.isEmpty ? 'message' : _type, data, lastEventId);    _data.clear();    _type = '';    return event;  }} /// Bayt akışını olay akışına çevirir.Stream<SseEvent> sseEvents(Stream<List<int>> bytes, [SseParser? parser]) {  final p = parser ?? SseParser();  return const LineSplitter()      .bind(const Utf8Decoder(allowMalformed: true).bind(bytes))      .expand((line) {        final event = p.addLine(line);        return event == null ? const <SseEvent>[] : [event];      });}

_idBuffer ile lastEventId ayrımı standarttaki kimlik kuralının karşılığı. Tampon her id: satırında değişir ama dışarıya yalnızca boş satırda, yani olay tamamlanınca yansır. lastEventId dispatch'ten sonra sıfırlanmaz, bu yüzden kendi kimliği olmayan bir olay bir öncekinin kimliğini taşır. Testlerin bir kısmı doğrudan standarttaki örnek stream'lerden geliyor. En işe yarayanı, Türkçe bir satırı mümkün olan her bayt sınırından ikiye bölüp aynı sonucu bekleyen test:

dart
  test('Türkçe metin her bayt sınırından bölünse de bozulmaz', () async {    final bytes = b('data: {"t":"Çalışıyor, ğüşİı"}\n\n');    for (var cut = 1; cut < bytes.length; cut++) {      final events = await parse([bytes.sublist(0, cut), bytes.sublist(cut)]);      expect(events.single.data, '{"t":"Çalışıyor, ğüşİı"}', reason: 'kesim $cut');    }  });   test('id yalnızca olay tamamlanınca kesinleşir', () async {    final p = SseParser();    await parse([b('id: 7\ndata: a\n\nid: 8\ndata: yarım')], p);    expect(p.lastEventId, '7');  });
bash
$ dart test00:00 +11: All tests passed!

Bayt sınırı harf sınırı değildir

Ağ baytları istediği yerden bölünmüş teslim eder. UTF-8'de ç iki bayttır (C3 A7) ve TCP bu iki baytı ayrı parçalara koymakta hiçbir sakınca görmez. Deneme sunucusu her olayı iki yazmaya böldüğünde Dio'nun verdiği 32 parçanın 11'i bir harfin ortasında bitti. Her parçayı ayrı decode eden kod bunu şöyle yaşar:

dart
import 'dart:convert'; void main() {  final bytes = utf8.encode('data: Şimdi çalışıyor\n\n');  // Ağ, "ç" harfinin iki baytını iki ayrı parçaya bölmüş olsun.  final cut = bytes.indexOf(0xC3) + 1;  final chunks = [bytes.sublist(0, cut), bytes.sublist(cut)];  print('parçalar: ${chunks.map((c) => c.length).toList()}');   try {    print(chunks.map((c) => utf8.decode(c)).join());  } on FormatException catch (e) {    print('utf8.decode: ${e.message}');  }  print('allowMalformed: ${chunks.map((c) => utf8.decode(c, allowMalformed: true)).join().trim()}');}
bash
$ dart run bin/naive.dartparçalar: [14, 14]utf8.decode: Unfinished UTF-8 octet sequenceallowMalformed: data: Şimdi ��alışıyor
ç harfinin C3 ve A7 baytlarının iki ayrı TCP parçasına düştüğü, parça parça decode etmenin �� ürettiği, stream üzerinde decode etmenin ise çalışıyor kelimesini doğru verdiği şema
Aynı baytlar, iki farklı decode yolu

allowMalformed kapalıyken exception, açıkken iki replacement character (U+FFFD) alırsınız. Baytları String.fromCharCodes ile karaktere çevirmek de Türkçe karakter bozulmasının tanıdık görüntüsünü üretir. Çare, decoder'ı parçalara değil stream'in kendisine uygulamak. Utf8Decoder yarım kalan baytları bir sonraki parçaya saklar. Ardından gelen LineSplitter SSE'nin istediğiyle birebir örtüşür: CR, LF ve CRLF'yi satır sonu sayar, bir parçanın sonunda kalan CR'den sonra gelen LF'yi sonraki parçada yutar.

Parser'daki allowMalformed: true bilinçli bir seçim. Standart, stream'in UTF-8 decode algoritmasıyla decode edilmesini ister ve bu algoritma bozuk diziyi hata fırlatmadan replacement character'a çevirir. Aynı algoritma baştaki BOM'u da atar. Dart'ın decoder'ı bunu kendisi yapıyor, testlerden biri bunu da doğruluyor.

Uyarı

Dio'nun stream'i Stream<Uint8List> tipindedir. Onu Stream<List<int>> bekleyen bir fonksiyona verip içeride .transform(utf8.decoder) çağırırsanız derleyici ses çıkarmaz, ama çalışma zamanında type 'Utf8Decoder' is not a subtype of type 'StreamTransformer<Uint8List, String>' hatası alırsınız. Birim testler List<int> parçalarla yazıldığı için bunu yakalamaz. Utf8Decoder().bind(stream) biçimi bu tuzağa düşmez.

İptal neden üç saniye sürer

Parser'ı async* ile yazmak akla gelen ilk yol: await for ile satırları oku, olayları yield et. Bu sürüm bütün testleri geçer. Sorun iptalde ortaya çıkar. Kaynağın üç saniye sustuğu, dinleyicinin birinci saniyede vazgeçtiği bir deneme:

dart
import 'dart:async'; // Ağdan 3 saniye hiç bayt gelmiyor. Dinleyici 1. saniyede vazgeçiyor.Future<void> measure(String name, Stream<String> Function(Stream<String>) wrap) async {  final sw = Stopwatch()..start();  final source = StreamController<String>(      onCancel: () => print('$name: kaynak ${sw.elapsedMilliseconds} ms\'de kapandı'));  Timer(const Duration(seconds: 3), () => source.add('data: geç kalan'));  final sub = wrap(source.stream).listen((_) {});  await Future<void>.delayed(const Duration(seconds: 1));  await sub.cancel();  print('$name: cancel() ${sw.elapsedMilliseconds} ms\'de döndü');} Stream<String> withAsyncStar(Stream<String> lines) async* {  await for (final line in lines) {    yield line.toUpperCase();  }} Future<void> main() async {  await measure('async*', withAsyncStar);  await measure('expand', (s) => s.expand((line) => [line.toUpperCase()]));}
bash
$ dart run bin/iptal.dartasync*: kaynak 3006 ms'de kapandıasync*: cancel() 3008 ms'de döndüexpand: kaynak 1003 ms'de kapandıexpand: cancel() 1003 ms'de döndü

Bir async* generator'ı await for içinde beklerken subscription'ı iptal ederseniz generator kaynaktan bir sonraki olay gelene kadar uyanmaz ve iptal ancak o zaman tamamlanır. Kaynak bir HTTP yanıtıysa ağ sustuğu sürece soket açık kalır. Daha sinsi olanı şu: await for döngüsü bir hata aldığında önce subscription'ı iptal eder, hatayı ancak iptal bitince teslim eder. Stream'e iki saniyelik timeout koyduğunuzda TimeoutException üçüncü saniyede gelir. Aynı dönüşüm expand ile yazılınca iptal anında kaynağa ulaşıyor. Yukarıdaki sseEvents bu yüzden async* değil.

Dio ile bağlamak

dart
class ChatStream {  ChatStream(this.dio);   final Dio dio;  final parser = SseParser();  CancelToken? _cancel;  String? messageId;   Stream<SseEvent> start(String prompt) =>      _open('POST', '/chat', {'prompt': prompt});   /// Kopan ya da arka planda bırakılan akışa kaldığı yerden bağlanır.  Stream<SseEvent> resume() => _open('GET', '/chat/$messageId/stream', null);   /// Bağlantıyı bırakır ama üretimi durdurmaz.  void detach() => _cancel?.cancel('detach');   /// Kullanıcı "durdur"a bastı: önce ekranı serbest bırak, sonra sunucuya söyle.  Future<void> stop() async {    _cancel?.cancel('stop');    if (messageId != null) await dio.post<void>('/chat/$messageId/stop');  }   Stream<SseEvent> _open(String method, String path, Object? body) async* {    final cancel = _cancel = CancelToken();    final Response<ResponseBody> res;    try {      res = await dio.request<ResponseBody>(        path,        data: body,        cancelToken: cancel,        options: Options(          method: method,          responseType: ResponseType.stream,          headers: {            'Accept': 'text/event-stream',            if (parser.lastEventId.isNotEmpty) 'Last-Event-ID': parser.lastEventId,          },        ),      );    } on DioException catch (e) {      final data = e.response?.data;      if (e.type == DioExceptionType.badResponse && data is ResponseBody) {        // Akış modunda hata gövdesi de bir akıştır, okumadan göremezsiniz.        throw ChatHttpException(            e.response!.statusCode!, await utf8.decodeStream(data.stream));      }      rethrow;    }     final type = res.headers.value(Headers.contentTypeHeader) ?? '';    if (!type.startsWith('text/event-stream')) {      cancel.cancel();      throw ChatHttpException(res.statusCode ?? 0, 'beklenmeyen içerik türü: $type');    }    messageId ??= res.headers.value('x-message-id');    yield* sseEvents(res.data!.stream, parser);  }}

Koddan okunmayan üç ayrıntı var. Birincisi, ResponseType.stream ile yapılan istekte sunucu 401 dönerse Dio yine DioException fırlatır, ama e.response.data bir ResponseBody olur ve hata gövdesi de bir stream'dir. Okumazsanız sorunun süresi dolmuş token mı yoksa dolan kota mı olduğunu bilemezsiniz. İkincisi, içerik türü kontrol edilmeli. Yanlış yapılandırılmış bir proxy ya da bakım sayfası 200 ile HTML dönebilir ve parser bunu tek olay üretmeden sessizce yutar. Üçüncüsü, stream'in ortasında kopan bağlantı her zaman DioException olarak gelmiyor. Deneme sunucusu bağlantıyı yarıda kestiğinde Dio'nun IO adaptörü dart:io'nun HttpException'ını olduğu gibi iletti:

bash
== bağlantı koparsa[istemci +   44ms] olay id=0 "Yanıt "[istemci +   70ms] olay id=1 "parça "[istemci +  163ms] HttpException: HttpException: Connection closed while receiving data, uri = http://127.0.0.1:8787/chat?dropAfter=4&gap=30, 4 olay, lastEventId=3 == Last-Event-ID ile devam[istemci +    7ms] olay id=4 "ğ, "[istemci +   25ms] olay id=5 "ü, "[istemci +  324ms] olay id=15 "<done>"[istemci +  325ms] akış bitti, 12 olay: ğ, ü, ş, ı, İ, ö ve ç harfleri de sağlam.<done>

Sunucu kopuştan hemen önce id: 4 olayını yazmıştı ama olay istemciye ulaşmadı, lastEventId 3'te kaldığı için de resume() 4'ten devam etti. Hiçbir parça kaybolmadı, hiçbiri iki kez gelmedi.

Mobilde bağlantı sizin değil

Arka plana geçiş

iOS arka plana geçen uygulamayı kısa süre içinde askıya alır ve askıdaki uygulama soketten okuyamaz. Android'de süreç bir süre daha çalışabilir, ama Doze ve App Standby kısıtları devreye girince ağ erişimi kesilir, süreç de her an sonlandırılabilir. Kırk saniyelik bir yanıtın ortasında kullanıcının bir bildirime bakması yeter. Bağlantıyı açık bırakıp şansınızı deneyebilirsiniz ya da Flutter 3.13 ile gelen AppLifecycleListener ile uygulama gizlendiğinde bağlantıyı kendiniz bırakıp ön plana dönünce kaldığınız kimlikten devam edebilirsiniz. Ben ikincisini tercih ederim, çünkü o zaman kopuş ile arka plan aynı koda iner: detach() ve resume().

bash
== arka plana geçiş[istemci +   39ms] olay id=0 "Yanıt "[istemci +  136ms] olay id=1 "parça "[istemci +  136ms] uygulama arka plana geçti, bağlantı bırakıldı[istemci +  139ms] DioException.cancel, 2 olay, lastEventId=1 == 2 sn sonra ön plana dönüş[istemci +    8ms] olay id=2 "parça "[istemci +   14ms] olay id=3 "geliyor: "[istemci +   84ms] olay id=15 "<done>"[istemci +   86ms] akış bitti, 14 olay: parça geliyor: ğ, ü, ş, ı, İ, ö ve ç harfleri de sağlam.<done>

Bunun çalışması için sunucunun üretimi bağlantıya değil mesaja bağlaması şart. Deneme sunucusunda her istek bir iş başlatıyor ve bağlantı o işin yalnızca dinleyicisi. Bağlantı kopunca dinleyici ayrılıyor ama üretim sürüyor, yeni bağlantı da Last-Event-ID'den sonrasını tekrar oynatıp canlı stream'e katılıyor. Yukarıdaki örnekte iki saniyelik arada üretim bitmişti, dönüşte kalan on dört olay bir anda geldi.

Ağ değişimi

Wi-Fi'den hücresel ağa geçişte açık TCP bağlantısı yeni arayüze taşınmaz. Eski bağlantı kimi zaman hemen hata verir, kimi zaman tek kelime etmeden susar. Susan bağlantıyı yakalamanın güvenilir tek yolu sessizliği ölçmek. connectivity_plus gibi bir paketin bildirdiği ağ değişimini zaman aşımını beklemeden yeniden bağlanmak için tetik olarak kullanabilirsiniz, ama ağ değişmeden de bağlantı ölebilir.

Zaman aşımı

Dio'nun belgeleri receiveTimeout için iki şey söylüyor: bağlantı kurulup ilk yanıt baytları gelene kadar geçen süre ve aktarım sırasında iki bayt olayı arasındaki süre. Toplam süreden söz etmiyor. Stream'de bu tam aranan şey, çünkü üç dakika süren bir yanıt parçalar arasında uzun sessizlik olmadıkça kısa bir receiveTimeout ile de sorunsuz akar. Ölçünce yine de bir boşluk çıktı:

Senaryo (receiveTimeout 2 sn)Sonuç
Başlıklar hemen geliyor, ilk olay 3 sn sonraZaman aşımı yok, ilk olay 3044 ms'de geldi
Olaylar arası 3 sn2019 ms'de DioException.receiveTimeout
Olaylar arası 3 sn, saniyede bir heartbeatZaman aşımı yok, 45 sn'lik stream tamamlandı
Olay stream'ine .timeout(2 sn)2012 ms'de TimeoutException, sunucu bağlantının kapandığını gördü

Dio 5.9.2'nin stream'i işleyen kodu sayacı ilk veri parçasıyla kuruyor. Sunucu başlıkları hemen gönderip ilk token'ı bekletirse, ki model düşünürken olan tam olarak budur, aradaki süreyi hiçbir şey saymıyor. Bu yüzden iki ayrı sınır kullanmayı doğru buluyorum. receiveTimeout bağlantının canlı olup olmadığını ölçer ve heartbeat aralığının birkaç katı olur, olay stream'ine uygulanan Stream.timeout ise modelin cevap vermediğine karar verir. Yorum satırları olay üretmediği için heartbeat bu ikinci sayacı sıfırlamaz, olması gereken de bu.

Kullanıcı durdurduğunda

Durdur düğmesi iki iş yapar ve sıraları önemli. Önce CancelToken ile bağlantıyı bırakın ki arayüz hemen serbest kalsın, sonra sunucuya ayrı bir istekle üretimi kesmesini söyleyin. Yalnızca bağlantıyı kesmek yetmez, çünkü bu tasarımda kopuş bilerek üretimi durdurmuyor. Fark sunucu logunda iki satırla görünüyor:

bash
[sunucu +14401ms] m3: dinleyici ayrıldı, üretim sürüyor[sunucu +14403ms] POST /chat/m3/stop last-event-id=-[sunucu +14403ms] m3: kullanıcı durdurdu, üretim kesildi

İptal edilen stream DioException.cancel ile biter. Bunu arayüzde hata olarak göstermeyin. Kullanıcının kendi isteği.

Bilgi

Node'da istemcinin gidişini dinlerken req.on('close') değil res.on('close') kullanın. Gövdeli bir POST'ta req gövde okunduğu anda kapanır, deneme sunucusunda bu olay isteğin geldiği milisaniyede tetiklendi. Olay dinleyicisini yanıta bağlayın, yanıtın erken kapandığını da res.writableFinished değerinin false olmasından anlayın.

Sunucu ve nginx: tampon nerede

Sunucunun ilk işi başlıkları beklemeden göndermek: Content-Type: text/event-stream, Cache-Control: no-cache ve arkada nginx varsa X-Accel-Buffering: no. Node'da res.flushHeaders() başlıkları ilk token'ı beklemeden yollar. Aşağıdaki sunucu bu yazıdaki tasarımın özeti: üretim mesaja bağlı, bağlantı yalnızca dinleyici, durdurma ayrı bir istek.

javascript
import http from 'node:http'; const jobs = new Map(); // mesaj kimliği -> { events, done, listeners } function sse(req, res, job) {  res.writeHead(200, {    'Content-Type': 'text/event-stream',    'Cache-Control': 'no-cache',    'X-Accel-Buffering': 'no',    'X-Message-Id': job.id,  });  res.flushHeaders();  const send = (ev) => {    // Her olay tek write: heartbeat iki yazmanın arasına giremez.    res.write(`id: ${ev.id}\n${ev.event ? `event: ${ev.event}\n` : ''}data: ${JSON.stringify(ev.data)}\n\n`);    if (ev.event) res.end();  };  const last = Number(req.headers['last-event-id'] ?? -1);  job.events.filter((ev) => ev.id > last).forEach(send);  if (job.done) return;  job.listeners.add(send);  const hb = setInterval(() => res.write(': ping\n\n'), 15_000);  res.on('close', () => { // req.on('close') değil    clearInterval(hb);    job.listeners.delete(send); // üretim sürer, yalnızca dinleyici gider  });} function startJob(tokens) {  const job = { id: `m${jobs.size + 1}`, events: [], done: false, listeners: new Set() };  const emit = (ev) => { job.events.push(ev); job.listeners.forEach((l) => l(ev)); };  const timers = tokens.map((t, i) => setTimeout(() => emit({ id: i, data: { t } }), i * 100));  timers.push(setTimeout(() => { job.done = true; emit({ id: tokens.length, event: 'done', data: {} }); },    tokens.length * 100));  job.stop = () => {    timers.forEach(clearTimeout); // gerçek sunucuda: LLM isteğini AbortController ile kes    job.done = true;    emit({ id: job.events.length, event: 'stopped', data: {} });  };  jobs.set(job.id, job);  return job;} http.createServer(async (req, res) => {  let body = '';  for await (const chunk of req) body += chunk;  const [, id, action] = req.url.match(/^\/chat\/(m\d+)\/(stream|stop)$/) ?? [];  if (req.method === 'POST' && req.url === '/chat') {    return sse(req, res, startJob(['Merhaba, ', 'ğüşıöç ', 'tamam.']));  }  if (jobs.has(id) && action === 'stream') return sse(req, res, jobs.get(id));  if (jobs.has(id) && action === 'stop') jobs.get(id).stop();  res.writeHead(jobs.has(id) ? 204 : 404).end();}).listen(8787);
Sunucudan ekrana uzanan stream katmanları: Node sunucusu, nginx, mobil ağ, Dio, Utf8Decoder, LineSplitter ve SseParser, her birinin altında o katmana özgü tuzak
Bir token'ın ekrana kadar geçtiği katmanlar

nginx'te proxy_buffering varsayılan olarak açık. Belgelere göre açıkken yanıt tamponlara alınıyor, kapalıyken istemciye eşzamanlı aktarılıyor ve aynı ayar yanıt başlığındaki X-Accel-Buffering ile yanıt başına değiştirilebiliyor. nginx 1.29.8 ile yapılan ölçümde olaylar 150 ms arayla üretildi:

Yapılandırmaİlk olaySon olay
Varsayılan, başlık yok40 ms2291 ms
proxy_buffering off14 ms2266 ms
text/event-stream için gzip, başlık yok2271 ms2271 ms
gzip, X-Accel-Buffering: no13 ms2262 ms
gzip, proxy_buffering off10 ms2264 ms
nginx yok, Node'da gzip, flush yok2297 ms2298 ms
nginx yok, Node'da gzip, her olayda flush24 ms2260 ms

Tamponlama bu ölçümde olayları tek başına geciktirmedi. Sorun gzip ile birleşince çıktı. Sıkıştırıcı iyi oran için veri biriktirir, tamponlu modda nginx ona flush etmesini söylemez ve bütün yanıt en sona yığılır. Tamponlama kapanınca her parça anında sıkıştırılıp gönderiliyor. Aynı tuzak uygulama katmanında da var. Node'da gzip stream'ini her olaydan sonra flush() etmezseniz nginx olmadan da aynı tabloyu görürsünüz. nginx yalnızca text/html yanıtları kendiliğinden sıkıştırır, yani bu tablo biri gzip_types listesine text/event-stream eklediği gün ortaya çıkar. Kuralım basit. SSE yanıtı sıkıştırılmaz, sıkıştırılacaksa her olaydan sonra flush edilir ve X-Accel-Buffering: no her durumda gönderilir.

İki ayar daha önemli. proxy_read_timeout varsayılan olarak 60 saniye ve toplam süreye değil, iki okuma arasındaki süreye uygulanıyor. Standart eski proxy'lere karşı yaklaşık 15 saniyede bir yorum satırı göndermeyi öneriyor, bu aralık nginx'i de rahatlıkla canlı tutar. proxy_ignore_client_abort ise varsayılan olarak kapalı: telefon bağlantıyı bırakınca nginx de sunucuyla bağlantısını kapatır ve sunucunuz res.on('close') olayını alır. Heartbeat'i zamanlayıcıyla yazıyorsanız olay sınırına dikkat edin. Bir olayı birden fazla write ile gönderen kodda zamanlayıcı iki yazmanın arasına düşerse : ping satırı JSON'un ortasına girer. Deneme sunucusunun ilk hâli bu hatayı yaptı ve istemci FormatException: Unterminated string verdi.

Sonuç

Her kopuşu, her iptali ve her zaman aşımını tek bir soruya bağlayın: bu mesaj sunucuda hâlâ üretiliyor mu? Cevap evetse Last-Event-ID ile dönersiniz, hayırsa durdurmayı kullanıcı kendisi istemiştir. Bağlantıyı mesajın kendisi sanan bir tasarım mobil ağda er geç yanıt kaybeder. Parser ise işin en kolay kısmı, yeter ki baytları stream üzerinde decode edin ve iptali bekletmeyin.

Sıkça Sorulan Sorular

SSE yerine WebSocket kullansam bu sorunların hangisi ortadan kalkar?
Yalnızca bayt sınırı. WebSocket metin mesajını bütün olarak teslim eder. Arka plan, ağ değişimi ve susan bağlantı aynen kalır, üstelik kaldığı yerden devam etmek için Last-Event-ID gibi bir standart yoktur, kendi protokolünüzü yazarsınız. nginx'te de Upgrade ve Connection başlıklarını ayrıca iletmeniz gerekir. Tek yönlü bir token stream'i için WebSocket'in çift yönlülüğüne ihtiyaç yok.
Riverpod kullanıyorsam stream'i nereye bağlamalıyım?
Stream'i bir provider içinde başlatın ve ref.onDispose içinde detach() çağırın. Kullanıcı sohbet ekranından çıkınca bağlantı bırakılır ama sunucudaki üretim sürer, ekrana dönünce resume() ile kaldığı yerden devam edilir. stop() yalnızca durdur düğmesine bağlanmalı. Ekrandan çıkmak durdurmak anlamına gelmez.
Stream'i gerçek bir sunucu olmadan nasıl test ederim?
Dio'nun httpClientAdapter alanına kendi HttpClientAdapter sınıfınızı verin ve fetch içinden ResponseBody(Stream.fromIterable(parçalar), 200, headers: ...) döndürün. Parçaları bir harfin ortasından bölerseniz ChatStream'i interceptor'larıyla birlikte ağsız sınarsınız. Bu yazıdaki testlerden biri tam olarak bunu yapıyor. Parser ayrı durduğu için testlerin çoğu Dio'ya bile ihtiyaç duymaz.
Sunucunun gönderdiği `retry:` değerine uymalı mıyım?
Uyun ve onu alt sınır sayın. Standart, tarayıcılara başarısız denemeden sonra exponential backoff eklemeye ve işletim sistemi ağ olmadığını söylüyorsa ağın dönmesini beklemeye izin veriyor. Mobilde ikisini de yapın: her başarısız denemede bekleme süresini katlayın, ağ yokken hiç denemeyin, ağ dönünce sayacı sıfırlayın.

Kaynakça

Bu yazıyı paylaş

Mustafa Kürşad Başer
Yazar

Mustafa Kürşad Başer

Kıdemli Yazılım Mühendisi

Karmaşık sorunlara zarif çözümler üretmekten keyif alan, tutkulu bir yazılım mühendisi. Kodlamanın ötesinde, teknoloji, sanat ve insan bilincinin kesişim noktalarını keşfetmekle derinden ilgileniyor.