Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion src/shared/stdio.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JSONRPCMessage } from '../types.js';
import type { JSONRPCMessage } from '../types.js';
import { ReadBuffer } from './stdio.js';

const testMessage: JSONRPCMessage = {
Expand Down Expand Up @@ -33,3 +33,91 @@ 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 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);
});
});
41 changes: 39 additions & 2 deletions src/shared/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class ReadBuffer {
private _buffer?: Buffer;

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

readMessage(): JSONRPCMessage | null {
Expand All @@ -30,10 +30,47 @@ export class ReadBuffer {
}
}

export function deserializeMessage(line: string): JSONRPCMessage {
/**
* Filters out any lines that are not valid JSON objects from the given buffer.
* Retains the last line in case it is incomplete.
* @param buffer The buffer to filter.
* @returns A new buffer containing only valid JSON object lines and the last line.
*/
function filterNonJsonLines(buffer: Buffer): Buffer {
const text = buffer.toString('utf8');
const lines = text.split('\n');

// Pop the last line - it may be incomplete (no trailing newline yet)
const incompleteLine = lines.pop() ?? '';

// Filter complete lines to only keep those that look like JSON objects
const validLines = lines.filter(looksLikeJson);

// Reconstruct: valid JSON lines + incomplete line
const filteredText = validLines.length > 0 ? validLines.join('\n') + '\n' + incompleteLine : incompleteLine;

return Buffer.from(filteredText, 'utf8');
}

function looksLikeJson(line: string): boolean {
const trimmed = line.trim();
return trimmed.startsWith('{') && trimmed.endsWith('}');
}

/**
* Deserializes a JSON-RPC message from a string.
* @param line The string to deserialize.
* @returns The deserialized JSON-RPC message.
*/
export function deserializeMessage(line: string): JSONRPCMessage | null {
return JSONRPCMessageSchema.parse(JSON.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';
}
Loading