Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206/** * JSONL framing — LF-delimited records (PROTOCOL §2.1). * * **Split on `\n` only.** Never use Node `readline`: it splits on U+2028 / U+2029, which are * valid inside JSON strings and would silently corrupt payloads. Tolerate a single trailing * `\r` per line (CRLF input). One JSON object per line. * * `splitJsonl` is a pure one-shot splitter for fully-buffered input. `JsonlDecoder` is a * stateful streaming decoder that reassembles records split across arbitrary chunk * boundaries. */import { Type, type Static } from '@sinclair/typebox';
/** * Error thrown by the framing layer. Carries a stable `code` (PROTOCOL §8 registry values * where applicable) so callers can surface it as a command error or async error event * without inventing their own taxonomy. Wire-safe — never an `Error` object on the wire. */export class FramingError extends Error { readonly code: 'INVALID_REQUEST' | 'PROTOCOL_ERROR'; constructor( code: 'INVALID_REQUEST' | 'PROTOCOL_ERROR', message: string, ) { super(message); this.name = 'FramingError'; this.code = code; }}
/** * A raw JSONL record line plus its parse result. `parseError` is set when the line was not * valid JSON; callers decide whether to skip or surface a `PROTOCOL_ERROR`. */export interface JsonlRecord { /** The raw line text, with the terminating `\n` and any single trailing `\r` stripped. */ line: string; /** The parsed value, or `undefined` if JSON.parse threw. */ value: unknown; /** The parse error message, if `value` is undefined. */ parseError?: string;}
/** * Split fully-buffered JSONL input into record lines (§2.1). * * Splits on `\n` only. A single trailing `\r` on each line is stripped (CRLF tolerance). * The final line is included even without a trailing `\n` (it is still a record). Empty * lines (zero-length after `\r` strip) are dropped — they carry no record. * * This function does NOT parse JSON; use `parseJsonl` for that, or `JsonlDecoder` for * streaming. Splitting and parsing are deliberately separate so a caller can inspect raw * lines (e.g. for logging) before deciding to parse. * * @param input the buffered JSONL text * @returns the non-empty record lines, `\r` stripped, in order */export function splitJsonl(input: string): string[] { const lines: string[] = []; // Split on \n only — U+2028 / U+2029 are deliberately NOT line separators here. const parts = input.split('\n'); // If input ended with \n, the final element is '' (no trailing record) — drop it. // If input did NOT end with \n, the final element is the last (incomplete-buffer) record. for (let i = 0; i < parts.length; i++) { let line = parts[i]!; // Tolerate a single trailing \r (CRLF input). Do NOT strip interior \r. if (line.endsWith('\r')) { line = line.slice(0, -1); } if (line.length > 0) { lines.push(line); } } return lines;}
/** * Parse fully-buffered JSONL input into records (split + parse). * * Each non-empty line is JSON.parse'd; parse failures are reported per-record without * aborting the whole stream (one bad line shouldn't lose the surrounding records). */export function parseJsonl(input: string): JsonlRecord[] { return splitJsonl(input).map((line) => { try { return { line, value: JSON.parse(line) }; } catch (err) { return { line, value: undefined, parseError: err instanceof Error ? err.message : String(err), }; } });}
/** * Encode a value as a JSONL record: `JSON.stringify(value) + '\n'`. * * `JSON.stringify` does not insert U+2028 / U+2029 as separators (it uses `,` / `:`), and it * escapes them inside strings, so the output is safe to split on `\n`. The terminator is * always a single `\n` (never CRLF). */export function encodeJsonl(value: unknown): string { // JSON.stringify never emits lone U+2028/U+2029 as record separators; inside-string // occurrences are escaped, so LF-splitting is safe. return `${JSON.stringify(value)}\n`;}
/** * Stateful streaming JSONL decoder (§2.1). * * Feed it arbitrary chunks (`write`); completed records surface via `drain()`. A partial * final line (no trailing `\n`) is buffered until the next chunk completes it. Records are * split on `\n` only (U+2028/U+2029 safe); a single trailing `\r` per line is stripped. * * Usage: * const dec = new JsonlDecoder(); * dec.write(chunk1); dec.parse() // -> [records...] * dec.write(chunk2); dec.parse() // -> [records...] * dec.end(); // flush the final unterminated line, if any * dec.parse(); // -> [final record...] */export class JsonlDecoder { private buffer = ''; private ended = false;
/** Append a chunk (string or Uint8Array decoded as UTF-8). */ write(chunk: string | Uint8Array): void { this.buffer += typeof chunk === 'string' ? chunk : decodeUtf8(chunk); }
/** * Consume completed records. Returns parsed `JsonlRecord`s for every line terminated by * `\n` since the last call. Partial trailing data stays buffered until `end()` flushes * it. Parse failures are reported per-record (see {@link JsonlRecord.parseError}). */ parse(): JsonlRecord[] { if (this.buffer.length === 0) return []; const records: JsonlRecord[] = []; let start = 0; for (let i = 0; i < this.buffer.length; i++) { if (this.buffer.charCodeAt(i) === 0x0a /* \n */) { let line = this.buffer.slice(start, i); if (line.endsWith('\r')) line = line.slice(0, -1); if (line.length > 0) records.push(parseLine(line)); start = i + 1; } } if (this.ended) { // Flush the final unterminated tail (no trailing \n) as one last record. let line = this.buffer.slice(start); if (line.endsWith('\r')) line = line.slice(0, -1); if (line.length > 0) records.push(parseLine(line)); this.buffer = ''; } else { // Keep the un-terminated tail. this.buffer = this.buffer.slice(start); } return records; }
/** * Mark the stream ended. The next `parse()` flushes any final unterminated line (no * trailing `\n`) as one last record. Calling `end` more than once is harmless. */ end(): void { this.ended = true; }
/** Whether any un-consumed partial bytes remain. */ get hasPending(): boolean { return this.buffer.length > 0; }}
/** Parse one already-stripped line into a JsonlRecord (never throws). */function parseLine(line: string): JsonlRecord { try { return { line, value: JSON.parse(line) }; } catch (err) { return { line, value: undefined, parseError: err instanceof Error ? err.message : String(err), }; }}
/** * Decode UTF-8 bytes to a string. Avoids pulling in `Buffer`/`TextDecoder` semantics that * differ across runtimes — `TextDecoder('utf-8')` is available in Bun and the browser. * Exposed for `JsonlDecoder.write(Uint8Array)`. */function decodeUtf8(bytes: Uint8Array): string { const dec = new TextDecoder('utf-8', { fatal: false }); return dec.decode(bytes);}
/** * Schema for a JSONL frame — the on-the-wire unit is one JSON object per line. Exported so * transports can reference the shape; the actual byte boundary is the `\n`. */export const JsonlFrameSchema = Type.String({ description: 'one JSON object, LF-terminated' });export type JsonlFrame = Static<typeof JsonlFrameSchema>;