Bun.JSONL.parseChunk: อ่าน JSONL ทีละก้อนโดยไม่ทำ record ขาด
JSONL หรือ JSON Lines วาง JSON หนึ่งค่าต่อหนึ่งบรรทัด จึงเหมาะกับ log และ stream แต่ network chunk ไม่รับประกันว่าจะจบตรง newline หนึ่ง record อาจถูกตัดครึ่งระหว่างชื่อ field หรืออักขระ UTF-8 ถ้าเรา split("\n") ทุกก้อนแล้ว parse ทันที จะมองครึ่งท้ายเป็น JSON เสีย Bun.JSONL.parseChunk จึงคืนทั้งค่าที่สมบูรณ์และตำแหน่งที่อ่านถึง เพื่อให้เก็บเศษไว้ต่อกับก้อนหน้าครับ
WHY: ขอบ packet ไม่ใช่ขอบ record
ให้นึกถึงซองเอกสารไหลผ่านหน้าต่าง เจ้าหน้าที่ส่งต่อเฉพาะซองที่ปิดครบ ส่วนซองที่ยังขาดมุมถูกเก็บไว้รอชิ้นถัดไป ค่า read คือระยะที่ส่งต่อได้แล้ว values คือ record ที่สำเร็จ done บอกว่ากิน input หมดหรือยัง และ error แยก malformed JSON ออกจากข้อมูลที่แค่ยังมาไม่ครบ
HOW: ต่อ remainder แล้วตัดเฉพาะส่วนที่อ่านสำเร็จ
type Event = { id: number; state: string };
function parseChunks(chunks: string[], maxBuffered = 1024): Event[] {
let buffer = "";
const events: Event[] = [];
for (const chunk of chunks) {
buffer += chunk;
if (buffer.length > maxBuffered) {
throw new Error("JSONL record exceeds buffer limit");
}
const result = Bun.JSONL.parseChunk(buffer);
if (result.error) throw result.error;
events.push(...(result.values as Event[]));
buffer = buffer.slice(result.read);
}
if (buffer.trim().length !== 0) {
throw new Error("stream ended with an incomplete JSONL record");
}
return events;
}
const events = parseChunks([
'{"id":1,"state":"ready"}\n{"id":',
'2,"state":"done"}\n',
]);
if (events.length !== 2 || events[1]?.state !== "done") {
throw new Error(`unexpected events: ${JSON.stringify(events)}`);
}
let rejected = false;
try {
parseChunks(['{"id":3']);
} catch {
rejected = true;
}
if (!rejected) throw new Error("incomplete tail must be rejected");
console.log("parseChunk checks passed");
ตัวอย่างยอมให้ท้าย stream เหลือเฉพาะ whitespace แต่ถือว่า tail ที่ยังมีเนื้อหาคือข้อมูลไม่ครบ นโยบายจริงอาจยอมรับ JSON record สุดท้ายที่ไม่มี newline แต่ต้องกำหนดชัดและ parse รอบสุดท้ายอย่างระมัดระวัง อย่าเงียบแล้วทิ้ง tail เพราะจะทำให้ข้อมูลหายแบบตรวจยาก
read เป็นหน่วยอะไร
เมื่อส่ง string ค่า read คือจำนวน character ที่กินแล้ว ใช้ buffer.slice(result.read) ได้ตรง ๆ แต่เมื่อส่ง Uint8Array มันเป็น byte offset ใน buffer ต้นฉบับและควรใช้ subarray(result.read) เอกสาร Bun ระบุว่าทาง byte ใช้ต่อแบบ zero-copy ได้ แต่การเอา offset ของ byte ไป slice string หรือกลับกันจะทำให้ตัดผิดตำแหน่ง โดยเฉพาะข้อความไทยหลาย byte
parseChunk ไม่ throw สำหรับ malformed JSON แบบเดียวกับ parse() แต่วาง SyntaxError ไว้ใน error พร้อม values ที่ parse สำเร็จก่อนหน้า จึงต้องตัดสินใจ transactional policy: จะ commit record ก่อน error หรือทิ้งทั้ง batch ตัวอย่างเลือก fail-fast ก่อนส่งผลออกนอกฟังก์ชัน แต่ในระบบ streaming จริงอาจต้องมี dead-letter queue และ offset checkpoint
ป้องกัน buffer โตไม่จบ
ผู้ส่งสามารถส่ง record ยาวโดยไม่ปิด newline ทำให้ remainder โตทุก chunk จนกิน memory ต้องมีเพดานต่อ record, เพดานรวม, timeout และ backpressure ขนาดในตัวอย่างนับ character ไม่ใช่ byte; ถ้าขอบความปลอดภัยอิงปริมาณ network ให้ใช้ Uint8Array และจำกัด byte โดยตรง
อย่า cast เป็น Event[] แล้วเชื่อ schema ทันที JSON parsing ยืนยัน syntax ไม่ได้ยืนยันชนิดหรือช่วงค่า หลัง parse ต้อง validate shape ก่อนใช้กับฐานข้อมูล คำสั่ง หรือ path และห้าม log record ดิบที่อาจมี token หรือข้อมูลส่วนตัว
Recap
- ต่อ chunk เข้ากับ remainder ก่อน parse
- ตัด buffer ด้วย
readเท่านั้น - แยกหน่วย character ของ string กับ byte ของ
Uint8Array - ตรวจ
errorและกำหนดว่าจะ commit values ก่อน error หรือไม่ - จำกัด record size, buffer, timeout และ validate schema หลัง parse
- จัดการ tail ตอน EOF อย่างชัดเจน ห้ามทิ้งเงียบ ๆ
ทดสอบด้วย chunk ที่ตัดกลาง key, กลางค่า และก่อน newline ครับ ถ้าผลเหมือนการ parse input ก้อนเต็ม เราจึงมั่นใจว่า boundary ของ transport ไม่เปลี่ยนความหมายของข้อมูล