// JSONL framing for the engine pipe and the control socket (#1507, CHAT-03 §1). // // Records are split on LF (0x0A) only, and a trailing CR is stripped (rpc.md // lines 30–38). Node's readline is not used, because it also splits on U+2028 // and U+2029, which JSON strings may carry raw (E2). Splitting happens on // bytes, before UTF-8 decoding, so a multibyte character split across two // chunks is never damaged. export const MAX_LINE_BYTES = 64 * 1024 * 1024; export class LineSplitter { constructor(onLine, { maxBytes = MAX_LINE_BYTES, onOverflow = null } = {}) { this.onLine = onLine; this.maxBytes = maxBytes; this.onOverflow = onOverflow; this.parts = []; this.size = 0; this.overflowed = false; } push(chunk) { if (this.overflowed) return; let start = 0; for (;;) { const lf = chunk.indexOf(0x0a, start); if (lf === -1) break; this.parts.push(chunk.subarray(start, lf)); const line = Buffer.concat(this.parts); this.parts = []; this.size = 0; const end = line.length > 0 && line[line.length - 1] === 0x0d ? line.length - 1 : line.length; this.onLine(line.subarray(0, end).toString("utf8")); start = lf + 1; } if (start < chunk.length) { const rest = chunk.subarray(start); this.size += rest.length; if (this.size > this.maxBytes) { this.overflowed = true; this.parts = []; this.onOverflow?.(); return; } this.parts.push(Buffer.from(rest)); } } // Bytes left without a terminating LF when the stream ends. They are never // parsed as a record: a partial line is a transport gap, not data. pending() { return this.size; } } export function encodeLine(value) { return JSON.stringify(value) + "\n"; } // Parses one line. Returns {value} or {error}; never throws. export function parseLine(line) { try { const value = JSON.parse(line); if (value === null || typeof value !== "object" || Array.isArray(value)) return { error: "not-an-object" }; return { value }; } catch { return { error: "unparseable" }; } }