92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
import { PiProcessError } from './process-errors';
|
|
|
|
export const DEFAULT_MAX_PI_RPC_LINE_BYTES = 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 buffered = Buffer.alloc(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.buffered.length > 0) {
|
|
throw protocolError('Pi RPC stdout ended with a partial line');
|
|
}
|
|
}
|
|
|
|
private append(segment: Uint8Array): void {
|
|
if (this.buffered.length + segment.byteLength > this.maxLineBytes) {
|
|
throw protocolError(`Pi RPC stdout line exceeded ${this.maxLineBytes} bytes`);
|
|
}
|
|
if (segment.byteLength === 0) return;
|
|
this.buffered = this.buffered.length === 0
|
|
? Buffer.from(segment)
|
|
: Buffer.concat([this.buffered, segment], this.buffered.length + segment.byteLength);
|
|
}
|
|
|
|
private emitLine(): void {
|
|
let line = this.buffered;
|
|
this.buffered = Buffer.alloc(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,
|
|
});
|
|
}
|
|
}
|
|
}
|