Angular'da Streaming Chat: fetch ile SSE Okumak, resource stream ve Signal'larla Token Token Render
EventSource POST gövdesi gönderemediği için LLM akışını Angular'da fetch, ReadableStream ve TextDecoderStream ile okuyup event'leri signal'lara yazıyoruz. Bu yazıda chunk sınırında bölünen UTF-8 karakterini, AbortController ile sunucuya kadar giden Durdur butonunu, requestAnimationFrame ile yarıya inen DOM yazımını, @for track hatasının her güncellemede element'i yeniden oluşturmasını ve resource stream'in akış sürerken bile resolved görünmesini ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0, Node.js 24) ve .NET 10 ile derlenip denenmiştir; tarayıcı ölçümleri Playwright 1.65 ile headless Chromium 158'de, model Ollama 0.34.4 (qwen3:4b) ile alınmıştır.
Önce Tahmin Et
Bookstore asistanının cevabı saniyede yaklaşık 40 parça hızla akıyor. Hızlı bir bulut sağlayıcı ise aynı süreye birkaç yüz parça sığdırabilir. Her delta event'inde mesajın metnini bir signal'a yazıyorsun. 300 delta'lık bir cevapta Angular metni DOM'a (Document Object Model; tarayıcının sayfayı tuttuğu element ağacı) kaç kez yazar? Ve ekranda iki tane kitap baloncuğu varken, akan cevabın <article> element'i kaç kez yeniden oluşturulur?
Bir de şu: sunucu Doğu kelimesini gönderiyor ama ağ paketi ğ harfinin iki baytının tam ortasından bölünüyor. Ekranda ne görürsün?
Önceki yazıda Bookstore asistanının (sitedeki serilerin ortak örnek uygulaması Bookstore, bir online kitapçı) mimarisini ve /chat/sse event sözlüğünü kurduk. Bu yazıda o akışı Angular'da okuyoruz: fetch ile bir SSE (Server-Sent Events; sunucunun açık bir HTTP cevabı üzerinden istemciye tek yönlü event göndermesi) parser'ı, event'leri signal'lara yazan bir store, "Durdur" butonu, otomatik scroll ve resource ile tek seferlik bir AI özelliği. Sorulara yazının ortasında ölçümle cevap vereceğiz.
EventSource Neden Yetmiyor?
Tarayıcının SSE için hazır bir API'si var: EventSource. Yeniden bağlanmayı, Last-Event-ID'yi ve satır ayrıştırmayı kendisi yapar. Real-time Uygulamalar serisinin "Server-Sent Events Derinlemesine" yazısı onu Angular'da bir Observable'a sarmayı anlatıyor. Ama EventSource'un constructor'ı yalnız bir URL ve withCredentials seçeneği alır: method seçemezsin, istek gövdesi gönderemezsin, Authorization gibi header ekleyemezsin. Bir chat isteğinde ise kullanıcının mesajını, ileride sohbet kimliğini ve ayarları gövdede göndermek istiyoruz; uzun bir mesajı query string'e koymak da hem URL sınırlarına hem de log'lara takılır.
Bu yüzden isteği fetch ile POST olarak atıp cevabın gövdesini ReadableStream olarak okuyacağız. Karşılığında EventSource'un bedava verdiği iki şeyi kendimiz yazmamız gerekiyor: event stream formatını ayrıştırmak ve bağlantı koparsa ne olacağına karar vermek. İkincisi 3. yazının konusu; burada parser'a bakıyoruz.
SSE'yi fetch ile Okumak
.NET ile Yapay Zeka serisinin streaming yazısındaki Node istemcisi \n\n'e göre bölen kısa bir okuyucu kullanıyordu. Angular service'ine taşırken onu WHATWG'nin (Web Hypertext Application Technology Working Group; HTML standardını yayımlayan grup) HTML standardındaki kurallara biraz daha yaklaştırdım: satır sonu LF (yeni satır karakteri, \n), tek CR (satır başı karakteri, \r) ya da ikisi art arda, yani CRLF olabilir, iki nokta üst üste ile başlayan satır yorumdur, alan adından sonraki tek boşluk atılır ve birden fazla data: satırı satır sonuyla birleştirilir.
export interface SseMessage {
event: string;
data: string;
}
// WHATWG event stream formatı: satır sonu CRLF, LF ya da CR olabilir; boş satır event'i gönderir
export async function* readSse(body: ReadableStream<BufferSource>): AsyncGenerator<SseMessage> {
// TextDecoderStream, chunk sınırında bölünen çok baytlı UTF-8 karakterini bir sonraki chunk'la birleştirir
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
let event = '';
let data: string[] = [];
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
// Boş satırla kapanmamış son event spec gereği atılır
return;
}
buffer += value;
// CRLF'in \r'si chunk sonunda kaldıysa \n'yi bekle; yoksa araya sahte bir boş satır girer
const cut = buffer.endsWith('\r') ? buffer.length - 1 : buffer.length;
const lines = buffer.slice(0, cut).split(/\r\n|\r|\n/);
buffer = lines.pop()! + buffer.slice(cut);
for (const line of lines) {
if (line === '') {
if (data.length > 0) {
yield { event: event || 'message', data: data.join('\n') };
}
event = '';
data = [];
continue;
}
if (line.startsWith(':')) {
continue; // yorum satırı
}
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let fieldValue = colon === -1 ? '' : line.slice(colon + 1);
if (fieldValue.startsWith(' ')) {
fieldValue = fieldValue.slice(1);
}
if (field === 'event') {
event = fieldValue;
} else if (field === 'data') {
data.push(fieldValue);
}
}
}
} finally {
// Tüketici döngüden erken çıkarsa (break, abort) bağlantıyı da kapat
await reader.cancel().catch(() => {});
}
}
Parser'ın en önemli satırı ilk satırı: pipeThrough(new TextDecoderStream()). Ağ, cevabı istediği yerden böler; bir chunk'ın bir karakterin ortasında bitmesi normaldir. Önce Tahmin Et'teki ikinci soruyu küçük bir Node betiğiyle denedim: Doğu içeren event'i ğ'nin (UTF-8'de C4 9F) ilk baytından sonra iki chunk'a böldüm ve önce her chunk'ı ayrı ayrı decode ettim, sonra readSse'ye verdim. Aynı betik CRLF'i tam ortadan bölen bir akışı, bir yorum satırını, iki satırlık bir data'yı ve boş satırla kapanmayan bir son event'i de deniyor:
$ node parser-check.ts
chunks: 31 + 6 bytes
naive : "event: delta\ndata: {\"text\":\"Do��u\"}\n\n"
readSse: delta {"text":"Doğu"}
readSse: delta "{\"text\":\"a\"}"
readSse: note "line 1\nline 2"
Chunk'ları tek tek new TextDecoder().decode() ile çözmek ğ'yi iki tane U+FFFD (geçersiz bayt yerine konan karakter) ile değiştiriyor; Türkçe, emoji ya da Çince içeren her cevapta ara ara bozuk karakter görürsün. TextDecoderStream yarım baytı bir sonraki chunk'a taşıdığı için readSse doğru metni veriyor. CRLF bölünmesi sahte bir boş satır üretmedi, yorum satırı atlandı ve son event'teki "lost" metni gelmedi: boş satırla kapanmayan event'i spec gereği atıyoruz. Bu son davranış bilinçli; akış yarıda kesildiyse yarım bir JSON'u parse etmeye çalışmaktansa son event'i kaybetmek daha güvenli.
Bir TypeScript notu: parametreyi önce ReadableStream<Uint8Array> olarak yazmıştım ve Angular 22.2'nin kullandığı TypeScript 6.0 build'i durdurdu: TS2345: Argument of type 'TextDecoderStream' is not assignable to parameter of type 'ReadableWritablePair<string, Uint8Array<ArrayBufferLike>>'. TextDecoderStream'in yazılabilir tarafı BufferSource bekliyor; parametreyi ReadableStream<BufferSource> yapınca response.body sorunsuz geçiyor.
Tiplenmiş Event'ler: ChatApi
Parser ham event ve data veriyor; 1. yazıdaki toChatEvent bunları tiplenmiş ChatEvent'e çeviriyor. İkisini birleştiren service:
import { Injectable } from '@angular/core';
import { ChatEvent, toChatEvent } from './chat-events';
import { readSse } from './sse';
@Injectable({ providedIn: 'root' })
export class ChatApi {
// EventSource yalnız GET atar ve gövde gönderemez; mesajı POST ile göndermek için fetch kullanıyoruz
async *stream(message: string, signal: AbortSignal): AsyncGenerator<ChatEvent> {
const response = await fetch('/chat/sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ message }),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Chat request failed with HTTP ${response.status}`);
}
for await (const { event, data } of readSse(response.body)) {
const chatEvent = toChatEvent(event, data);
if (chatEvent) {
yield chatEvent;
}
}
}
}
AsyncGenerator seçmemin sebebi tüketicinin for await ile okuyabilmesi ve döngüden çıkınca (break, return ya da exception) generator'ın finally bloklarının zincir hâlinde çalışması: readSse'nin finally'si reader.cancel() ile bağlantıyı kapatıyor. response.ok kontrolü de önemli: SSE event'leri yalnız 200 cevabında gelir; 400, 429 ya da 500 bir JSON gövdesiyle gelir ve parser'a verilirse hiçbir event çıkmaz, akış sessizce "boş" biter. Bu hataları ayrıntılı olarak 3. yazıda ele alacağız.
Bir bedel de var: fetch'i doğrudan kullanınca Angular HttpClient'ın interceptor'ları devreye girmez. Uygulamanda token'ı bir interceptor ekliyorsa burada da aynı header'ı eklemen gerekir.
Signal'larla Token Token Render
Event'leri ekrana taşıyan store, mesaj listesini tek bir signal'da tutuyor. Angular 22'de yeni projeler zoneless ve component'ler varsayılan olarak OnPush; bir signal'ı güncellemek, onu template'te okuyan component'in yeniden render edilmesi için yeterli. async pipe'a, markForCheck'e ya da NgZone.run'a gerek yok.
import { Injectable, inject, signal } from '@angular/core';
import { ChatApi } from './chat-api';
export type MessageStatus = 'thinking' | 'streaming' | 'done' | 'stopped' | 'interrupted';
export interface ChatMessage {
id: number;
role: 'user' | 'assistant';
text: string;
status: MessageStatus;
}
export interface Usage {
inputTokens: number | null;
outputTokens: number | null;
}
@Injectable({ providedIn: 'root' })
export class ChatStore {
private readonly api = inject(ChatApi);
private nextId = 1;
private controller: AbortController | null = null;
private pendingText = '';
private frame = 0;
readonly messages = signal<ChatMessage[]>([]);
readonly busy = signal(false);
readonly usage = signal<Usage | null>(null);
async send(text: string): Promise<void> {
if (this.busy()) {
return;
}
const userId = this.nextId++;
const id = this.nextId++;
this.messages.update((list) => [
...list,
{ id: userId, role: 'user', text, status: 'done' },
{ id, role: 'assistant', text: '', status: 'thinking' },
]);
this.busy.set(true);
this.controller = new AbortController();
let finished = false;
try {
for await (const event of this.api.stream(text, this.controller.signal)) {
switch (event.type) {
case 'delta':
this.queueDelta(id, event.text);
break;
case 'usage':
this.usage.set({ inputTokens: event.inputTokens, outputTokens: event.outputTokens });
break;
case 'done':
finished = true;
this.flush(id);
this.patch(id, { status: 'done' });
break;
case 'error':
finished = true;
this.flush(id);
this.patch(id, { status: 'interrupted' });
break;
}
}
// done gelmeden kapanan bağlantı "bitti" değil, "kesildi" demektir
if (!finished) {
this.flush(id);
this.patch(id, { status: 'interrupted' });
}
} catch (err) {
this.flush(id);
const stopped = err instanceof DOMException && err.name === 'AbortError';
this.patch(id, { status: stopped ? 'stopped' : 'interrupted' });
} finally {
this.busy.set(false);
this.controller = null;
}
}
stop(): void {
this.controller?.abort();
}
// Delta'ları biriktirip ekrana kare başına bir kez yaz
private queueDelta(id: number, text: string): void {
this.pendingText += text;
if (this.frame === 0) {
this.frame = requestAnimationFrame(() => this.flush(id));
}
}
private flush(id: number): void {
cancelAnimationFrame(this.frame);
this.frame = 0;
if (this.pendingText === '') {
return;
}
const text = this.pendingText;
this.pendingText = '';
this.messages.update((list) =>
list.map((m) => (m.id === id ? { ...m, text: m.text + text, status: 'streaming' } : m)),
);
}
private patch(id: number, change: Partial<ChatMessage>): void {
this.messages.update((list) => list.map((m) => (m.id === id ? { ...m, ...change } : m)));
}
}
Asistan mesajı thinking durumunda doğuyor, ilk metinle streaming'e geçiyor ve üç sondan birine varıyor: done (sunucu done gönderdi), stopped (kullanıcı durdurdu) ya da interrupted (sunucu error gönderdi, bağlantı done'sız kapandı ya da ağ hatası oldu). ping ve thinking event'lerini store yok sayıyor; mesaj zaten thinking durumunda başlıyor. Liste güncellemeleri immutable: değişen mesaj için yeni bir object, diğerleri için aynı referans. Bu ayrıntı birazdan @for'un track ifadesiyle önem kazanacak.
Bu store'un bilerek eksik bıraktığı şeyler var: ikinci bir send çağrısı sessizce yok sayılıyor, hata türleri ayrılmıyor, 429 ve timeout yok. 3. yazıda bunu açık bir state machine'e çevireceğiz.
Component: Durdur Butonu ve Otomatik Scroll
import { Component, ElementRef, afterRenderEffect, inject, signal, viewChild } from '@angular/core';
import { ChatStore } from './chat-store';
@Component({
selector: 'app-assistant',
templateUrl: './assistant.html',
styleUrl: './assistant.css',
})
export class Assistant {
protected readonly store = inject(ChatStore);
protected readonly draft = signal('');
private readonly log = viewChild.required<ElementRef<HTMLElement>>('log');
private stickToBottom = true;
constructor() {
// DOM güncellendikten sonra çalışır: kullanıcı yukarı kaydırmadıysa en alta in
afterRenderEffect(() => {
this.store.messages();
const el = this.log().nativeElement;
if (this.stickToBottom) {
el.scrollTop = el.scrollHeight;
}
});
}
protected onScroll(): void {
const el = this.log().nativeElement;
this.stickToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
protected submit(event: Event): void {
event.preventDefault();
const text = this.draft().trim();
if (text === '') {
return;
}
this.draft.set('');
this.stickToBottom = true;
void this.store.send(text);
}
}
<section class="log" #log (scroll)="onScroll()">
@for (message of store.messages(); track message.id) {
<article class="bubble" [class.user]="message.role === 'user'">
@if (message.status === 'thinking') {
<p class="status">Thinking…</p>
}
<p class="text">{{ message.text }}</p>
@if (message.status === 'stopped') {
<p class="status">Stopped.</p>
} @else if (message.status === 'interrupted') {
<p class="status error">The answer was interrupted.</p>
}
</article>
} @empty {
<p class="hint">Ask about a book, an order or the store.</p>
}
</section>
<form (submit)="submit($event)">
<input #input [value]="draft()" (input)="draft.set(input.value)" placeholder="Ask the Bookstore assistant" />
@if (store.busy()) {
<button type="button" (click)="store.stop()">Stop</button>
} @else {
<button type="submit" [disabled]="draft().trim() === ''">Send</button>
}
</form>
@if (store.usage(); as usage) {
<p class="usage">Last answer: {{ usage.inputTokens }} input / {{ usage.outputTokens }} output tokens</p>
}
Otomatik scroll'un kuralı basit: kullanıcı en alttaysa cevap aktıkça en altta kal, yukarı kaydırıp eski bir mesajı okuyorsa onu aşağı çekme. afterRenderEffect DOM güncellendikten sonra çalıştığı için scrollHeight yeni metni zaten içeriyor. Akış sürerken "Gönder" yerine "Durdur" çıkıyor; bu hem iptal yolunu gösteriyor hem de ikinci bir mesajın aynı anda gönderilmesini arayüz seviyesinde engelliyor.
Ekranı Playwright ile gerçek modele karşı çalıştırdım. Bir MutationObserver son asistan baloncuğunun metin uzunluğunu ve durum satırını her değişimde kaydetti:
$ node run02-chat.mjs full
records: 65 first text at 4851 ms, last at 6302 ms
first 6: [{"t":122,"len":0,"status":"Thinking…"},{"t":4851,"len":3,"status":""},{"t":4885,"len":13,"status":""},
{"t":4902,"len":14,"status":""},{"t":4918,"len":16,"status":""},{"t":4952,"len":19,"status":""}]
answer: For teenagers, *The Hobbit* by J.R.R. Tolkien is a perfect starting point. It offers thrilling adventure, ...
usage : Last answer: 62 input / 272 output tokens
# sunucu log'u
Stream closed: deltas=64, firstDelta=4744 ms, elapsed=6208 ms, aborted=False
122. milisaniyede "Thinking…" göründü ve kullanıcı 4,7 saniye boyunca boş bir ekran yerine bir durum gördü. İlk metin 4851. milisaniyede geldi, cevap 1,5 saniyede aktı. İki şey not edelim: model "iki cümle" istememe rağmen üç cümle yazdı ve kitap adını *The Hobbit* diye markdown ile işaretledi; ekranda yıldızlar düz metin olarak duruyor. Markdown'ı güvenle render etmek 4. yazının konusu.
Sonra 300 kelimelik bir hikâye isteyip metin 80 karakteri geçince "Stop"a bastım:
$ node run02-chat.mjs stop
after Stop: "Whiskers, a tabby cat with eyes like old leather, slept on the second-floor shelves of *\n\nStopped."
# sunucu log'u
Stream closed: deltas=24, firstDelta=15301 ms, elapsed=15836 ms, aborted=True
controller.abort() fetch bağlantısını kapattı, ASP.NET Core bunu HttpContext.RequestAborted üzerinden endpoint'in CancellationToken'ına taşıdı ve sunucu 24. delta'da akışı kapattı (aborted=True). Token'ın modele kadar gidip Ollama'yı da durdurduğunu .NET ile Yapay Zeka serisinin streaming yazısında ölçmüştük. Ekranda yarım metin kaldı ve altında "Stopped." yazıyor; kullanıcı neyi kendisinin kestiğini biliyor.
requestAnimationFrame ve @for track: Ölçüm
Şimdi Önce Tahmin Et'in ilk sorusu. Gerçek modelde 64 delta için 65 kayıt var: bir "Thinking…" ve her delta için bir metin değişikliği. Delta'lar yaklaşık 23 milisaniye arayla geliyor, bu da bir ekran karesinden (60 Hz'te 16,7 ms) uzun; yani requestAnimationFrame ile toplamanın bu modelde kazandırdığı bir şey yok. Hızlı bir sağlayıcıyı taklit etmek için Playwright'ta fetch'i, her biri ayrı chunk'ta ve aralarında 2 milisaniye olan 300 delta üreten sahte bir akışla değiştirdim. Store'un ve template'in dört varyantında metnin DOM'a kaç kez yazıldığını ve kaç <article> eklendiğini saydım:
| Varyant | Metin güncellemesi | Eklenen article | Akış süresi |
|---|---|---|---|
requestAnimationFrame + track message.id | 91 | 2 | 1542 ms |
Her delta'da yaz + track message.id | 183 | 2 | 1570 ms |
requestAnimationFrame + track message | 90 | 91 | 1551 ms |
Her delta'da yaz + track message | 184 | 185 | 1583 ms |
Üç sonuç var. Birincisi, requestAnimationFrame olmadan bile 300 delta 300 render'a dönüşmedi, 183'e indi. Zoneless Angular aynı task içindeki signal değişikliklerini tek bir change detection'da birleştiriyor; tarayıcı birkaç event'i aynı chunk'ta verdiğinde onlar zaten tek seferde yazılıyor. İkincisi, requestAnimationFrame yazma sayısını ekranın yenilenme hızına, yaklaşık 1,5 saniyede 90'a indirdi; görünür bir fark yok ama yarı yarıya daha az iş var. Bu fark 4. yazıda her güncellemede markdown parse etmeye başladığımızda çok daha önemli olacak. Üçüncüsü ve en önemlisi track. track message object kimliğine göre izler; immutable güncellemede akan mesaj her seferinde yeni bir object olduğu için Angular onun <article>'ını her güncellemede silip yeniden oluşturdu: 1,5 saniyede 91 element. Kullanıcının seçtiği metin, açık bir menü, bir input'taki odak her güncellemede kaybolur. track message.id ile yalnız iki <article> eklendi (kullanıcı ve asistan) ve akış boyunca aynı element'in metni güncellendi.
resource stream ile Tek Seferlik AI Özelliği
1. yazıda AI arayüzünün yalnız chat kutusu olmadığını söylemiştik. Kitap detay sayfasında "bu kitabı neden okumalısın" cümlesini akan bir metin olarak göstermek tek istek, tek değer olan bir iş; burada mesaj listesi gerekmiyor. Angular 22'de stable olan resource'un stream seçeneği bu iş için yazılmış: loader bir Signal<ResourceStreamItem<T>> döner ve her yeni parça o signal'a { value } ya da { error } olarak yazılır.
import { Component, ResourceStreamItem, inject, input, resource, signal } from '@angular/core';
import { ChatApi } from '../assistant/chat-api';
export interface Pitch {
text: string;
done: boolean;
}
@Component({
selector: 'app-book-pitch',
template: `
@if (pitch.error(); as error) {
<p class="error">No summary right now: {{ error.message }}</p>
} @else if (pitch.hasValue()) {
<p>{{ pitch.value().text }}@if (!pitch.value().done) {<span class="cursor">▍</span>}</p>
} @else {
<p class="muted">Generating a summary…</p>
}
`,
})
export class BookPitch {
readonly title = input.required<string>();
private readonly api = inject(ChatApi);
// Tek istek, tek değer: kitap değişince önceki akış abortSignal ile iptal edilir
protected readonly pitch = resource({
params: () => ({ title: this.title() }),
stream: async ({ params, abortSignal }) => {
const result = signal<ResourceStreamItem<Pitch>>({ value: { text: '', done: false } });
const prompt = `In one sentence, why should someone read "${params.title}"?`;
void (async () => {
let text = '';
try {
for await (const event of this.api.stream(prompt, abortSignal)) {
if (event.type === 'delta') {
text += event.text;
result.set({ value: { text, done: false } });
} else if (event.type === 'done') {
result.set({ value: { text, done: true } });
} else if (event.type === 'error') {
result.set({ error: new Error(event.message) });
}
}
} catch (err) {
// Kitap değiştiği için iptal edildiyse hata gösterme
if (!abortSignal.aborted) {
result.set({ error: err instanceof Error ? err : new Error(String(err)) });
}
}
})();
return result;
},
});
}
Değerin tipine done alanını bilerek koydum. Sebebi, resource'un status'unu akış boyunca Playwright'ta ng.getComponent ile örneklediğimde gördüğüm şey:
{"t":58,"status":"resolved","isLoading":false,"hasValue":true,"text":0,"done":false}
{"t":5907,"status":"resolved","isLoading":false,"hasValue":true,"text":2,"done":false}
{"t":6535,"status":"resolved","isLoading":false,"hasValue":true,"text":169,"done":true}
text: "Dune" offers a groundbreaking blend of political strategy, ecological philosophy, and intricate world-building ...
Loader signal'ı döndürdüğü anda, yani 58. milisaniyede ve henüz tek kelime gelmeden resource resolved oldu ve isLoading false'a döndü. Resource akışın ne zaman bittiğini bilmiyor; ona göre değer "çözüldü" ve sonra değişiyor. "Yükleniyor" göstergesini isLoading()'e bağlarsan gösterge model düşünürken kaybolur. Akışın bittiğini değerin içinde taşımak gerekiyor; burada done alanı ve imleç (▍) bunu yapıyor.
abortSignal'ın işini görmek için akış sürerken kitap değiştirdim:
$ node run02-pitch.mjs switch
{"t":7349,"status":"resolved","isLoading":false,"hasValue":true,"text":5,"done":false}
{"t":7503,"clicked":"Emma"}
{"t":7804,"status":"resolved","isLoading":false,"hasValue":true,"text":0,"done":false}
# sunucu log'u
Stream closed: deltas=11, firstDelta=7307 ms, elapsed=7533 ms, aborted=True
Route parametresi değişince params yeni değer üretti, Angular önceki loader'ın abortSignal'ını iptal etti, fetch kapandı ve sunucu Dune akışını 11. delta'da bıraktı. Kodda unsubscribe, takeUntil ya da ngOnDestroy yok; component yok edildiğinde de aynı sinyal iptal ediliyor. Chat için bu yolu seçmememin sebebi ise yapı: chat'te bir değer değil, büyüyen bir mesaj listesi, kullanıcı aksiyonları (durdur, yeniden üret) ve mesaj başına durum var. resource "parametre değişince yeniden yükle" modeline uyan işler için doğru araç.
Üretimde Dikkat
Akış başına bir bağlantı. HTTP/1.1'de tarayıcı aynı origin'e en fazla altı bağlantı açar; açık kalan her akış bunlardan birini tutar. Kullanıcı üç sekmede asistanı açtıysa diğer API çağrıları sırada bekleyebilir. Production'da HTTP/2 ya da HTTP/3 bu sınırı kaldırır; yine de sayfadan ayrılırken akışı iptal etmek iyi bir alışkanlık.
Render maliyeti cevap uzadıkça artar. Burada her güncelleme yalnız bir text node'u değiştiriyor ve ucuz. Markdown, kod renklendirme ya da kaynak gösterimi eklediğinde güncelleme başına iş büyür; ölçümü o zaman tekrarla.
Hata mesajını kullanıcıya olduğu gibi gösterme. fetch'in fırlattığı TypeError: Failed to fetch ya da sunucunun iç hata mesajı kullanıcıya bir şey söylemez. Burada yalnız "interrupted" gösterdik; hangi durumda ne denileceği 3. yazıda.
Odak ve ekran okuyucu. Her token'da değişen bir metin ekran okuyucu için ayrı bir sorun; aria-live ve odak yönetimini 7. yazıda ekleyeceğiz.
Sık Düşülen Tuzaklar
Birincisi, chunk'ları tek tek TextDecoder.decode() ile çözmek. Testte İngilizce cevaplar sorunsuz görünür, ilk Türkçe karakter bir chunk sınırına denk geldiğinde ekranda �� belirir. TextDecoderStream ya da decode(chunk, { stream: true }) kullan.
İkincisi, @for içinde object kimliğiyle track etmek. Immutable güncellemelerle birlikte akan mesajın DOM'u her güncellemede yeniden oluşturulur; ölçümde 1,5 saniyede 91 kez.
Üçüncüsü, response.ok'u kontrol etmeden gövdeyi SSE diye okumak. 429 ya da 500 cevabının JSON gövdesinden hiçbir event çıkmaz ve arayüz "boş bir cevap geldi" sanır.
Dördüncüsü, resource stream'inin isLoading()'ini akışın sürdüğü anlamında kullanmak. Loader signal'ı döndürünce status resolved oluyor; bitişi değerin içinde taşı.
Beşincisi, "Durdur"u yalnız arayüzde uygulamak: döngüden break ile çıkıp fetch'i iptal etmemek. Ekrandaki metin durur ama bağlantı ve sunucudaki model çalışmaya devam eder. AbortController sinyalini fetch'e vermek, sunucunun aborted=True log'unun sebebi.
Küçük Bir Deneme
★☆☆ Store'daki requestAnimationFrame yerine her delta'da flush çağır ve gerçek modelle bir cevap iste. Metin güncellemesi sayısı değişiyor mu? Sonra aynı denemeyi hızlı sahte akışla yap ve iki sonucu karşılaştır.
★★☆ readSse'ye id: alanını ekle ve her mesajda son event kimliğini döndür. Sunucu tarafında SseItem'a bir EventId vererek bunun uçtan uca geldiğini doğrula. Bu kimliği yeniden bağlanmada nasıl kullanırdın?
★★★ BookPitch'i resource yerine rxResource ve bir Observable ile yaz: ChatApi.stream'i bir Observable'a çevir, kitap değişince önceki aboneliğin iptal edildiğini sunucu log'unda gör. Hangi sürüm daha az kod ve daha az hata riski taşıyor?
Ekranda artık akan bir cevap ve çalışan bir "Durdur" var. Ama store hâlâ iyimser: bağlantı hiç kurulmazsa, ilk token 60 saniye gelmezse, sunucu 429 dönerse ya da akış ortasında kopar ve kullanıcı aynı soruyu yeniden üretmek isterse ne olacağı tanımlı değil. Bir sonraki yazıda chat'i açık bir state machine'e çeviriyor ve her hata türü için kullanıcıya ne gösterileceğine karar veriyoruz.