Jawaban AI Streaming di Blog Lewat fetch Biasa
EventSource nggak bisa POST. Jawaban AI di search dialog akhirnya jalan pakai fetch biasa, parser SSE manual, dan satu jebakan CRLF.
Search dialog di blog udah bisa fulltext search via MariaDB. Tapi ada tombol “Tanya AI” yang kalau diklik, harusnya nge-stream jawaban dari LLM: muncul dikit-dikit, hampir kata per kata, persis kayak ChatGPT lagi ngetik.
Pertama kali saya implementasiin, dugaanku gampang: pakai EventSource, API native browser buat dengerin SSE. Simpel, tinggal kasih URL, dia handle reconnect otomatis.
Ternyata nggak bisa.
EventSource cuma menerima GET. Nggak ada cara kirim body JSON berisi q dan locale yang dibutuhkan endpoint saya. Di dokumentasi MDN pun ditegaskan koneksi SSE itu satu arah, dari server ke client [1][2]. Dari sisi browser cuma bisa dengerin; nggak bisa ngirim apa-apa selain query string di URL.
Padahal endpoint pencarian AI saya, POST /articles/ask, emang sengaja butuh POST. Query bisa panjang, plus ada field locale. Nempatin semuanya di query string rasanya kayak mbalik-balikin tujuan bikin endpoint POST.
Fetch biasa, baca stream manual
Solusinya ternyata lebih sederhana dari yang saya kira. Pakai fetch biasa, tapi set header Accept dengan nilai “text/event-stream”. Browser tetap kirim POST dengan body JSON, dan response-nya berupa SSE stream. Tinggal baca pakai res.body.getReader() plus TextDecoder.
Parse-nya manual. Buffer semua chunk yang masuk, split per baris kosong; itu pemisah antar SSE event. Dari situ cari prefix data:, parse JSON-nya, lalu handle berdasarkan tipe event: “sources” buat sumber kutipan, “delta” buat teks yang muncul bertahap, “followups” buat saran pertanyaan lanjutan, terakhir “done” dan “error”.
Keputusan ini nyambung sama desain LLM API modern yang emang dirancang buat streaming, supaya client bisa mulai render output awal selagi model masih generate sisa jawabannya [4]. Saya tinggal nerusin stream itu dari backend ke frontend, satu koneksi HTTP dari awal sampai akhir. Untuk jawaban yang panjang, pola ini juga lebih ramah pembaca: chip sumber artikel kelihatan duluan sebelum kalimat pertama jawaban selesai diketik.
Di backend, endpoint balik StreamingResponse dari FastAPI, yang nerima async generator dan nge-stream response body per chunk [3]. Tiap event dikirim sebagai frame kecil, misalnya event: delta berisi potongan teks jawaban.
Jebakan CRLF sse-starlette
Paling nyebelin bukan soal fetch versus EventSource, tapi satu detail kecil yang bikin jawaban nggak muncul-muncul cukup lama.
Framework SSE di sisi Python-nya sse-starlette. Masalahnya, frame yang dikirim diakhiri CRLF, bukan LF biasa. Di frontend saya split buffer pada baris kosong dua karakter LF. Karena yang masuk sebenernya dua CRLF berurutan, pola dua LF yang saya cari nggak pernah ketemu. Buffer terus numpuk, browser diem aja. Nggak error, nggak crash, cuma diem.
Satu baris fix: normalisasi dulu semua CRLF jadi LF sebelum split. Tapi gejalanya cuma “data nggak sampai”, bukan error yang kelihatan. Debug-nya kerasa kayak nyari jarum di tumpukan jerami, makanya sekarang normalisasi ini jadi bagian tetap dari parser.
Backend-nya sendiri tetap sederhana: async generator yang yield frame SSE, StreamingResponse handle sisanya. Ada rate limit 5 request per menit per IP, jawaban di-cache 24 jam biar pertanyaan yang sama nggak bikin LLM kerja dua kali, dan kalau LLM router-nya mati, endpoint langsung balik 501 ask_disabled. Frontend tinggal fallback ke pesan bahwa AI lagi offline, tanpa crash.
Kenapa nggak pakai library SSE client
Banyak library yang bisa handle SSE parsing otomatis. Tapi ini cuma satu endpoint, satu cara baca. Tambah dependency baru buat parse beberapa baris teks itu overkill. fetch plus ReadableStream udah cukup, dan saya nggak perlu ikut khawatir API library-nya berubah di major version berikutnya.
EventSource memang tetap pilihan tepat buat data push murni, kayak live ticker atau notifikasi yang nggak butuh input dari client [2]. Untuk search interaktif yang butuh kirim body POST, fetch dengan header Accept: text/event-stream jauh lebih fleksibel. Satu method, satu koneksi, kontrol penuh di dua arah konfigurasi request.