1. Gdzie w ogóle pojawiają się „strumienie” w architekturze ChatGPT App
Zanim zaczniemy spierać się, co jest lepsze — SSE czy HTTP-stream, warto zrozumieć, gdzie w naszym stosie w ogóle występują strumienie.
Umownie można wyróżnić trzy poziomy.
Po pierwsze, poziom ChatGPT i modelu. Sam model już strumieniuje odpowiedź tokenami: widzisz, jak tekst odpowiedzi „pisze się” po literach. To też jest strumień, ale w pełni zarządzany przez OpenAI i nie dotyczy bezpośrednio twojego kodu.
Po drugie, poziom MCP. Gdy ChatGPT łączy się z twoim serwerem MCP, zazwyczaj utrzymuje połączenie SSE: serwer wypycha do niego komunikaty MCP w formacie JSON‑RPC (odpowiedzi i powiadomienia), a ChatGPT w odpowiedzi wysyła żądania na osobny endpoint HTTP, na przykład /messages. W terminologii MCP to podstawowy transport.
Po trzecie, poziom Apps SDK i twojego backendu. Twój widżet React GiftGenius działa w sandboxie ChatGPT i komunikuje się z twoim backendem/MCP‑gateway przez HTTP: albo przez zwykły fetch, albo przez fetch ze strumieniem (ReadableStream), albo przez subskrypcję SSE (EventSource).
Ważne, aby nie mieszać tych poziomów. Zdarzenia MCP to „przewód” między ChatGPT a serwerami; a SSE/HTTP-stream między widżetem a twoim backendem HTTP — to już twój odcinek drogi.
Można narysować schemat.
flowchart TD
subgraph ChatGPT
UI[ChatGPT UI + model]
W[GiftGenius Widget]
end
subgraph YourInfra[Infrastruktura dewelopera]
GW[MCP Gateway / Backend]
MCP[MCP Server]
end
UI -- "tool-call / odpowiedzi\n(wewnętrzny strumień tokenów)" --> W
UI <-- "MCP over SSE\n(/sse + /messages)" --> MCP
W <-- "HTTP / fetch / SSE / stream" --> GW
GW <-- "JSON-RPC MCP" --> MCP
Dziś skupimy się na strzałce Widget ↔ Backend i częściowo przypomnimy, że transport MCP też opiera się na SSE.
Właśnie na tym odcinku — Widget ↔ Backend — musimy wybrać, jak się komunikować: prostymi żądaniami HTTP czy strumieniami. W następnym rozdziale zobaczymy, dlaczego „zwykłego” HTTP tutaj szybko zaczyna brakować.
2. Dlaczego zwykłe żądanie HTTP nie wystarcza
Standardowy model HTTP to „żądanie → jedna odpowiedź”. Klient o coś pyta, serwer odpowiada raz i połączenie się zamyka.
Do wielu zadań to wystarcza: pobrać bieżący status joba, zapisać ustawienia użytkownika, pobrać gotową listę prezentów, która już leży w bazie.
Ale gdy tylko robisz długą operację, wszystko zaczyna trzeszczeć.
Wyobraź sobie GiftGenius, który:
- zbiera sygnały z kilku źródeł (historia zakupów, wishlist, media społecznościowe),
- przepuszcza to przez kilka zapytań do LLM,
- buduje spersonalizowany ranking ze stu kandydatów.
To wszystko może zająć kilkadziesiąt sekund. Jeśli będziesz trzymać zwykłe żądanie HTTP przez 40 sekund i milczeć, UX będzie jak w starych przeglądarkach: użytkownik patrzy na kręcący się spinner i zgaduje, czy aplikacja padła, czy jeszcze „myśli”.
Poza UX są też problemy czysto techniczne:
- time‑outy po stronie ChatGPT, Vercel, proxy;
- brak możliwości wysyłania postępu, wyników cząstkowych itp.;
- brak możliwości poprawnego obsłużenia zerwania połączenia i przywrócenia stanu.
Stąd naturalne rozwiązanie: przejść od jednej dużej odpowiedzi do strumienia małych kawałków, które serwer może wysyłać w miarę gotowości.
Te kawałki mogą być:
- zdarzeniami (job.progress, job.completed) — to dotyczy SSE;
- fragmentami jednej dużej ładunku danych (tekst raportu, linie NDJSON z prezentami) — to dotyczy HTTP-stream.
3. SSE (Server‑Sent Events): subskrypcja zdarzeń
Zacznijmy od SSE, bo jest w pewnym sensie „bliższy” MCP: sam MCP nad HTTP używa połączenia SSE, aby wypychać zdarzenia od serwera do klienta.
Model SSE w skrócie
SSE to protokół nad zwykłym HTTP:
- klient otwiera żądanie GET na endpoint, który odpowiada nagłówkiem Content-Type: text/event-stream;
- serwer nie zamyka połączenia, tylko okresowo zapisuje tam linie w rodzaju:
event: job.progress
data: {"jobId":"123","percent":40}
event: job.completed
data: {"jobId":"123","resultCount":12}
- po stronie przeglądarki używamy EventSource, który:
- sam pilnuje ponownych połączeń;
- parsuje format event: + data: + podwójny znak nowej linii;
- wywołuje obsługi onmessage / addEventListener("job.progress", ...).
Kluczowy punkt: kanał jest jednokierunkowy. Tylko serwer wysyła zdarzenia do klienta. Klient tym połączeniem nie wysyła żadnych danych.
Dla ChatGPT Apps ten model świetnie pasuje, gdy widżet po prostu chce „zasubskrybować” zdarzenia po jobId i reagować na postęp oraz zakończenie zadania.
Mini‑przykład endpointu SSE w Next.js 16
Załóżmy, że mamy obsługę trasy dla zdarzeń postępu joba:
app/api/gift-jobs/[jobId]/events/route.ts
import { NextRequest } from "next/server";
export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
const jobId = params.jobId;
const stream = new ReadableStream({
start(controller) {
// Narzędzie do wysyłania zdarzenia SSE
const send = (event: string, data: unknown) => {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(payload));
};
send("job.started", { jobId });
let percent = 0;
const interval = setInterval(() => {
percent += 20;
if (percent >= 100) {
send("job.completed", { jobId, totalGifts: 10 });
clearInterval(interval);
controller.close();
} else {
send("job.progress", { jobId, percent });
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream", // to tutaj ustawiamy SSE
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
To zabawkowa imitacja: co sekundę rośnie procent i na końcu przychodzi job.completed. Później zastąpisz ten timer realnymi zdarzeniami workera/kolejki, ale sam schemat pozostanie ten sam.
Klient: subskrypcja SSE w widżecie GiftGenius
Wewnątrz widżetu React możemy zasubskrybować ten strumień, gdy mamy jobId. Przypomnę, że API widżetu działa w sandboxie ChatGPT, ale EventSource jest tam dostępny tak samo, jak w zwykłej przeglądarce.
import { useEffect, useState } from "react";
export function GiftJobProgress({ jobId }: { jobId: string }) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const url = `/api/gift-jobs/${jobId}/events`;
const es = new EventSource(url);
es.addEventListener("job.progress", (event) => {
const data = JSON.parse((event as MessageEvent).data);
setPercent(data.percent);
});
es.addEventListener("job.completed", () => {
setPercent(100);
es.close();
});
es.onerror = () => {
// tutaj można pokazać "Problemy z połączeniem, próbujemy ponownie się połączyć"
};
return () => es.close();
}, [jobId]);
return <div>Postęp doboru prezentów: {percent}%</div>;
}
Teraz możesz to powiązać z narzędziem MCP. Narzędzie start_gift_job zwraca jobId, a w ToolOutput twojego widżetu po prostu renderujesz GiftJobProgress.
Auto‑reconnect i Last‑Event‑ID
EventSource zgodnie ze standardem próbuje automatycznie ponownie łączyć, jeśli połączenie się rwie. Serwer może używać standardowego pola SSE id: w zdarzeniach, a klient — nagłówka Last-Event-ID, aby po ponownym połączeniu nadrobić pominięte zdarzenia.
Dla prostego GiftGenius możesz na razie nie implementować ani id:, ani osobnego identyfikatora zdarzeń i po prostu dopuścić niewielkie „dziury” w postępie przy reconnect. Ale na produkcji, zwłaszcza przy dużym obciążeniu, będzie ci potrzebne:
- dodawanie standardowego pola id: do każdego zdarzenia SSE, aby klient mógł przekazywać Last-Event-ID przy ponownym łączeniu;
- wprowadzenie aplikacyjnego event_id w payloadzie zdarzenia i opieranie się na nim przy idempotentnej obsłudze po stronie klienta/backendu.
To bezpośrednio łączy się z idempotentnością: nawet jeśli to samo job.progress przyjdzie dwa razy, obsługa, widząc znany event_id, nie wykona ponownie efektów ubocznych.
W rezultacie SSE daje nam wygodną subskrypcję zdarzeń wokół jobId z auto‑reconnectem i kontrolą duplikatów przez identyfikatory zdarzeń. Teraz rozpracujmy drugi typ strumieni — gdy mamy jedno żądanie, ale bardzo dużą odpowiedź, którą chcemy zwracać częściami.
4. HTTP‑streaming: odpowiadamy stopniowo na jedno żądanie
Jeśli SSE to „subskrypcja niezależnych zdarzeń”, to HTTP‑streaming to „jedno żądanie, jedna odpowiedź, ale odpowiedź rozciągnięta w czasie i przychodząca w chunkach”.
To dokładnie ten mechanizm, który widzisz, gdy używasz OpenAI API z stream : true: serwer wysyła chunki JSON (często w formacie SSE, ale logika jest taka: „jedno żądanie ↔ strumień częściowej odpowiedzi”), a klient składa je w finalny tekst.
W swoich API możesz zrobić to samo dla:
- dużych raportów tekstowych (np. wyjaśnienie logiki doboru prezentów),
- długich list prezentów (strumieniować je częściami zamiast kazać użytkownikowi czekać).
Najprostszy endpoint HTTP‑stream w Next.js
Załóżmy, że musimy wygenerować „wyjaśnienie” wyniku doboru, gdzie LLM pisze długi tekst. Chcemy strumieniować go do widżetu w miarę generowania.
app/api/gift-report/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode("Zaczynamy analizę...\n"));
// Tutaj mogłaby być prawdziwa generacja LLM z chunkami
for (const line of ["Zbieramy preferencje...\n", "Liczymy budżet...\n", "Ostateczne rekomendacje...\n"]) {
await new Promise((r) => setTimeout(r, 1000));
controller.enqueue(encoder.encode(line));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked", // Tutaj ustawiamy, że to HTTP/stream
},
});
}
Technicznie Next sam zarządza kodowaniem chunked; ważne, by zwracać ReadableStream.
Czytanie strumienia HTTP w widżecie przez fetch
Po stronie klienta (w widżecie) można odczytać strumień tak:
async function fetchReport(setText: (s: string) => void) {
const res = await fetch("/api/gift-report", { method: "POST" });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let acc = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
acc += decoder.decode(value, { stream: true });
setText(acc); // aktualizujemy UI w miarę napływu danych
}
}
A komponent‑wrapper:
import { useState } from "react";
export function GiftReport() {
const [text, setText] = useState("");
return (
<div>
<button onClick={() => fetchReport(setText)}>Wygeneruj raport</button>
<pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
</div>
);
}
To klasyczny wzorzec: jedno żądanie POST /api/gift-report, w odpowiedzi — strumień tekstu, który stopniowo wyświetlasz.
Strumieniujemy JSON, a nie tekst
Często będziesz chciał strumieniować nie ciągi znaków, lecz obiekty JSON. Najpopularniejszy format to NDJSON (Newline‑delimited JSON): każde zdarzenie — to jeden wiersz JSON zakończony znakiem \n.
Przykład po stronie serwera:
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for (let i = 0; i < 5; i++) {
const chunk = { type: "gift", index: i, name: `Prezent #${i}` };
controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
Klient czyta przez TextDecoder, dzieli po \n i parsuje pojedyncze obiekty JSON.
5. SSE vs HTTP‑stream: na czym polega różnica i jak wybierać
W tym momencie obraz powinien być już intuicyjny, ale i tak podsumujmy to w krótkiej tabeli.
| Cecha | SSE (Server‑Sent Events) | HTTP‑stream (chunked) |
|---|---|---|
| Inicjator | Klient wykonuje GET i subskrybuje | Klient wykonuje żądanie (GET/POST), serwer strumieniuje odpowiedź |
| Kierunek | Tylko serwer → klient | Odpowiedź serwera na konkretne żądanie |
| Semantyka | Subskrypcja strumienia zdarzeń (pub/sub) | Częściowa odpowiedź na jedno żądanie |
| Wbudowany protokół | Jest (event:, data:, id: itd.) | Brak, format wymyślasz sam (linie, NDJSON, JSON) |
| API po stronie klienta | EventSource | fetch + ReadableStream / response.body |
| Obsługa ponownego połączenia | Wbudowana (EventSource, Last-Event-ID) | Trzeba zaimplementować ręcznie |
| Typowe przypadki użycia | Postęp, statusy, powiadomienia dla jobId | Strumieniowanie tekstu, dużych odpowiedzi JSON, wyjścia LLM |
Uproszczone „zasady na szybko” (ostrożnie, bez fanatyzmu):
- masz job i mnóstwo zdarzeń wokół niego → SSE;
- jeden wywołanie narzędzia (tool) zwraca duży wynik, który chcesz pokazywać częściami → HTTP‑stream.
Dla GiftGenius będzie to oznaczać: SSE — dla żywego paska postępu i statusów doboru; HTTP‑stream — dla długiego podsumowania tekstowego lub dla stopniowego ładowania długiej listy prezentów.
6. Jak to łączy się z MCP i GiftGenius
Przypomnijmy nasz schemat z początku wykładu: model ↔ MCP ↔ widżet ↔ backend. Obejrzeliśmy już strumienie na poziomie widżet ↔ backend, a teraz wróćmy o krok i rozdzielmy dokładnie, gdzie w tej historii jest MCP, a gdzie — „po prostu HTTP”.
MCP określa, jak ChatGPT (jako klient MCP) komunikuje się z twoim serwerem MCP. Do tego jest transport, w którym:
- ChatGPT otwiera połączenie SSE /sse i otrzymuje po nim komunikaty MCP (odpowiedzi, powiadomienia, zdarzenia);
- ChatGPT wysyła żądania MCP (call_tool, list_tools itd.) na /messages, zwykle jako POST z JSON‑RPC.
Ten poziom przeszedłeś już, gdy podłączałeś GiftGenius do ChatGPT.
Teraz, gdy dodajemy zadania asynchroniczne i UX oparty na strumieniach w widżecie, pojawiają się dwa warianty architektury.
Wariant pierwszy — „czysty MCP”: serwer MCP sam generuje zdarzenia job.progress i job.completed; ChatGPT odbiera je przez MCP‑SSE; następnie model sam wywołuje twój widżet z odświeżonym kontekstem, a widżet renderuje postęp bez bezpośredniej komunikacji z backendem. To najbardziej „kanoniczna” ścieżka MCP‑events.
Wariant drugi — hybrydowy: narzędzie MCP start_gift_job tworzy zadanie i zwraca jobId; widżet otrzymuje jobId i dalej sam komunikuje się z backendem przez HTTP, subskrybując endpoint SSE /api/gift-jobs/{jobId}/events i w razie potrzeby pobierając strumień HTTP raportu. Po stronie MCP nie dzieje się przy tym nic szczególnego.
W kursie idziemy drogą hybrydową: lepiej wpasowuje się w App Router/Next i jest prostsza do lokalnego debugowania. Później możesz przechodzić na „czyste powiadomienia MCP”, gdy nabierzesz wprawy.
7. Reconnect, time‑outy i inne realia sieci
Brzmiało idealnie: otwieramy SSE albo strumień, wszystko płynie, zdarzenia nadlatują, UX błyszczy. W prawdziwym życiu sieć lubi zrywać połączenia w nieoczekiwanych momentach, a infrastruktura — ustawiać time‑outy.
Co może pójść nie tak
Z SSE i HTTP-stream prędzej czy później zetkniesz się z:
- limitami bezczynności (idle timeout) na proxy: „jeśli po połączeniu nic nie płynęło przez N sekund — zamykamy”;
- restartem twojego backendu (deploy, awaria);
- niestabilną siecią po stronie użytkownika (zwłaszcza w mobile).
To normalne; ważne, by być na to gotowym, a nie liczyć, że „jakoś się uda”.
Strategia dla SSE
SSE ma wiele plusów właśnie w tej strefie:
- EventSource sam ponawia połączenie z pewnym opóźnieniem;
- masz id: i Last-Event-ID, by nadrobić zdarzenia.
Minimalny zestaw praktyk:
- Po stronie serwera okresowo wysyłaj coś w rodzaju heartbeat, aby połączenie nie było uznane za całkowicie bezczynne. Może to być osobne zdarzenie event: ping albo po prostu komentarz : keep-alive.
- Po stronie klienta w onerror pokazuj użytkownikowi czytelny status w rodzaju „Problemy z połączeniem, próbujemy ponownie…”, zamiast psuć cały widżet.
- Przy ponownym połączeniu, jeśli używasz id:, zwracaj z serwera tylko nowe zdarzenia po tym ID. W GiftGenius możesz zacząć w ogóle bez id: i po prostu „odtwarzać” stan po ostatnim otrzymanym job.progress/job.completed.
Strategia dla HTTP‑stream
HTTP‑stream to jedno żądanie, więc przy zerwaniu w praktyce trzeba zaczynać od nowa:
- jeśli strumieniujesz raport tekstowy, możesz po prostu powiedzieć użytkownikowi „Nie udało się pobrać pełnego raportu, spróbuj ponownie” i zacząć od początku;
- jeśli strumieniujesz ustrukturyzowane dane (NDJSON), możesz rozważyć mechanizm resume: na przykład przekazywać w żądaniu offset lub cursor, od którego trzeba kontynuować.
Na start nie komplikuj — zrób prostą politykę: jeśli strumień odpowiedzi przerwał się przed końcem — pokazujemy to, co mamy, i przycisk „Kontynuuj generowanie raportu”, który wyśle nowe żądanie.
Najważniejsze — nie zostawiaj użytkownika w stanie „wiecznego oczekiwania”.
8. Zastosowanie w GiftGenius: scenariusz od początku do końca
Teraz złóżmy razem wszystko, co omówiliśmy o SSE, HTTP‑stream i dwóch wariantach architektury z MCP, na żywym scenariuszu GiftGenius — od prośby użytkownika do gotowego raportu.
Użytkownik w ChatGPT pisze: „Dobierz prezent dla fana gier planszowych, budżet do 100 dolarów”. Model decyduje się wywołać GiftGenius. Aplikacja/agent wykonuje tool‑call start_gift_job na twoim serwerze MCP. Serwer:
- zapisuje job w bazie danych;
- wysyła go do wewnętrznej kolejki (szczegóły kolejek i workerów — w następnym wykładzie, na razie zakładamy, że „ktoś” to wykonuje);
- synchronicznie zwraca jobId w odpowiedzi na tool‑call.
Widżet GiftGenius otrzymuje ToolOutput z jobId i renderuje komponent:
function GiftGeniusRoot({ jobId }: { jobId: string }) {
return (
<div>
<h2>Szukamy idealnych prezentów...</h2>
<GiftJobProgress jobId={jobId} />
<GiftReport />
</div>
);
}
Komponent GiftJobProgress subskrybuje SSE /api/gift-jobs/{jobId}/events i rysuje postęp. Każde job.progress aktualizuje procenty, job.completed — ustawia 100 % i, być może, włącza przycisk „Pokaż szczegółowy raport”.
Komponent GiftReport po kliknięciu przycisku wysyła POST /api/gift-report (przekazując tam jobId) i stopniowo wyświetla raport tekstowy, podczas gdy serwer zwraca chunki strumienia HTTP.
Przy zerwaniu połączenia SSE widżet pokazuje łagodne ostrzeżenie, a EventSource próbuje połączyć się ponownie. Przy problemach ze strumieniem raportu użytkownik widzi część raportu i przycisk „Kontynuuj generowanie” lub „Spróbuj ponownie”.
Z punktu widzenia ChatGPT i MCP:
- MCP widzi wywołanie narzędzia start_gift_job i, być może, później powiadomienia o statusach joba;
- UX wokół strumieni jest realizowany głównie na poziomie HTTP między widżetem a twoim backendem.
9. Typowe błędy przy pracy z SSE i HTTP‑stream
Błąd nr 1: traktowanie SSE i HTTP‑stream jako „tej samej rzeczy”.
Tak, na dole jest wspólny HTTP i odpowiedzi chunked, ale semantyka jest mocno różna. SSE to subskrypcja niezależnych zdarzeń, które mogą przyjść kiedykolwiek i klient z góry o nich nie wie. HTTP‑stream to jedna konkretna odpowiedź rozłożona w czasie. Jeśli spróbujesz zrealizować subskrypcję wielu jobId przez jeden strumień HTTP, będziesz musiał sam wymyślić protokół nad bajtami, de facto budując na nowo połowę SSE.
Błąd nr 2: ignorowanie auto‑reconnectu SSE i brak myślenia o idempotentności.
Wielu pisze „prosty” serwer SSE: wysyła data: ... i nie dodaje ani standardowego id: (dla Last-Event-ID), ani aplikacyjnego event_id w treści zdarzenia. Potem, przy pierwszym zerwaniu i ponownym połączeniu, zaczynają mnożyć się duplikaty zdarzeń. Bez przemyślanego event_id i logiki „już widziałem to zdarzenie” obsługa po stronie klienta ryzykuje podwójnie aktualizować stan, podwójnie pokazywać to samo job.completed albo, co gorsza, podwójnie pobierać środki/naliczac bonusy.
Błąd nr 3: wysyłanie każdego „kichnięcia” workera osobnym zdarzeniem SSE.
Jeśli wysyłasz postęp zadania przez SSE co milisekundę, to raczej zabijesz sieć i klienta, niż ucieszysz użytkownika płynną animacją. Znacznie rozsądniej jest agregować aktualizacje i wysyłać postęp, powiedzmy, raz na 200–500 ms albo przy zmianie etapu procesu. Temat throttlingu i backpressure omówimy jeszcze później, ale już teraz warto myśleć o częstotliwości zdarzeń.
Błąd nr 4: budowanie złożonych protokołów nad HTTP‑stream bez jawnego formatu.
Typowy anty‑wzorzec: strumieniować JSON bez separatorów i próbować „zgadywać”, gdzie kończy się jeden obiekt, a zaczyna drugi. Albo mieszać w jednym strumieniu tekst i JSON. Najlepsza droga — wybrać prosty i zrozumiały format: tekst w liniach, albo NDJSON (jeden obiekt JSON na linię), albo jawne separatory. Wtedy parser po stronie klienta pozostanie sensowny.
Błąd nr 5: zapominanie o time‑outach i „wiecznych” strumieniach.
Czasem programiści robią endpointy SSE, które nic nie wysyłają przez 5–10 minut, a potem dziwią się, że połączenia zrywają się po drodze od użytkownika do serwera (load balancery, bramki API, korporacyjne proxy). Regularne zdarzenia heartbeat lub komentarze pozwalają utrzymać połączenie żywe i na czas wykrywać zerwania. A strumienie HTTP nie powinny zamieniać się w nieskończone odpowiedzi — do wiecznych subskrypcji jest SSE.
Błąd nr 6: próba zrobienia złożonego pub/sub nad HTTP‑stream zamiast normalnych zdarzeń.
Czasem kusi: „Zrobimy jeden strumień, którym będziemy wysyłać i postęp, i partial results, i losowe logi”. W efekcie po stronie klienta pojawi się złożony multiplekser, który analizuje każdy chunk i decyduje, do którego jobId należy. W większości przypadków prościej i pewniej jest użyć SSE ze zdarzeniami typu job.progress, job.completed i osobnym kanałem na joba, niż wymyślać własny megaprotokół nad HTTP‑stream.
Błąd nr 7: twarde wiązanie UX z tym, że strumień „nigdy nie pada”.
Każdy strumień kiedyś się zerwie. Jeśli twój widżet pozostaje wtedy z wiecznie animowanym paskiem postępu i bez opcji działania — UX odbierany jest jako „zepsuty”. Nawet prosta informacja „Wygląda na to, że połączenie zostało przerwane. Spróbuj ponownie uruchomić dobór prezentów” z przyciskiem „Spróbuj ponownie” jest dużo lepsza niż milczenie.
GO TO FULL VERSION