Skip to content

From String to Stream using Typescript & Standard Web API

turborepo ep1 cover
#Typescript#Stream#Bun#StandardWebAPI
CodeSookPublish: 10th December 2025

มาเรียนรู้ว่า Stream ทำงานอย่างไร

ทุกวันนี้เราทุกคนน่าจะใช้ AI Chat กันอยู่แล้วใช่มะ
หลายๆ platform ก็จะใช้การ stream text กลับมาที่ client ทำให้เราเห็นว่า Text มันค่อยๆมาทีละคำสองคำ
เคยสงสัยไหมครับว่าเขาทำแบบนั้นได้อย่างไรนะ เบื้องหลังมันทำงานยังไง ผมที่จับงาน AI Application มากขึ้นเรื่อยๆ ก็ไม่ได้มีปัญหาอะไรนะ เพราะว่า Framework มันจัดการให้หมดแล้ว แต่ก็ยังอดสงสัยไม่ได้ ไปหาคำตอบแล้วก็เลยมาเขียน Blog เผื่อว่ามีคนสงสัยเหมือนกัน

ใน blog นี้เราจะมาดูกันว่าเขาทำได้อย่างไร โดยจะเน้นไปที่ Standard Web API และไม่ใช้ Framework เลย เพื่อจะได้เข้าใจเบื้องหลังของ framework มากขึ้นนะครับ แต่ไม่ได้ลงลึกไปถึงขั้น parse HTTP Request ด้วยตัวเองนะ เพราะว่าสุดท้ายเราก็ไปใช้ framework น่ะแหละ แต่ fundamentals ก็สำคัญ

โดยใน blog นี้ผมจะแสดงตัวอย่างให้ดูจากง่ายๆไปยากๆนะครับ จะมีทั้งตัวอย่างทางฝั่ง Server และตัวอย่างฝั่ง client เมื่อใช้กับ React นะครับ จะแสดงตัวอย่างการใช้งาน TextEncoder, TextDecoder, Chuked-transfer-encoding และ Server send event นะครับ ตัวอย่างทั้งหมดนี้จะเน้นทำงานบน HTTP1.1 นะครับ

ใน blog จะไม่ได้ cover ไปถึงการ streaming video อะไรพวกนั้นนะครับ เน้นที่ Text อย่างเดียวเลย


มารู้จักกับ TextEncoder กับ TextDecoder กันก่อน

ทั้งสองคำเป็นภาษาอีสาน ถุยยย 🤣🤣 ฮาไม่ฮาก็เล่นไปก่อนมันต้องมีคนอมยิ้มบ้างแหละน่าาา

ถ้าเราอยากส่งคำว่า “Hello สวัสดี 👋🏼” จากฝั่ง server ไปที่ client

หลายๆก็น่าจะรู้อยู่แล้วและมันง่ายๆมากๆ ทางฝั่ง server ก็ใส่ text เข้าไปที่ Response ตรงๆ ก็จบแล้ว

const handler = () => {
return new Response("Hello สวัสดี 👋🏼");
};

ทางฝั่ง client ก็จะใช้ fetch เรียกแบบปกติ

const getText = async () => {
const response = await fetch("/some-route");
const text = await response.text();
return text; // "Hello สวัสดี 👋🏼"
};

ทาง client ก็จะได้ text แบบทั้งหมดมาเลยในทีเดียว

แต่ blog นี้เราจะพูดคุยกันในเรื่องของ Stream ฉนั้นเราจะให้ server ค่อยๆส่ง text ไปให้ client และ client ก็จะค่อยๆอ่าน text ออกมา

แต่ก่อนจะไปรับส่ง text แบบค่อยๆนั้น เราต้องรู้จักเบื้องหลังของมันซักนิดนึง


Strings vs Bytes

เนื่องจากว่า String ใน Typescript มันไม่เท่ากับ Bytes นะ
การส่ง data ออกไปใน internet มันจะไม่ได้ส่ง string ไปตรงๆนะ ไม่ว่ายังไงก็ต้องแปลง String ให้ไปเป็น Bytes ก่อน แล้วค่อยส่ง data ออกไป ทางฝั่งรับก็จะรับ data ที่เป็น Bytes มาเช่นกัน แล้วค่อยแปลงไปเป็น String อีกทีนึง

จะเห็นว่าทางขาส่งจะมี String แล้วแปลงให้เป็น Bytes แล้วส่งไปใน internet แล้วฝั่งรับก็จะรับ Bytes แล้วแปลงเป็น String อีกที

img1

ทีนี้การจะแปลงจาก String เป็น Bytes ก็จะมีมาตรฐานที่ใช้ ซึ่งที่นิยมมากที่สุดในปัจจุบันคือ UTF-8 นะครับ

How to convert String to Bytes in Typescript

ทีนี้ใน Typescript เราจะแปลง String ไปเป็น Bytes ได้อย่างไร

ใน Typescript จะมี type นึง ที่ชื่อว่า Uint8Array ซึ่งมันจะเป็น type ที่กำหนดการเก็บ binary 8 bits หรือก็คือเก็บ byte ในรูปของ Array นั่นเอง

ฉนั้นเราก็จะเอา String มาแปลงเป็น Uint8Array ซะ โดยจะต้อง encode ด้วยมาตรฐาน UTF-8 ด้วยนะ

ซึ่ง UTF-8 เองก็มีข้อกำหนด ซึ่งเราคงไม่ต้องไปรู้รายละเอียดก็ได้ ใช้ตัวช่วยไปเลย นั่นก็คือ TextEncoder กับ TextDecoder

  • TextEncoder จะเป็นตัวช่วยที่จะแปลง String เป็น Bytes ตามมาตรฐาน UTF-8
  • TextDecoder จะเป็นตัวช่วยที่จะแปลง Bytes เป็น String ตามมาตรฐาน UTF-8

สามารถเรียกใช้งาน TextEncoder และ TextDecoder ตรงๆได้เลย มี built-in มาในทุกๆ js runtime (Browsers, Bun, Deno) อยู่แล้ว (แต่ NodeJS ไม่แน่ใจนะ)

ตัวอย่าง

text.ts
const text = "Hello สวัสดี 👋🏼";
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
console.log("bytes:", bytes);
const decoder = new TextDecoder("utf-8");
const decodedText = decoder.decode(bytes);
console.log("decodedText:", decodedText);

ลองรันดูด้วย Bun

bun text.ts

จะได้แบบนี้

Terminal window
bytes: Uint8Array(33) [ 72, 101, 108, 108, 111, 32, 224, 184, 170, 224, 184, 167, 224, 184, 177, 224, 184, 170, 224, 184, 148, 224, 184, 181, 32, 240, 159, 145, 139, 240, 159, 143, 188 ]
decodedText: Hello สวัสดี 👋🏼

จากตัวอย่างจะเห็นว่า คำว่า Hello สวัสดี 👋🏼 แปลงเป็น bytes จะมีขนาดเท่ากับ 33 bytes ซึ่งไม่เท่ากับจำนวนตัวอักษรนะ
เพราะว่าใน utf-8 แต่ละภาษาจะมีขนาดจำนวน bytes ไม่เท่ากัน

  • ภาษาอังกฤษ 1 ตัวอักษรจะมีขนาด 1 byte
  • ภาษาไทย 1 ตัวอักษารส่วนมากจะมีขนาด 3 bytes
  • emoji 1 ตัวอักษรจะมีขนาด 4 bytes

ภาษาไทยต้องมี 3 bytes จึงจะแสดงผลได้ถูกต้อง แต่ถ้ามีอะไรขัดข้อง byte ดันหายไป computer จะแสดงผลเป็นตัวนี้

เวลาเราใช้งาน stream text จาก server มาที่ client ตัว server จะส่ง bytes มาเรื่อยๆ แล้วฝั่ง client ก็จะรับ bytes เข้ามาแล้วแปลงเป็น string แต่ทีนี้ถ้าเกิดว่า bytes ของตัวอักษรมันยังมาไม่ครบ ก็อาจจะทำให้เห็นตัวนี้ ก็ได้

เช่นตัวอย่างนี้

จะเอา bytes 33 ตัวที่ได้ เอามาแค่ 20 ตัว แล้วส่งไป decode ดู

test.ts
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
console.log("bytes:", bytes);
const bytePart1 = bytes.slice(0, 20);
const decoder = new TextDecoder("utf-8");
const decodedText = decoder.decode(bytePart1);
console.log("decodedText:", decodedText);

ลองรันดู

Terminal window
bytes: Uint8Array(33) [ 72, 101, 108, 108, 111, 32, 224, 184, 170, 224, 184, 167, 224, 184, 177, 224, 184, 170, 224, 184, 148, 224, 184, 181, 32, 240, 159, 145, 139, 240, 159, 143, 188 ]
decodedText: Hello สวัส�

ตรงนี้ TextDecoder ได้รองรับส่วนนี้แล้ว เราไม่ต้องปวดหัวเลย ทำให้ string ที่ได้ safe มากขึ้นไม่ว่าจะเป็นภาษาไหนก็ตาม ตัว TextDecoder นั้นรองรับ stream อยู่แล้ว การทำงานของมันก็ง่ายมากๆ ถ้ามันเห็นว่า bytes ยังไม่ครบ มันจะเก็บไว้ก่อน และจะส่ง string เท่าที่ได้ออกมาก่อน พอได้ bytes ตัวถัดไปมาแล้ว ครบแล้ว ก็ค่อยส่งตัวอักษรตัวนั้นออกมา

ถ้าใครงงมาดูตัวอย่างกัน

test.ts
const text = "Hello สวัสดี 👋🏼";
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
console.log("bytes:", bytes);
const bytePart1 = bytes.slice(0, 20);
const decoder = new TextDecoder("utf-8");
const decodedText = decoder.decode(bytePart1, { stream: true });
console.log("decodedText:", decodedText);

จากตัวอย่างด้านบนตอนที่ decode เราจะใส่ options { stream: true } เข้าไปด้วย เป็นการเปิดใช้งาน stream เหมือนที่บอกไปแล้วด้านบน

ลองรันดู

Terminal window
bytes: Uint8Array(33) [ 72, 101, 108, 108, 111, 32, 224, 184, 170, 224, 184, 167, 224, 184, 177, 224, 184, 170, 224, 184, 148, 224, 184, 181, 32, 240, 159, 145, 139, 240, 159, 143, 188 ]
decodedText: Hello สวัส

จะเห็นว่า ไม่ได้แสดงแล้ว

มาดูตัวอย่างเมื่อ steam จบ เราจะได้ string แบบไหนมา

test.ts
const text = "Hello สวัสดี 👋🏼";
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
console.log("bytes:", bytes);
const bytePart1 = bytes.slice(0, 20);
const bytePart2 = bytes.slice(20);
const decoder = new TextDecoder("utf-8");
const decodedText1 = decoder.decode(bytePart1, { stream: true });
const decodedText2 = decoder.decode(bytePart2, { stream: false });
console.log({ decodedText1, decodedText2 });
const fullText = decodedText1 + decodedText2;
console.log("decodedText:", fullText);

ลองรันดูจะได้แบบนี้

Terminal window
bytes: Uint8Array(33) [ 72, 101, 108, 108, 111, 32, 224, 184, 170, 224, 184, 167, 224, 184, 177, 224, 184, 170, 224, 184, 148, 224, 184, 181, 32, 240, 159, 145, 139, 240, 159, 143, 188 ]
{
decodedText1: "Hello สวัส",
decodedText2: "ดี 👋🏼",
}
decodedText: Hello สวัสดี 👋🏼

ยังมี Web Standard API อีก 2 ตัว TextEncoderStream กับ TextDecoderStream ที่ทำให้เราทำงานง่ายขึ้นอีกด้วยนะ ลองไปศึกษาเพิ่มเติมดูได้นะ

Summarize part TextEncoder & TextDecoder

ตรงนี้เรารู้จักการแปลง String ให้ไปเป็น Bytes ผ่านการใช้ Web Standard API ที่ชื่อว่า TextEncoder กับ TextDecoder แล้ว
ทั้งนี้ถ้าเป็นภาษาอื่นผมคิดว่าน่าจะมี Tools ลักษณะเดียวกันนี้ให้ใช้งาน
ถึงแม้จะบอกว่าไม่ใช้ Frameworks แต่ผมก็ใช้ Web Standard API อยู่บ้าง เช่น 2 ตัวนี้


HTTP Chunked Transfer Encoding (Server Side)

what is Chunked Transfer

เราจะมาทำฝั่ง Server side กันก่อน
โดยวิธีที่เราจะใช้เรียกว่า Chunked Transfer นะ ซึ่งทำงานบน HTTP1.1 นะ

โดยปกติแล้วเวลาเราสร้าง REST API ไว้ ฝั่ง client เรียกใช้ ก็จะได้ json ก้อนเดียวไปเลยในทีเดียว ซึ่งฝั่ง Server อาจจะไปดึง Data จาก Database มาแล้วค่อย response กลับไปให้ client
ตรงนี้ฝั่ง Server จะรู้แน่ชัดอยู่แล้วว่า data มีขนาดเท่าไร, content length มีขนาดเท่าไร (ตรงนี้ส่วนใหญ่ frameworks เบื้องหลังจะใส่ content-length ให้เราเองแบบอัตโนมัติ) client ฝั่งรับข้อมูลก็จะนับจำนวนได้ พอจำนวนครบปุ๊ปก็แสดงผลต่อได้เลย

Chunked Transfer คืออะไร
คือเราจะเอา data ที่มีมาแบ่งเป็นชิ้นเล็กๆ แล้วค่อยๆส่งไปให้ client ผ่าน HTTP1.1 (REST นี่แหละ) เราอาจจะมี Text ที่ยาวมากๆ แทนที่จะส่ง Text ไปทั้งหมดในทีเดียว ซึ่งมันชิ้นใหญ่ เราก็เอา Text มาแบ่งเป็นชิ้นเล็กๆ อาจจะ 10 ตัวอักษร แล้วค่อยๆส่งไปทีละ 10 จนครบ ยิ่งถ้าเราทำงานกับ LLM เราไม่รู้เลยว่า Text ที่ Generate มาจะยาวแค่ไหน เราก็ต้องใช้วิธี Chunked Transfer นี่แหละ

พอเป็น Chunked Transfer ฝั่ง Server จะไม่รู้ว่า content-length จะยาวแค่ไหน ทำให้ฝั่ง client เองก็ไม่รู้ว่าเมื่อไรจะได้ data ครบ client ก็จะใช้วิธีอื่นแทน เดี๋ยวได้เรียนรู้กันในลำดับต่อไป

ฉนั้นถ้ามองแค่ในฝั่ง Server เราไม่ต้องใส่ Content-Length ลงไปใน header เพราะว่าเราจะค่อยๆ ส่งไปเรื่อยๆไง จริงๆเราเองก็ยังไม่รู้ว่ามันจะยาวสักแค่ไหน

example

มาดูตัวอย่างกัน ในตัวอย่างจะไม่ใช้ frameworks แต่อย่างที่บอกว่าผมจะไม่ทำการ parse HTTP Request เองฉนั้นผมจะยังพึ่ง Bun.serve อยู่นะครับ ซึ่งมันก็ built-in มากับ Bun อยู่แล้ว ถ้าเป็น Deno, NodeJS ผมไม่รู้เหมือนกันว่าต้องใช้อะไร แต่เพื่อนๆน่าจะพอไปค้นหาเองได้แล้วใช่ไหม

ผมจะส่ง text นี้ "Hello สวัสดี 👋🏼" แบบ stream ด้วยวิธี Chunked Transfer

จาก Text "Hello สวัสดี 👋🏼" ผมจะแบ่งเป็น 4 ชุด แบบนี้ (การที่แบ่งแบบนี้เพื่อเป็นตัวอย่างเฉยๆนะ)

  1. "Hello" จำนวน 5 bytes
  2. " สวัส" มี space ด้านหน้าด้วยนะ จำนวน 13 bytes
  3. "ดี" จำนวน 6 bytes
  4. " 👋🏼" มี space ด้านหน้าด้วยนะ จำนวน 9 bytes

แล้วจะค่อยๆให้ server stream ไปทีละชุด จากทั้งหมด 4 ชุด จำนวน bytes ในตัวอย่างนี้ไม่ได้ตรงกับการเข้ารหัสจริงของ UTF‑8 นะ ใช้เพื่อสาธิตโครงสร้าง Raw HTTP Response เฉยๆ

อยากให้มาดู Raw HTTP Response ก่อนว่าหน้าตาจะเป็นประมาณไหน (แต่อย่างที่บอกว่าผมจะไม่ parse เอง แค่เอามาอธิบายเพื่อให้เห็นภาพเฉยๆนะ)

Terminal window
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked
5\r\n
Hello\r\n
d\r\n
สวัส\r\n
6\r\n
ดี\r\n
9\r\n
👋🏼\r\n
0\r\n
\r\n

จาก Raw HTTP Response ด้านบน ผมจะ break down ออกมาเป็นแบบนี้

ส่วนของ Headers

  • Content-Type เป็น text/plain คือบอก browser ว่า data ที่ได้จะเป็น text ธรรมดานะ
  • Transfer-Encoding เป็น chunked คือบอก browser ว่า data ที่ได้จะเป็น chunked นะ ต้องรอรับทีละชุดนะ ไม่ได้ส่งทั้งก้อน
  • ไม่มี Content-Length นะ เพราะว่าใช้ Transfer-Encoding ไปแล้ว

ส่วนของ Body จะส่งเป็นชุดๆ แต่ละชุดจะมี 2 บรรทัดนะ เช่น

ชุดแรก

Terminal window
5\r\n
Hello\r\n

ชุดสอง

Terminal window
d\r\n
สวัส\r\n

ไปเรื่อยๆจนหมด text ที่แบ่งเป็นชุดไว้

สุดท้ายจะต้องจบด้วย

Terminal window
0\r\n
\r\n

แต่ละชุดประกอบไปด้วย 2 บรรทัด

  • บรรทัดแรก คือจำนวน bytes ในบรรทัดที่สอง (นับเฉพาะ text นะ ไม่รวม \r\n) ในรูปของ Hex นะ เลขฐาน 16 นะไม่ใช่เลขฐาน 10 ของ Text ในชุดนั้น ตามด้วย \r\n (return and new line)
    ทำไมต้องเป็นเลขฐาน 16 จริงๆ แล้ว spec เขากำหนดให้ใช้ hex เพื่อให้ parsing ง่ายและเป็นมาตรฐานเดียวกันในโปรโตคอลครับ
  • บรรทัดที่สอง คือ text จริงๆละ แล้วตามด้วย \r\n เหมือนกัน

ก็ส่งวนไปเรื่อยๆจนครบชุดที่เราแบ่งไว้ จนครบหมด ก็จบด้วย

0\r\n คือไม่มี text แล้ว bytes เลยเป็น 0
\r\n คือจบจริงๆแล้ว ไม่มี text แล้ว

พอ browser เห็น 2 บรรทัดสุดท้ายนี้ก็จะจบการรับ HTTP Response ละ

การทำงานคือ Server ก็จะส่ง chunk ไปเรื่อยๆ ไม่ได้สนใจอะไร
ส่วน client ฝั่งรับ ก็จะรับมาเรื่อยๆเหมือนกัน แต่ว่า Browser จะรอให้ครบ 2 บรรทัดก่อนจึงจะส่ง data ไปให้ web app ของเรา โดย browser จะดูที่ \r\n กับจำนวน Size พอครบเงื่อนไข ก็ส่งไปให้ web app 1 ชุด วนไปเรื่อยๆจนครบ text พอเจอ 2 บรรทัดจบ ก็จบการรับ Response ละ

Create Bun Server that serve chunked transfer

มาทำ Server กันจริงๆด้วย Bun.serve

โดยจะ response text “Hello สวัสดี 👋🏼” เป็น 4 ชุดเหมือนด้านบนเลย
โดยแต่ละชัดจะเว้นช่วง 1 วินาที เพื่อจำลองว่า request นี้มันใช้เวลา stream นานนะ

test.ts
const text = "Hello สวัสดี 👋🏼";
const chunked = ["Hello", " สวัส", "ดี", " 👋🏼"];
Bun.serve({
port: 3000,
fetch: () => {
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
const encoder = new TextEncoder();
let index = 0;
const intervalId = setInterval(() => {
const chunk = chunked[index];
const bytes = encoder.encode(chunk);
controller.enqueue(bytes);
index += 1;
if (index >= chunked.length) {
controller.close();
clearInterval(intervalId);
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
},
});

อธิบายโค้ดด้านบนสักนิด

เราใช้ Bun.serve นะ

  • port 3000
  • fetch เป็น function ที่เมื่อมี request มาที่ http://localhost:3000 ก็จะทำงาน โดยไม่สนว่า path อะไร ถ้าอยากแยก path ด้วยก็ใช้ if เช็ค request.url.path ก็ได้ แต่ในที่นี้ผมไม่ได้สนใจ path แค่ demo เฉยๆ ฉนั้นไม่ว่าเรียกมาที่ path อะไรก็จะทำงานเหมือนกันหมด
    • ใน fetch นี้ผมสร้าง const stream = new ReadableStream<Uint8Array>() ขึ้นมา แล้วให้ return เป็น body ใน Response ไปเลย
    • ใน ReadableStream ก็จะใส่ start: (controller) => {} ซึ่ง start จะเริ่มทำงานตอนที่เริ่มตอบกลับน่ะแหละ พอ start ถูกเรียกก็จะมี parameter ชื่อ controller ส่งมาใน function callback ตัวนี้
      ตัว controller ตัวนี้แหละจะช่วยเราส่ง text แต่ละ chunk ให้กับ client ที่ request มา
      โดยเราจะใช้ controller.enqueue(bytes) ใส่แค่ bytes ให้ enqueue() ก็พอ เดี๋ยวมันจะไปคำนวน size แล้วเพิ่ท \r\n ทำเป็น 2 บรรทัดใน Response body ให้เองเลย ค่อนข้างสะดวก พอครบแล้วก็สั่ง controller.close() เพื่อปิด stream ส่ง 0\r\n \r\n น่ะแหละ

เมื่อ client เรียกเข้ามา Server ก็จะสร้าง ReadableStream แล้ว response เลยทันที แล้วเปิดช่องทางไว้ ปล่อยให้ ReadableStream ทำงานไปเรื่อยๆ ส่วน client ก็จะเปิดรับข้อมูลไปเรื่อยๆจนครบ

ทำทดสอบรันดู

Terminal window
bun test.ts
Terminal window
curl -N http://localhost:3000

จะได้แบบนี้ ผมอัดวิดีโอมา


ทำ Frontend ด้วย React

เราจะมาทำ Frontend ให้ไปเรียกใช้งาน Server ของเรา แล้วค่อยๆเอา Text ที่ stream มา Display ใน react ของเรา ผมจะทำให้ดูแค่ component เดียวจะ ใครลองทำตามก็สร้าง React app ด้วย vite ก็ได้ จะ framework ไหนก็ได้แหละ

สร้าง components

index.tsx
function App() {
return <div></div>;
}

ใส่ state

function App() {
const [text, setText] = useState("");
const [isLoading, setIsLoading] = useState(false);
return (
<div className="text-center">
<button
type="button"
onClick={startStream}
disabled={isLoading}
className="p-2 bg-blue-200 rounded-xl"
>
{isLoading ? "Streaming..." : "Start Stream"}
</button>
<div style={{ marginTop: "20px", whiteSpace: "pre-wrap" }}>
{text}
</div>
</div>
);
}

สร้าง function เพื่อดึงข้อมูลแบบ stream

function App() {
const [text, setText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const startStream = useCallback(async () => {
setIsLoading(true);
setText("");
const response = await fetch("http://localhost:3000");
if (!response.body) {
console.error("No response body");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
const chunkStr = decoder.decode(value, { stream: true });
setText((prevText) => prevText + chunkStr);
}
}
setIsLoading(false);
}, []);
return (
<div className="text-center">
<button
type="button"
onClick={startStream}
disabled={isLoading}
className="p-2 bg-blue-200 rounded-xl"
>
{isLoading ? "Streaming..." : "Start Stream"}
</button>
<div style={{ marginTop: "20px", whiteSpace: "pre-wrap" }}>
{text}
</div>
</div>
);
}

อธิบายโค้ดด้านบนเฉพาะส่วน fn startStream นะ

  • ยิง fetch ไปที่ http://localhost:3000 เพื่อดึงข้อมูลแบบ stream ตรงนี้เดี๋ยว server จะ return ReadableStream กลับมา ทำให้ await แค่แปปเดียว
  • พอเป็น ReadableStream ใน body จะต้องสามารถเรียก body.getReader() ได้ และจะได้ reader กลับมา เอาไว้อ่านค่าที่ stream มาทีละ chunk
  • สร้าง decoder ด้วย TextDecoder เหมือนที่ได้ทำไปก่อนหน้านี้แล้ว
  • เราจะค่อยๆอ่าน text ทีละ chunk ไปเรื่อยๆ ถ้ายังไม่หมดก็จะอ่านไปเรื่อยๆ ด้วย while (true) {} เมื่อครบแล้วก็จะสั่ง break ออกจาก while loop
  • ใน reader จะสั่ง reader.read() เพื่ออ่านค่า text chunk ซึ่งจะได้เป็น {done, value} เสมอเลยนะ ถ้ายังมี text ที่ stream เข้ามาอยู่เรื่อยๆ value จะมีค่า และเป็น Bytes นะ เราก็เอาไปเข้า decoder เพื่อแกะเป็น string อีกทีนึง ถ้าหมดแล้ว ค่าของ done จะเป็น true
  • พอแกะค่าออกมาได้เราก็ใส่เข้าไปที่ setText() ตามโค้ดด้านบน ก็แค่เอา text เก่ามาต่อกับ text ที่พึ่งแกะได้
  • แล้วก็วนไปอีกรอบจนเจอว่า done === true เราก็สั่ง break เพื่อออกจาก while loop

ลองรันดู จะได้แบบนี้
ผมอัดวิดีโอมาแบบนี้


Stream JSON Line

จากตัวอย่างด้านบน เราทำการ stream text ธรรมดา
แต่ว่าในงานของเรามักจะเป็น json ซะมากกว่าใช่มะ

ไม่ยากเลย เราก็แค่เปลี่ยนจาก text ให้เป็น json ซะ เท่านี้ก็ใช้ได้แล้ว
แต่ว่าเราจะแบ่ง chunk ยังไงดีละ

เราจะมาเรียนรู้การ stream json ด้วย JSON Line กัน

JSON Line คือการที่เรามี json แบบ array แล้วแบ่งเป็น chunk ตาม member ของ Array เลย ซี่ง member นั้นอาจจะเป็น object ก็ได้ เราจะเอามาแปลงเป็น string แล้วส่งไป
ซึ่งจะทำให้แต่ละ chunk จะเป็น completed json ภายในตัวเอง เวลาเราได้รับ chunk มา 1 ชุด แล้วเอามา parse ด้วย JSON.parse() เราจะได้ data ที่ใช้งานได้เลย
เวลามี chunk ถัดไปเข้ามา เราก็เอามา push เข้า Array ไปได้เลย แล้วค่อยเอาไป display ต่อไป

JSON Line มีอีกชื่อนึงว่า NDJSON (Newline Delimited JSON)

ใครงงมาดูตัวอย่างกันเลย

เราจะทำฝั่ง Server ก่อนนะ โดยตัวอย่างจะเป็นการเลียนแบบการ Chat กับ LLM นะ

โดยจะสมมติไปเลยว่า LLM มันจะตอบกลับมาแบบนี้

Terminal window
Sure, I can help with that. First we'll look at the server implementation, then we'll build a small React client.

แต่ว่า LLM Inference ใน platform ต่างๆ จะไม่ได้ตอบ Text กลับมาตรงๆ แต่จะตอบกลับมาเป็น JSON แบบนี้

const items = [
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 0,
delta: {
role: "assistant",
content: "Sure, I can help with that.",
},
created: 1730876800,
model: "gpt-5.1-mini",
},
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 1,
delta: {
content: " First we'll look at the server implementation, ",
},
},
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 2,
delta: {
content: "then we'll build a small React client.",
},
},
{
id: "chatcmpl-9abc123",
type: "message.completed",
index: 3,
done: true,
usage: {
input_tokens: 128,
output_tokens: 256,
total_tokens: 384,
},
},
];

แต่ว่าเราไม่ได้ json มาทีเดียวทั้งก้อนนะ Server ของ LLM จะค่อยๆส่ง json มาเป็นชุดๆ โดยแต่ละชุดก็คือ index ของ Array นั่นแหละ
พอเอาทั้งหมดมารวมกันก็ต้องเอามารวมกันไว้ใน Array เราเรียกทั้งหมดนี้ว่า JSON Line

เราที่เป็นคน call platform พวกนั้น ก็จะต้องจัดการกับ Stream JSON Line ที่ได้รับมา แล้วเอา Text ที่อยู่ใน content มาต่อกันด้วยตัวเอง
ตอนจบจะมี key ที่ชื่อว่า done: true ทำให้เรารู้ว่า LLM ได้ส่ง data มาครบแล้ว
และมี usage: อยู่ด้วย ทำให้เรารู้ว่าใช้ token ไปเท่าไรทั้ง input และ output

แต่ว่าในแต่ละ platform อาจจะ response มาไม่เหมือนกับในตัวอย่างนะ แค่อยากให้เห็นภาพการ stream แบบ JSON Line ก่อน

ตัวอย่าง Server side do stream JSON Line

ในตัวอย่างนี้เราจะทำฝั่ง Server ก่อนนะ เราจะ stream JSON Line จาก backend

server.ts
const items = [
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 0,
delta: {
role: "assistant",
content: "Sure, I can help with that.",
},
created: 1730876800,
model: "gpt-5.1-mini",
},
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 1,
delta: {
content: " First we'll look at the server implementation, ",
},
},
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 2,
delta: {
content: "then we'll build a small React client.",
},
},
{
id: "chatcmpl-9abc123",
type: "message.completed",
index: 3,
done: true,
usage: {
input_tokens: 128,
output_tokens: 256,
total_tokens: 384,
},
},
];
Bun.serve({
port: 3000,
fetch: () => {
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
const encoder = new TextEncoder();
let index = 0;
const intervalId = setInterval(() => {
const chunk = items[index];
const chunkStr = JSON.stringify(chunk);
const bytes = encoder.encode(chunkStr);
controller.enqueue(bytes);
index += 1;
if (index >= items.length) {
controller.close();
clearInterval(intervalId);
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
// CORS:
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Credentials": "false",
},
});
},
});

โค้ดด้านบนค่อนข้างเหมือนเดิมเลย แกะ object ออกมาจาก Array items ตาม index ต่างๆ
แล้วเอามาแปลงเป็น string ด้วย JSON.stringify() ก่อนจะส่งไปให้ client

ลองรัน server

Terminal window
bun server.ts

มาทดลองยิง request ด้วย curl กันก่อนจะไปทำที่ React

จะเห็นว่าเราจะค่อยๆได้รับ string มาในรูปของ json
ถ้าเราจะเอาไปใช้ต่อก็ต้อง parse json อีกที

React consume json line stream

เราจะมาทำ React ให้เอา json line ที่ stream มาแสดงผล

โค้ดที่ได้จะเป็นแบบนี้ จะต่างจากเดิมนิดหน่อยเท่านั้น

import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useState } from "react";
export const Route = createFileRoute("/json-line")({
component: RouteComponent,
});
type Chunk = {
id: string;
type: "message.delta" | "message.completed";
index: number;
delta: {
role?: string | undefined;
content: string;
};
created: number;
model: string;
};
function RouteComponent() {
const [text, setText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const startStream = useCallback(async () => {
setIsLoading(true);
setText("");
const res = await fetch("http://localhost:3000");
if (!res.body) {
console.error("No response body");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
const chunkStr = decoder.decode(value, { stream: true });
const chunkJson = JSON.parse(chunkStr) as Chunk;
if (chunkJson.type === "message.completed") {
break;
}
setText((prevText) => prevText + chunkJson.delta.content);
}
setIsLoading(false);
}
}, []);
return (
<div className="text-center">
<button
type="button"
onClick={startStream}
disabled={isLoading}
className="p-2 bg-orange-200 rounded-xl"
>
{isLoading ? "Streaming..." : "Start Stream"}
</button>
<div style={{ marginTop: "20px", whiteSpace: "pre-wrap" }}>{text}</div>
</div>
);
}

ผมอัดวิดีโอมา ได้แบบนี้

Stream chunk using Server Sent Events

What is SSE and Why

จากตัวอย่างที่ผ่านมา จะเห็นว่าค่อนข้าง simple เลย
เราแบ่งของออกเป็น chunk เล็กๆ แล้วเอามา encode แล้วค่อยๆส่งไปให้ client
ฝั่ง client ก็รับ chunk มาแล้ว decode ออกมา แล้วเอาไป display

แต่ท่าที่ Platform ส่วนใหญ่ใช้ จะไม่ใช่แบบนั้น
สิ่งที่ platform ใหญ่ๆใช้คือการส่ง chunk ผ่าน Server Sent Events (SSE) แทน

ทำไมถึงเป็น SSE

เพราะว่า

  • ทุกๆ browser support SSE กันหมดแล้ว เวลา connection หลุด browser จะต่อ connection ให้เองแบบ auto เลย เราไม่ต้อง control การ re-fetch เองเลย
  • คนที่ทำ Frontend ไม่ต้อง decode bytes เองแล้ว ทำให้การ integration ง่ายขึ้นอีก
  • SSE มี format ที่ชัดเจนเป็นมาตรฐานอยู่แล้ว ทำให้เข้าใจง่ายกว่า Chunked transfer ที่เป็น Raw string

เท่าที่ผมรู้ส่วนใหญ่จะเป็น SSE นะ แต่งานจริงๆ เราไม่ได้จัดการส่ิงเหล่านี้เอง แต่ใช้ library ที่มีอยู่แล้วแทน

SSE Format

เราจะส่ง chunk ผ่าน SSE ใช่มะ แต่ละ chunk จะส่งเป็น 1 event ซึ่ง 1 Event ไม่ได้มีแค่ chunk data ของเราเท่านั้น มีอะไรบ้าง

  1. data ก็เป็น chunk data ของเรา เนื้อหาจริงๆที่เราต้องการส่งจะใส่ไว้ในนี้ จะเป็นอะไรก็ได้ JSON, Text etc etc. จะมี new-line (\n) ก็ได้ด้วยนะ
  2. id จะเป็น id ของ event ที่ต้องไม่ซ้ำกับ event อื่นๆนะ id จะไม่ใส่ก็ได้นะ แต่ถ้าใส่จะมีประโยชน์มากๆในตอนที่ recconect เพื่อรับ data ต่อเมื่อ connection หลุด
  3. event จะเป็น event type พอใจจะใส่คำว่าอะไรก็จัดไป จะไม่ใส่ก็ได้ ถ้าใส่ก็ควรจะสื่อสักหน่อยว่า event นี้มันคืออะไร จะมีประโยชน์มากๆ ที่ Frontend เขาจะได้ใช้ event type ตัวนี้กรองสิ่งที่ไม่อยากได้ออกไป ตัวอย่าง event ก็เช่น “new_message”, “user_joined”, “user_left”, “job_started”, “job_finished”
  4. retry ก็กำหนดว่าถ้า connection หลุดให้ รอนานเท่าไร เป็น milli seconds นะ ก่อนที่จะลอง reconnect อีกครั้งนึง จะไม่ใส่ก็ได้นะ ค่า default ก็แล้วแต่ browser ละ แต่ส่วนมากก็ 3000 milli seconds

SSE Format 4 ตัวนี้ไม่ใช่ json นะ เป็น format ของ Text 4 บรรทัด
จะคั่นด้วย new line (\n) นะ ตัวอย่างเช่น

Terminal window
id: 1\n
event: chat_delta\n
data: {"id":"chatcmpl-9abc123","delta":{"content":"Hello"}}\n

Server response event stream

ทีนี้มาดูกันว่าถ้าเราจะเขียน Server ให้ส่ง chunk ผ่าน SSE จะทำยังไง

โค้ดจะคล้ายๆเดิมเลย หลักๆคือเราเปลี่ยนเนื้อหาใน message ที่จะส่งไป กับ header 1 ตัว

server.ts
const items = [
38 collapsed lines
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 0,
delta: {
role: "assistant",
content: "Sure, I can help with that.",
},
created: 1730876800,
model: "gpt-5.1-mini",
},
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 1,
delta: {
content: " First we'll look at the server implementation, ",
},
},
{
id: "chatcmpl-9abc123",
type: "message.delta",
index: 2,
delta: {
content: "then we'll build a small React client.",
},
},
{
id: "chatcmpl-9abc123",
type: "message.completed",
index: 3,
done: true,
usage: {
input_tokens: 128,
output_tokens: 256,
total_tokens: 384,
},
},
];
Bun.serve({
port: 3000,
fetch: () => {
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
const encoder = new TextEncoder();
let index = 0;
const intervalId = setInterval(() => {
const chunk = items[index];
const chunkStr = JSON.stringify(chunk);
const messageLines = [
`id: ${index}`,
`event: chat_delta`,
`data: ${chunkStr}`,
];
const message = `${messageLines.join("\n")}\n\n`;
console.log({ index, chunkStr, message });
const bytes = encoder.encode(message);
controller.enqueue(bytes);
index += 1;
if (index >= items.length) {
// Optional: send a final "done" signal
// OpenAI sends "data: [DONE]\n\n"
const completeMessage = `${[
"event: chat_completed",
"data: [DONE]",
].join("\n")}\n\n`;
controller.enqueue(encoder.encode(completeMessage));
controller.close();
clearInterval(intervalId);
}
}, 1000);
},
});
return new Response(stream, {
headers: {
// CRITICAL: This tells the browser it's SSE
"Content-Type": "text/event-stream",
// Disable caching (important for streaming)
"Cache-Control": "no-cache",
// Keep connection alive
Connection: "keep-alive",
6 collapsed lines
// CORS:
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "false",
},
});
},
});

จากโค้ดด้านบนเราทำเพิ่มแค่ 3 ส่วน

  1. บรรทัดที่ 55-60 เราสร้าง message ให้เหมือนกับ format ของ SSE แต่ละส่วนจะต้องคั่นด้วย \n นะ ก็เลยใช้ array แล้ว join ด้วย \n พอจบครบ 1 chunk message เราจะต้องปิดด้วย \n\n นะ เพื่อบอกให้ browser รู้ว่าจบ 1 chunk message แล้ว ให้ frontend เอาไปแสดงผลก่อน เดี๋ยว message ถัดไปจะตามมา
  2. บรรทัด 70-73 จะเป็น message chunk ปิดจบ จะไม่ทำก็ได้นะ
  3. บรรทัด 82-88 เป็นการ set headers ให้เหมาะกับ Event Stream อะนะ
  4. บรรทัดที่ 91-93 เป็นการแก้ปัญหา CORS เผื่อว่าใครเจอรัน frontend กับ backend แยกกันคนละ port
  • Content-Type: text/event-stream เพื่อบอก browser ว่ากำลังจะส่ง chunk data ในรูปแบบของ event stream นะ
  • Cache-Control: no-cache บอก browser ว่าไม่ต้อง cache
  • Connection: keep-alive บอก browser ว่าถ้าเรียก api เส้นนี้ก็จะต้องเชื่อมต่อยาวๆไปเลย

ลองรันดู

Terminal window
bun server.ts

ลองเปิด browser ดู จะได้แบบนี้

React consume SSE

มาดู frontend กันบ้างว่าถ้าจะเชื่อมต่อกับ Server ที่ response เป็น SSE จะทำอย่างไร มี 2 แบบนะ

1. Using EventSource

มาดูแบบแรกกันก่อน คือใช้ EventSource

EventSource เป็น class ที่อยู่ใน Standard Web API อยู่แล้ว มันเอาไว้ดึง data จาก api เหมือน fetch เลย แต่จะต่างกันนิดนึงคือ ถ้าเรารู้อยู่แล้วว่า api เส้นที่เราเรียกจะ response กลับมาเป็น Event stream สังเกตได้จากใน response headers = Content-Type: text/event-stream จะสามารถใช้ EvenSource ได้เลย พอใช้ EventSource แล้ว browser จะคอยต่อ connection ให้เองเลยถ้าเกิดว่า connection หลุด แล้วก็จะเปิด connection ค้างไว้รอรับ data จาก server เพียงอย่างเดียว เราที่เป็น developer ก็แค่จัดการกับ event ต่อจาก browser อีกทีนึง ไม่ต้องไปกังวลเรื่อย connection, ไม่ต้องจัดการ parse SSE format จาก text เลย

App.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useState, useCallback } from "react";
type Chunk = {
id: string;
type: "message.delta" | "message.completed";
index: number;
delta?: {
role?: string | undefined;
content: string;
};
created: number;
model: string;
};
export const Route = createFileRoute("/sse")({
component: RouteComponent,
});
function RouteComponent() {
const [text, setText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const startStream = useCallback(async () => {
setIsLoading(true);
setText("");
const eventSource = new EventSource("http://localhost:3000");
eventSource.addEventListener("chat_delta", (event) => {
const data = JSON.parse(event.data) as Chunk;
console.log({ data });
if (data.delta?.content) {
setText((prev) => prev + data.delta?.content);
}
});
eventSource.addEventListener("chat_completed", (event) => {
console.log("Stream completed:", event.data);
eventSource.close();
setIsLoading(false);
});
eventSource.onerror = (err) => {
console.error("SSE error:", err);
console.log("obj", JSON.stringify(err));
eventSource.close();
setIsLoading(false);
};
}, []);
return (
<div className="text-center">
<button
type="button"
onClick={startStream}
disabled={isLoading}
className="p-2 bg-orange-200 rounded-xl"
>
{isLoading ? "Streaming..." : "Start Stream"}
</button>
<div style={{ marginTop: "20px", whiteSpace: "pre-wrap" }}>
{text}
</div>
</div>
);
}

อธิบายโค้ดตามนี้
โค้ดส่วนใหญ่ก็เหมือนเดิมนะ ผมจะอธิบายแค่ส่วนที่เปลี่ยนไปแบบนี้

  • บรรทัด 28 new EventSource(<url>)

  • บรรทัดที่ 30-36 .addEventListener(<event name>) ให้เราใส่ event name เข้าไป ถ้าเกิดว่ามี event ที่ชื่อเดียวกันเข้ามาก็จะมาเรียก callback ให้ทำงาน ส่วน event name ที่ว่าก็คือส่วนที่ event: <event name> server response กลับมา

    ผมเอาโค้ดที่ server กลับมาให้ดูอีกที ตรงนี้ chat_delta นี่แหละ คือ event name ที่เราจะใส่ใน .addEventListener()

server.ts
const messageLines = [`id: ${index}`, `event: chat_delta`, `data: ${chunkStr}`];
const message = `${messageLines.join("\n")}\n\n`;

พอ event name เป็นชื่ออื่น เป็นคำอื่น callback ก็จะไม่ทำงาน ก็เพิ่มความสะดวกให้เราพอสมควร

  • บรรทัด 38-42 .addEventListener("chat_completed") ก็เช่นกันเหมือนกับด้านบน แค่จะทำงานตอนที่มี event ที่ชื่อว่า “chat_completed” เท่านั้น

  • บรรทัด 44-49 .onerror = ()=> {} ใส่ function เข้าไปถ้าเกิดว่าเกิด error จะให้ทำอะไร ในที่นี้ก็ให้พ่น logs แล้วก็ ปิด connection ไปเลย ไม่ต้องพยายามเชื่อมต่อแล้ว

ลองรันแล้วเปิด browser จะได้แบบนี้

2. use fetch that we already familiar

เราสามารถใช้ fetch() เพื่อดึง event stream แทน EventSource ได้

ข้อจำกัดของ EventSource คือเราไม่สามารถใส่ custom headers ได้ มันจะใช้ได้กับ api แบบปกติที่เราไม่ได้ต้องการใส่ headers ได้ แต่ก็ยังดีมันสามารถใส่ query param ได้นะ เพราะมันเป็นส่วนหนึ่งของ url ถ้าเกิดว่า api ของเราต้องการ bearer token ก็จบเลย ไม่สามาถใช้ EventSource ได้แล้ว เราจะต้องกลับมาใช้ fetch() ที่เราคุ้นเคยแทน

แต่พอมาใช้ fetch() จะสูญเสียความสามารถในการ recconection, parsing SSE Format ไป เราต้องจัดการเองละทีนี้

เท่ากับว่าเราจะได้ raw string จาก SSE มา
ตัวอย่าง raw string ที่จะได้ จาก Server ที่เราทำ

Terminal window
id: 0\n
event: chat_delta\n
data: {"id":"chatcmpl-9abc123","type":"message.delta","index":0,"delta":{"role":"assistant","content":"Sure, I can help with that."},"created":1730876800,"model":"gpt-5.1-mini"}\n
\n\n
id: 1\n
event: chat_delta\n
data: {"id":"chatcmpl-9abc123","type":"message.delta","index":1,"delta":{"content":" First we'll look at the server implementation, "}}\n
\n\n
id: 2\n
event: chat_delta\n
data: {"id":"chatcmpl-9abc123","type":"message.delta","index":2,"delta":{"content":"then we'll build a small React client."}}\n
\n\n
id: 3\n
event: chat_delta\n
data: {"id":"chatcmpl-9abc123","type":"message.completed","index":3,"done":true,"usage":{"input_tokens":128,"output_tokens":256,"total_tokens":384}}\n
\n\n
event: chat_completed\n
data: [DONE]\n
\n\n

จากตัวอย่างด้านบน เราไม่ได้ได้ data มาทั้งหมดในทีเดียวนะ
เราจะได้มาทีละชุด (chunk) ตามที่เราได้ทำไปใน Server น่ะแหละ
แต่ละ chunk จะคั่นด้วย \n\n นะ

ส่วนภายใน 1 chunk จะคั่นด้วย \n จะมี id, event และ data

เราจะมาสร้าง function ทีเอาไว้แกะ data แต่ละชุดกันก่อน โดยจะแกะ text ที่ครบ 1 ชุดเต็มๆเท่านั้น

โดยเรารับ parameter 1 ตัว ที่มีหน้าตาแบบนี้

Terminal window
id: 0\n
event: chat_delta\n
data: {"id":"chatcmpl-9abc123","type":"message.delta","index":0,"delta":{"role":"assistant","content":"Sure, I can help with that."},"created":1730876800,"model":"gpt-5.1-mini"}\n
function parseSSEEvent(event: string) {
const lines = event.split("\n");
const raw = lines.reduce(
(acc, cur) => {
if (cur.startsWith("data: ")) {
acc.data = cur.replace("data: ", "");
}
if (cur.startsWith("id: ")) {
acc.id = cur.replace("id: ", "");
}
if (cur.startsWith("event: ")) {
acc.event = cur.replace("event: ", "");
}
return acc;
},
{
id: undefined,
event: undefined,
data: undefined,
} as {
id?: string;
event?: string;
data?: string;
},
);
if (!raw.data) return null;
const done = raw.data === "[DONE]";
if (done) return null;
return {
...raw,
data: JSON.parse(raw.data) as Chunk,
};
}

จากโค้ดด้านบน สร้าง function parseSSEEvent() ที่จะ return

// ต้องการให้ return แบบนี้
type ReturnValue = {
data: Chunk;
id?: string;
event?: string;
} | null;
  • บรรทัดที่ 2 แยกทั้งชุดออกด้วย .split("\n") เราจะได้ array ของ string มา
  • จากหลายๆบรรทัด สุดท้ายอยากให้เหลือแค่ id, event, data ผมก็เลยจะใช้ .reduce()
  • บรรทัดที่ 4-27 แล้วดูว่าแต่ละ item จากการ split มีคำขึ้นต้นด้วย "data: ", "id: " หรือ "event: " ไหม ถ้ามีก็แค่ใส่ค่ามันลงไปใน accumulotor ของ reduce
  • บรรทัดที่ 28 ถ้าไม่มี data เราก็เอาไปทำอะไรไม่ได้ ก็เลยให้ return null ไปเลย
  • บรรทัดที่ 30 ถ้า data เป็นคำว่า [DONE] ก็ถือว่าจบแล้ว ก็ไม่ได้ทำอะไรต่อ ก็เลยให้ null เหมือนกัน
  • สุดท้ายก็ return แต่ว่าในส่วนของ data ก็ใช้ JSON.parse ด้วยเลย

ส่วน Chunk มี type เป็นแบบนี้ ตามที่ฝั่ง Server เราได้ทำไว้น่ะแหละ

type Chunk = {
id: string;
type: "message.delta" | "message.completed";
index: number;
delta?: {
role?: string | undefined;
content: string;
};
created: number;
model: string;
};

ทีนี้ก็พร้อมละ มาทำ React สำหรับ consume stream ผ่าน SSE กัน

App.tsx
function RouteComponent() {
const [text, setText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const startStream = useCallback(async () => {
setIsLoading(true);
setText("");
const response = await fetch("http://localhost:3000", {
headers: {
// for example only
Authorization: "Bearer jwt-token",
},
});
if (!response.ok || !response.body) {
throw new Error(`Request failed with status ${response.status}`);
}
const decoder = new TextDecoder("utf-8");
const reader = response.body.getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
// console.log("events", events);
for (const event of events) {
if (event.trim() === "") continue;
const data = parseSSEEvent(event);
if (data === null) continue;
if (data.data.type === "message.completed") continue;
setText((prev) => prev + data.data.delta?.content);
}
}
setIsLoading(false);
}, []);
return (
<div className="text-center">
<button
type="button"
onClick={startStream}
disabled={isLoading}
className="p-2 bg-orange-200 rounded-xl"
>
{isLoading ? "Streaming..." : "Start Stream"}
</button>
<div style={{ marginTop: "20px", whiteSpace: "pre-wrap" }}>
{text}
</div>
</div>
);
}

อธิบายโค้ดด้านบน

  • บรรทัด 9-14 ก็ใช้ fetch() แทน EventStream ละ ผมใส่ตัวอย่าง custom header มาด้วย เพื่อบอกว่าถ้าเป็น custom header ลักษณะนี้จะใช้ EventSource ไม่ได้ละ
  • บรรทัด 16-18 ไม่มีอะไร ก็เช็คว่า response ok ไหม มี body ไหม ถ้าไม่มีก็ Error ทำงานต่อไม่ได้ละ
  • บรรทัด 23 คือตัวแปร buffer คือเอาไว้เก็บ string ที่ได้รับมาจาก event ก่อน พอมี event เข้ามาจะได้เป็น raw string ใช่มะ เราเอามาต่อแบบดื้อๆเลย แล้วค่อยลองแกะด้วย \n\n ภายหลัง ต่อไป
    ที่ต้องมี buffer ก็เพราะว่า ใน 1 chunk ที่ได้รับมาจาก SSE มันอาจจะยังไม่ครบชุดมันก็ได้ ทำให้เราต้องเอาหลายๆ chunk มาต่อกันก่อน แล้วทุกๆครั้งที่เอา chunk มาต่อกัน เราก็จะลองดูซิว่ามันลงท้ายด้วย \n\n หรือยัง ถ้ามี \n\n โผล่มานั่นหมายความว่ามันครบชุดแล้ว สามารถเอาไปแกะเพื่อแสดงผลต่อได้ละ
  • บรรทัด 25 คือใช้ while loop วนไปเรื่อยๆจนจบการ stream หรือมีปัญหาก็ break
  • เราจะใช้ reader.read() เหมือนเดิม
  • บรรทัดที่ 29 พอได้ value มาแล้ว ก็เอามา decode() แล้วรวมเข้ากับ buffer เอาไปต่อท้ายได้เลย
  • บรรทัดที่ 30 เราจะลองแกะดูว่า split ด้วย \n\n ได้หรือยัง ถ้าได้แล้วจะได้ array มา ถ้าไม่ได้ก็จะได้ array มาเหมือนกันแต่มีแค่ item เดียว แล้วเก็บไว้ในตัวแปร events
  • บรรทัดที่ 32 เราจะเอาตัวสุดท้ายออกไป เก็บไว้ใน buffer ส่วนตัวที่เหลือจะยังอยู่ในตัวแปร events
    ทำไมต้องเอาตัวสุดท้ายมาเก็บลงในตัวแปร buffer
    เพราะว่า เมื่อเรา split \n\n ออกมาแล้ว จะได้ array ที่มี items หลายๆตัวคือมี text ที่ครบชุดหลายๆชุดเลย text ชุดเต็มๆ จะอยู่ใน items ด้านหน้า ส่วนตัวสุดท้าย อาจจะเป็น text ที่ยังไม่ครบชุด ยังต้องรอ chunk อื่นๆมาต่อท้ายมันต่อไป เราก็เลยเอาไปเก็บใน buffer เหมือนเดิม เพื่อรอ chunk อื่นๆมาต่อท้ายมัน แล้วจะลอง split ด้วย \n\n ในรอบถัดไป ส่วน text ชุดเต็มๆจะอยู่ใน events พร้อมเอาไปแกะ เพื่อแสดงผลต่องไป
  • บรรทัดที่ 35-41 ก็เอา events ทั้งหมดมา loop เพื่อแกะ data แล้วเอาใส่ลงใน setText() ต่อไป ไม่มีอะไรซับซ้อนแล้ว

ลองรันแล้วดูผลลัพธ์กัน


Thank you

จบแล้วครับ
สำหรับใครที่อ่านมาจนจบ ผมก็ขอบคุณมากๆ หวังว่าจะเข้าใจมากขึ้นนะครับว่า Stream มันทำงานอย่างไรในเบื้องหลังผ่านตัวอย่างของผมนะครับ สำหรับ blog นี้ผมใช้เวลาเขียนอยู่นานเลย ก็ว่างบ้าง ไม่ว่างบ้าง ค่อยๆทำไปเรื่อยๆ ฮ่าๆ ค่อนข้างยาวและค่อนข้างยาก สำหรับใครที่อ่านเฉยๆ แต่ยังไม่เข้าใจ ผมแนะนำให้ลองไปเขียนตามดูครับ จะเข้าใจมากขึ้นแน่นอน

ยุค AI เข้ามาแล้ว(นานแล้ว) ทำให้ Fundamentals ยิ่งสำคัญมากขึ้นไปอีก ไม่งั้นจะอยู่ไม่รอดเอา ผมเองก็ดิ้นรนเช่นกัน ก็เลยมาเขียน blog แนวนี้มากขึ้น เราจะได้อยู่รอดไปด้วยกันครับ

ยังไงขอขอบคุณทุกคนที่อ่านมาจนถึงตรงนี้

BYE 👋🏼


Crafted with care 📝❤️ ✨ bycode sook logoCodeSook