Skip to content

Commit c84ddde

Browse files
author
Sahar Shemesh
committed
refactor: filter non-json line while appending
1 parent a807cf7 commit c84ddde

File tree

2 files changed

+126
-22
lines changed

2 files changed

+126
-22
lines changed

src/shared/stdio.test.ts

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { ZodError } from 'zod/v4';
2-
import { JSONRPCMessage } from '../types.js';
1+
import type { JSONRPCMessage } from '../types.js';
32
import { ReadBuffer } from './stdio.js';
43

54
const testMessage: JSONRPCMessage = {
@@ -35,16 +34,92 @@ test('should be reusable after clearing', () => {
3534
expect(readBuffer.readMessage()).toEqual(testMessage);
3635
});
3736

38-
test('should override invalid json message and return null', () => {
39-
const readBuffer = new ReadBuffer();
37+
describe('non-JSON line filtering', () => {
38+
test('should filter out non-JSON lines before a complete message', () => {
39+
const readBuffer = new ReadBuffer();
4040

41-
readBuffer.append(Buffer.from('invalid message\n'));
42-
expect(readBuffer.readMessage()).toBeNull();
43-
});
41+
// Append debug output followed by a valid JSON message
42+
const mixedContent = 'Debug: Starting server\n' +
43+
'Warning: Something happened\n' +
44+
JSON.stringify(testMessage) + '\n';
45+
46+
readBuffer.append(Buffer.from(mixedContent));
47+
48+
// Should only get the valid JSON message, debug lines filtered out
49+
expect(readBuffer.readMessage()).toEqual(testMessage);
50+
expect(readBuffer.readMessage()).toBeNull();
51+
});
52+
53+
test('should filter out non-JSON lines mixed with multiple valid messages', () => {
54+
const readBuffer = new ReadBuffer();
55+
56+
const message1: JSONRPCMessage = { jsonrpc: '2.0', method: 'method1' };
57+
const message2: JSONRPCMessage = { jsonrpc: '2.0', method: 'method2' };
58+
59+
const mixedContent = 'Debug line 1\n' +
60+
JSON.stringify(message1) + '\n' +
61+
'Debug line 2\n' +
62+
'Another non-JSON line\n' +
63+
JSON.stringify(message2) + '\n';
64+
65+
readBuffer.append(Buffer.from(mixedContent));
66+
67+
expect(readBuffer.readMessage()).toEqual(message1);
68+
expect(readBuffer.readMessage()).toEqual(message2);
69+
expect(readBuffer.readMessage()).toBeNull();
70+
});
71+
72+
test('should preserve incomplete JSON line at end of buffer', () => {
73+
const readBuffer = new ReadBuffer();
74+
75+
// Append incomplete JSON (no closing brace or newline)
76+
const incompleteJson = '{"jsonrpc": "2.0", "method": "test"';
77+
readBuffer.append(Buffer.from(incompleteJson));
78+
79+
expect(readBuffer.readMessage()).toBeNull();
80+
81+
// Complete the JSON in next chunk
82+
readBuffer.append(Buffer.from('}\n'));
83+
84+
const expectedMessage: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' };
85+
expect(readBuffer.readMessage()).toEqual(expectedMessage);
86+
});
87+
88+
test('should handle lines that start with { but do not end with }', () => {
89+
const readBuffer = new ReadBuffer();
90+
91+
const content = '{incomplete\n' +
92+
JSON.stringify(testMessage) + '\n';
93+
94+
readBuffer.append(Buffer.from(content));
95+
96+
// Should only get the valid message, incomplete line filtered out
97+
expect(readBuffer.readMessage()).toEqual(testMessage);
98+
expect(readBuffer.readMessage()).toBeNull();
99+
});
100+
101+
test('should handle lines that end with } but do not start with {', () => {
102+
const readBuffer = new ReadBuffer();
103+
104+
const content = 'incomplete}\n' +
105+
JSON.stringify(testMessage) + '\n';
106+
107+
readBuffer.append(Buffer.from(content));
108+
109+
// Should only get the valid message, incomplete line filtered out
110+
expect(readBuffer.readMessage()).toEqual(testMessage);
111+
expect(readBuffer.readMessage()).toBeNull();
112+
});
113+
114+
test('should handle lines with leading/trailing whitespace around valid JSON', () => {
115+
const readBuffer = new ReadBuffer();
116+
117+
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' };
118+
const content = ' ' + JSON.stringify(message) + ' \n';
119+
120+
readBuffer.append(Buffer.from(content));
121+
122+
expect(readBuffer.readMessage()).toEqual(message);
123+
});
44124

45-
test('should throw validation error on invalid JSON-RPC message', () => {
46-
const readBuffer = new ReadBuffer();
47-
const invalidJsonRpcMessage = '{"jsonrpc":"2.0","method":123}\n';
48-
readBuffer.append(Buffer.from(invalidJsonRpcMessage));
49-
expect(() => readBuffer.readMessage()).toThrowError(ZodError);
50125
});

src/shared/stdio.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export class ReadBuffer {
77
private _buffer?: Buffer;
88

99
append(chunk: Buffer): void {
10-
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
10+
this._buffer = filterNonJsonLines(this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk);
1111
}
1212

1313
readMessage(): JSONRPCMessage | null {
@@ -30,18 +30,47 @@ export class ReadBuffer {
3030
}
3131
}
3232

33+
/**
34+
* Filters out any lines that are not valid JSON objects from the given buffer.
35+
* Retains the last line in case it is incomplete.
36+
* @param buffer The buffer to filter.
37+
* @returns A new buffer containing only valid JSON object lines and the last line.
38+
*/
39+
function filterNonJsonLines(buffer: Buffer): Buffer {
40+
const text = buffer.toString('utf8');
41+
const lines = text.split('\n');
42+
43+
// Pop the last line - it may be incomplete (no trailing newline yet)
44+
const incompleteLine = lines.pop() ?? '';
45+
46+
// Filter complete lines to only keep those that look like JSON objects
47+
const validLines = lines.filter(looksLikeJson);
48+
49+
// Reconstruct: valid JSON lines + incomplete line
50+
const filteredText = validLines.length > 0 ? validLines.join('\n') + '\n' + incompleteLine : incompleteLine;
51+
52+
return Buffer.from(filteredText, 'utf8');
53+
}
54+
55+
function looksLikeJson(line: string): boolean {
56+
const trimmed = line.trim();
57+
return trimmed.startsWith('{') && trimmed.endsWith('}');
58+
}
59+
60+
/**
61+
* Deserializes a JSON-RPC message from a string.
62+
* @param line The string to deserialize.
63+
* @returns The deserialized JSON-RPC message.
64+
*/
3365
export function deserializeMessage(line: string): JSONRPCMessage | null {
34-
try {
35-
return JSONRPCMessageSchema.parse(JSON.parse(line));
36-
} catch (error: unknown) {
37-
// When non-JSON messages are received, we simply ignore them.
38-
if (error instanceof SyntaxError) {
39-
return null;
40-
}
41-
throw error;
42-
}
66+
return JSONRPCMessageSchema.parse(JSON.parse(line));
4367
}
4468

69+
/**
70+
* Serializes a JSON-RPC message to a string.
71+
* @param message The JSON-RPC message to serialize.
72+
* @returns The serialized JSON-RPC message string.
73+
*/
4574
export function serializeMessage(message: JSONRPCMessage): string {
4675
return JSON.stringify(message) + '\n';
4776
}

0 commit comments

Comments
 (0)