Files
makelore/electron/coding-runtime/pi/rpc-framer.ts

99 lines
3.2 KiB
TypeScript

import { PiProcessError } from './process-errors';
// Pi caps each inline image at 4.5 MiB of base64, and one RPC record can contain
// multiple image results (for example an agent_end batch or get_entries response).
// Keep a finite transport bound while allowing normal parallel image inspection.
export const DEFAULT_MAX_PI_RPC_LINE_BYTES = 32 * 1024 * 1024;
type StrictLfJsonlFramerOptions = {
maxLineBytes?: number;
onRecord(record: unknown): void;
};
function protocolError(message: string): PiProcessError {
return new PiProcessError('PI_RPC_PROTOCOL_ERROR', message);
}
export class StrictLfJsonlFramer {
private readonly decoder = new TextDecoder('utf-8', { fatal: true });
private readonly maxLineBytes: number;
private readonly onRecord: (record: unknown) => void;
private bufferedChunks: Buffer[] = [];
private bufferedBytes = 0;
private finished = false;
constructor(options: StrictLfJsonlFramerOptions) {
const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_PI_RPC_LINE_BYTES;
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes <= 0) {
throw new Error('maxLineBytes must be a positive safe integer');
}
this.maxLineBytes = maxLineBytes;
this.onRecord = options.onRecord;
}
push(chunk: Uint8Array | string): void {
if (this.finished) throw protocolError('Pi RPC stdout continued after stream end');
const bytes = typeof chunk === 'string'
? Buffer.from(chunk, 'utf8')
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
let offset = 0;
while (offset < bytes.length) {
const newline = bytes.indexOf(0x0a, offset);
if (newline === -1) {
this.append(bytes.subarray(offset));
return;
}
this.append(bytes.subarray(offset, newline));
this.emitLine();
offset = newline + 1;
}
}
finish(): void {
if (this.finished) return;
this.finished = true;
if (this.bufferedBytes > 0) {
throw protocolError('Pi RPC stdout ended with a partial line');
}
}
private append(segment: Uint8Array): void {
const nextBytes = this.bufferedBytes + segment.byteLength;
if (nextBytes > this.maxLineBytes) {
throw protocolError(`Pi RPC stdout line exceeded ${this.maxLineBytes} bytes`);
}
if (segment.byteLength === 0) return;
this.bufferedChunks.push(Buffer.from(segment));
this.bufferedBytes = nextBytes;
}
private emitLine(): void {
let line = this.bufferedChunks.length === 1
? this.bufferedChunks[0]
: Buffer.concat(this.bufferedChunks, this.bufferedBytes);
this.bufferedChunks = [];
this.bufferedBytes = 0;
if (line.at(-1) === 0x0d) line = line.subarray(0, -1);
if (line.length === 0) throw protocolError('Pi RPC stdout emitted a blank line');
let source: string;
try {
source = this.decoder.decode(line);
} catch (error) {
throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', 'Pi RPC stdout was not valid UTF-8', {
cause: error,
});
}
try {
this.onRecord(JSON.parse(source));
} catch (error) {
if (error instanceof PiProcessError) throw error;
throw new PiProcessError('PI_RPC_PROTOCOL_ERROR', 'Pi RPC stdout contained malformed JSON', {
cause: error,
});
}
}
}