Skip to content

Commit b68ff8d

Browse files
committed
Bump version to 0.2.18-fork in package.json. Refactor SubprocessCLITransport to handle large JSON responses and improve JSON parsing by manually processing data chunks. Enhance tests to validate handling of large JSON messages and responses split across multiple data chunks, ensuring robustness in streaming scenarios.
1 parent 2a60d5d commit b68ff8d

4 files changed

Lines changed: 463 additions & 1027 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@botanicastudios/claude-code-sdk-ts",
3-
"version": "0.2.17-fork",
3+
"version": "0.2.18-fork",
44
"description": "Unofficial TypeScript port of the official Python Claude Code SDK",
55
"type": "module",
66
"main": "dist/index.cjs",

src/_internal/transport/subprocess-cli.ts

Lines changed: 146 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -432,49 +432,163 @@ export class SubprocessCLITransport {
432432
});
433433
}
434434

435-
const rl = createInterface({
436-
input: this.process.stdout,
437-
crlfDelay: Infinity
438-
});
439-
440435
try {
441-
// Process stream-json format - each line is a JSON object
442-
for await (const line of rl) {
443-
const trimmedLine = line.trim();
444-
if (!trimmedLine) continue;
436+
// Handle large JSON responses that may exceed readline buffer limits
437+
// by manually parsing JSON from raw data instead of relying on line-by-line reading
438+
let buffer = '';
439+
const messages: CLIOutput[] = [];
440+
let processComplete = false;
441+
let parseError: Error | null = null;
442+
443+
// Set up data handler for manual JSON parsing
444+
const onData = (chunk: Buffer) => {
445+
buffer += chunk.toString();
446+
447+
// Try to parse complete JSON objects from the buffer
448+
let startIndex = 0;
449+
while (startIndex < buffer.length) {
450+
const openBrace = buffer.indexOf('{', startIndex);
451+
const openBracket = buffer.indexOf('[', startIndex);
452+
453+
// Find the first JSON object/array start
454+
let jsonStart = -1;
455+
if (openBrace !== -1 && openBracket !== -1) {
456+
jsonStart = Math.min(openBrace, openBracket);
457+
} else if (openBrace !== -1) {
458+
jsonStart = openBrace;
459+
} else if (openBracket !== -1) {
460+
jsonStart = openBracket;
461+
}
445462

446-
this.debugLog('DEBUG stdout:', trimmedLine);
463+
if (jsonStart === -1) break;
464+
465+
// Try to parse JSON starting from this position
466+
let depth = 0;
467+
let inString = false;
468+
let escaped = false;
469+
let jsonEnd = -1;
470+
471+
for (let i = jsonStart; i < buffer.length; i++) {
472+
const char = buffer[i];
473+
474+
if (escaped) {
475+
escaped = false;
476+
continue;
477+
}
478+
479+
if (char === '\\') {
480+
escaped = true;
481+
continue;
482+
}
483+
484+
if (char === '"') {
485+
inString = !inString;
486+
continue;
487+
}
488+
489+
if (!inString) {
490+
if (char === '{' || char === '[') {
491+
depth++;
492+
} else if (char === '}' || char === ']') {
493+
depth--;
494+
if (depth === 0) {
495+
jsonEnd = i + 1;
496+
break;
497+
}
498+
}
499+
}
500+
}
501+
502+
if (jsonEnd !== -1) {
503+
// Found complete JSON object
504+
const jsonStr = buffer.substring(jsonStart, jsonEnd);
447505

448-
try {
449-
const parsed = JSON.parse(trimmedLine) as CLIOutput;
450-
451-
// For non-keepAlive mode, close stdin when we receive a result message
452-
// This allows the CLI process to exit gracefully after completing the response
453-
if (
454-
!this.keepAlive &&
455-
(parsed as any).type === 'result' &&
456-
this.process?.stdin &&
457-
!this.process.stdin.destroyed
458-
) {
459506
this.debugLog(
460-
'DEBUG: [Transport] Received result message, closing stdin for non-keepAlive mode'
507+
'DEBUG stdout:',
508+
jsonStr.substring(0, 200) + (jsonStr.length > 200 ? '...' : '')
461509
);
462-
this.process.stdin.end();
510+
511+
try {
512+
const parsed = JSON.parse(jsonStr) as CLIOutput;
513+
514+
// For non-keepAlive mode, close stdin when we receive a result message
515+
if (
516+
!this.keepAlive &&
517+
(parsed as any).type === 'result' &&
518+
this.process?.stdin &&
519+
!this.process.stdin.destroyed
520+
) {
521+
this.debugLog(
522+
'DEBUG: [Transport] Received result message, closing stdin for non-keepAlive mode'
523+
);
524+
this.process.stdin.end();
525+
}
526+
527+
messages.push(parsed);
528+
} catch (error) {
529+
// If JSON parsing fails but it looks like JSON, capture error
530+
if (
531+
jsonStr.trim().startsWith('{') ||
532+
jsonStr.trim().startsWith('[')
533+
) {
534+
parseError = new CLIJSONDecodeError(
535+
`Failed to parse CLI output: ${error}`,
536+
jsonStr
537+
);
538+
return; // Stop processing more data
539+
}
540+
this.debugLog(
541+
'DEBUG: Skipping non-JSON data:',
542+
jsonStr.substring(0, 100)
543+
);
544+
}
545+
546+
// Remove processed JSON from buffer
547+
buffer = buffer.substring(jsonEnd);
548+
startIndex = 0;
549+
} else {
550+
// No complete JSON found, wait for more data
551+
break;
463552
}
553+
}
554+
};
464555

465-
yield parsed;
466-
} catch (error) {
467-
// Skip non-JSON lines (like Python SDK does)
468-
if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) {
469-
throw new CLIJSONDecodeError(
470-
`Failed to parse CLI output: ${error}`,
471-
trimmedLine
472-
);
556+
const onEnd = () => {
557+
processComplete = true;
558+
};
559+
560+
this.process.stdout.on('data', onData);
561+
this.process.stdout.on('end', onEnd);
562+
563+
// Yield messages as they become available
564+
let messageIndex = 0;
565+
while (!processComplete || messageIndex < messages.length) {
566+
// Check for parse errors first
567+
if (parseError) {
568+
throw parseError;
569+
}
570+
571+
if (messageIndex < messages.length) {
572+
const message = messages[messageIndex];
573+
if (message) {
574+
yield message;
473575
}
474-
continue;
576+
messageIndex++;
577+
} else {
578+
// Wait a bit for more messages
579+
await new Promise((resolve) => setTimeout(resolve, 10));
475580
}
476581
}
477582

583+
// Clean up event listeners
584+
this.process.stdout.removeListener('data', onData);
585+
this.process.stdout.removeListener('end', onEnd);
586+
587+
// Final check for parse errors
588+
if (parseError) {
589+
throw parseError;
590+
}
591+
478592
// After all messages are processed, wait for process to exit
479593
try {
480594
await this.process;
@@ -514,8 +628,6 @@ export class SubprocessCLITransport {
514628
// Ensure cleanup on any error
515629
await this.cleanup();
516630
throw error;
517-
} finally {
518-
rl.close();
519631
}
520632
}
521633

tests/subprocess-cli.test.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,17 @@ describe('SubprocessCLITransport', () => {
2626
const stdinStream = new Readable({ read() {} });
2727
(stdinStream as any).write = vi.fn();
2828
(stdinStream as any).end = vi.fn();
29+
(stdinStream as any).destroyed = false;
2930

3031
mockProcess = {
3132
stdout: stdoutStream,
3233
stderr: new Readable({ read() {} }),
3334
stdin: stdinStream,
3435
cancel: vi.fn(),
36+
killed: false,
37+
exitCode: null,
38+
on: vi.fn(),
39+
removeListener: vi.fn(),
3540
then: vi.fn((onfulfilled) => {
3641
// Simulate successful process completion
3742
if (onfulfilled) onfulfilled({ exitCode: 0 });
@@ -89,7 +94,7 @@ describe('SubprocessCLITransport', () => {
8994

9095
expect(execa).toHaveBeenCalledWith(
9196
'/usr/local/bin/claude-code',
92-
['test prompt', '--output-format', 'json'],
97+
['--output-format', 'stream-json', '--verbose', '--print'],
9398
expect.any(Object)
9499
);
95100
});
@@ -352,7 +357,8 @@ describe('SubprocessCLITransport', () => {
352357
await transport.connect();
353358

354359
setTimeout(() => {
355-
stdoutStream.push('invalid json\n');
360+
// Push invalid JSON that looks like JSON (starts with {) to trigger the error
361+
stdoutStream.push('{"invalid": json}');
356362
stdoutStream.push(null);
357363
}, 10);
358364

@@ -396,6 +402,94 @@ describe('SubprocessCLITransport', () => {
396402
// Skip this test for now - it's complex to test async stream + promise rejection timing
397403
// The actual functionality is tested in integration tests
398404
});
405+
406+
it('should handle large JSON responses exceeding 8000 bytes', async () => {
407+
vi.mocked(which as any).mockResolvedValue('/usr/local/bin/claude-code');
408+
vi.mocked(execa).mockReturnValue(mockProcess as any);
409+
410+
const transport = new SubprocessCLITransport('test prompt');
411+
await transport.connect();
412+
413+
// Create a large JSON object that exceeds 8000 bytes
414+
const largeContent = 'A'.repeat(8500); // Create content > 8000 bytes
415+
const largeMessage = {
416+
type: 'message',
417+
data: {
418+
type: 'assistant',
419+
content: [{ type: 'text', text: largeContent }]
420+
}
421+
};
422+
423+
const largeJsonString = JSON.stringify(largeMessage);
424+
expect(largeJsonString.length).toBeGreaterThan(8000); // Verify it's actually large
425+
426+
const messages = [largeMessage, { type: 'end' }];
427+
428+
// Emit the large JSON message to stdout
429+
setTimeout(() => {
430+
// Send the large JSON as a single chunk (no newlines, simulating the truncation scenario)
431+
stdoutStream.push(largeJsonString);
432+
stdoutStream.push(JSON.stringify({ type: 'end' }));
433+
stdoutStream.push(null); // End stream
434+
}, 10);
435+
436+
const received = [];
437+
for await (const msg of transport.receiveMessages()) {
438+
received.push(msg);
439+
}
440+
441+
expect(received).toHaveLength(2);
442+
expect(received[0]).toEqual(largeMessage);
443+
expect(received[1]).toEqual({ type: 'end' });
444+
445+
// Verify the large content was received intact
446+
expect((received[0] as any).data.content[0].text).toEqual(largeContent);
447+
expect((received[0] as any).data.content[0].text.length).toBe(8500);
448+
});
449+
450+
it('should handle JSON responses split across multiple data chunks', async () => {
451+
vi.mocked(which as any).mockResolvedValue('/usr/local/bin/claude-code');
452+
vi.mocked(execa).mockReturnValue(mockProcess as any);
453+
454+
const transport = new SubprocessCLITransport('test prompt');
455+
await transport.connect();
456+
457+
// Create a large JSON that will be split across chunks
458+
const largeContent = 'B'.repeat(9000);
459+
const largeMessage = {
460+
type: 'message',
461+
data: {
462+
type: 'assistant',
463+
content: [{ type: 'text', text: largeContent }]
464+
}
465+
};
466+
467+
const largeJsonString = JSON.stringify(largeMessage);
468+
const midpoint = Math.floor(largeJsonString.length / 2);
469+
470+
// Split the JSON into two chunks to simulate data arriving in parts
471+
const chunk1 = largeJsonString.substring(0, midpoint);
472+
const chunk2 = largeJsonString.substring(midpoint);
473+
474+
setTimeout(() => {
475+
// Send JSON split across multiple chunks
476+
stdoutStream.push(chunk1);
477+
setTimeout(() => {
478+
stdoutStream.push(chunk2);
479+
stdoutStream.push(JSON.stringify({ type: 'end' }));
480+
stdoutStream.push(null);
481+
}, 5);
482+
}, 10);
483+
484+
const received = [];
485+
for await (const msg of transport.receiveMessages()) {
486+
received.push(msg);
487+
}
488+
489+
expect(received).toHaveLength(2);
490+
expect(received[0]).toEqual(largeMessage);
491+
expect((received[0] as any).data.content[0].text.length).toBe(9000);
492+
});
399493
});
400494

401495
describe('disconnect', () => {

0 commit comments

Comments
 (0)