Skip to content
57 changes: 43 additions & 14 deletions src/shared/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,65 @@ import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
*/
export class ReadBuffer {
private _buffer?: Buffer;
private _validLines: object[] = [];
private _lastIncompleteLine: string = '';

append(chunk: Buffer): void {
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
this._processChunk(chunk);
}

readMessage(): JSONRPCMessage | null {
if (!this._buffer) {
if (this._validLines.length === 0) {
return null;
}

const index = this._buffer.indexOf('\n');
if (index === -1) {
return null;
}

const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
this._buffer = this._buffer.subarray(index + 1);
const line = this._validLines.shift()!;
return deserializeMessage(line);
}

clear(): void {
this._buffer = undefined;
this._validLines = [];
this._lastIncompleteLine = '';
}

private _processChunk(newChunk: Buffer): void {
// Combine any previously incomplete line with the new chunk
const combinedText = this._lastIncompleteLine + newChunk.toString('utf8');
const newLines = combinedText.split('\n');

// The last element may be incomplete, so store it for the next chunk
this._lastIncompleteLine = newLines.pop() ?? '';
const completedLines = newLines.map(safeJsonParse).filter(Boolean) as object[];
this._validLines.push(...completedLines);
}
}

/**
* Safely parses a JSON string, returning false if parsing fails.
* @param line The JSON string to parse.
* @returns The parsed object, or false if parsing failed.
*/
function safeJsonParse(line: string): object | false {
try {
return JSON.parse(line);
} catch {
return false;
}
}

export function deserializeMessage(line: string): JSONRPCMessage {
return JSONRPCMessageSchema.parse(JSON.parse(line));
/**
* Deserializes a JSON-RPC message from object.
* @param line The object to deserialize.
* @returns The deserialized JSON-RPC message.
*/
export function deserializeMessage(line: object): JSONRPCMessage | null {
return JSONRPCMessageSchema.parse(line);
}

/**
* Serializes a JSON-RPC message to a string.
* @param message The JSON-RPC message to serialize.
* @returns The serialized JSON-RPC message string.
*/
export function serializeMessage(message: JSONRPCMessage): string {
return JSON.stringify(message) + '\n';
}
101 changes: 100 additions & 1 deletion test/shared/stdio.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JSONRPCMessage } from '../../src/types.js';
import type { JSONRPCMessage } from '../../src/types.js';
import { ReadBuffer } from '../../src/shared/stdio.js';

const testMessage: JSONRPCMessage = {
Expand Down Expand Up @@ -33,3 +33,102 @@ test('should be reusable after clearing', () => {
readBuffer.append(Buffer.from('\n'));
expect(readBuffer.readMessage()).toEqual(testMessage);
});

describe('non-JSON line filtering', () => {
test('should filter out non-JSON lines before a complete message', () => {
const readBuffer = new ReadBuffer();

// Append debug output followed by a valid JSON message
const mixedContent = 'Debug: Starting server\n' + 'Warning: Something happened\n' + JSON.stringify(testMessage) + '\n';

readBuffer.append(Buffer.from(mixedContent));

// Should only get the valid JSON message, debug lines filtered out
expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should filter out non-JSON lines mixed with multiple valid messages', () => {
const readBuffer = new ReadBuffer();

const message1: JSONRPCMessage = { jsonrpc: '2.0', method: 'method1' };
const message2: JSONRPCMessage = { jsonrpc: '2.0', method: 'method2' };

const mixedContent =
'Debug line 1\n' +
JSON.stringify(message1) +
'\n' +
'Debug line 2\n' +
'Another non-JSON line\n' +
JSON.stringify(message2) +
'\n';

readBuffer.append(Buffer.from(mixedContent));

expect(readBuffer.readMessage()).toEqual(message1);
expect(readBuffer.readMessage()).toEqual(message2);
expect(readBuffer.readMessage()).toBeNull();
});

test('should preserve incomplete JSON line at end of buffer', () => {
const readBuffer = new ReadBuffer();

// Append incomplete JSON (no closing brace or newline)
const incompleteJson = '{"jsonrpc": "2.0", "method": "test"';
readBuffer.append(Buffer.from(incompleteJson));

expect(readBuffer.readMessage()).toBeNull();

// Complete the JSON in next chunk
readBuffer.append(Buffer.from('}\n'));

const expectedMessage: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' };
expect(readBuffer.readMessage()).toEqual(expectedMessage);
});

test('should handle lines that start with { but do not end with }', () => {
const readBuffer = new ReadBuffer();

const content = '{incomplete\n' + JSON.stringify(testMessage) + '\n';

readBuffer.append(Buffer.from(content));

// Should only get the valid message, incomplete line filtered out
expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should handle lines that end with } but do not start with {', () => {
const readBuffer = new ReadBuffer();

const content = 'incomplete}\n' + JSON.stringify(testMessage) + '\n';

readBuffer.append(Buffer.from(content));

// Should only get the valid message, incomplete line filtered out
expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should filter out lines which looks like JSON but are not valid JSON', () => {
const readBuffer = new ReadBuffer();

const content = '{invalidJson: true}\n' + JSON.stringify(testMessage) + '\n';
readBuffer.append(Buffer.from(content));

// Should only get the valid message, invalid JSON line filtered out
expect(readBuffer.readMessage()).toEqual(testMessage);
expect(readBuffer.readMessage()).toBeNull();
});

test('should handle lines with leading/trailing whitespace around valid JSON', () => {
const readBuffer = new ReadBuffer();

const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' };
const content = ' ' + JSON.stringify(message) + ' \n';

readBuffer.append(Buffer.from(content));

expect(readBuffer.readMessage()).toEqual(message);
});
});
Loading